show bash mode in the TUI (#18271)

## What

- Explicitly show our "bash mode" by changing the color and adding a
callout similar to how we do for `Plan mode (shift + tab to cycle)`
- Also replace our `›` composer prefix with a bang `!`


![](https://github.com/user-attachments/assets/f5549c75-3a03-433d-aa57-e4c6d0682c49)

## Why

- It was unclear that we had a Bash mode
- This feels more responsive
- It looks cool!

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-21 00:15:49 -07:00
committed by GitHub
Unverified
parent a3ed5068c1
commit ef071cf816
2 changed files with 356 additions and 29 deletions
+343 -29
View File
@@ -225,6 +225,10 @@ use std::ops::Range;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
#[cfg(test)]
use ratatui::style::Color;
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -315,6 +319,7 @@ impl ChatComposerConfig {
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
is_bash_mode: bool,
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
@@ -471,6 +476,7 @@ impl ChatComposer {
let mut this = Self {
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
is_bash_mode: false,
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
@@ -710,6 +716,7 @@ impl ChatComposer {
/// Returns true if the composer currently contains no user-entered input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
&& !self.is_bash_mode
&& self.attached_images.is_empty()
&& self.remote_image_urls.is_empty()
}
@@ -847,6 +854,7 @@ impl ChatComposer {
/// remote images). Cursor is placed at the end after rebuilding elements.
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.pending_pastes.clear();
let (text, _) = self.imported_text_for_textarea(text, Vec::new());
// Count placeholder occurrences in the new text.
let mut placeholder_counts: HashMap<String, usize> = HashMap::new();
@@ -914,7 +922,7 @@ impl ChatComposer {
}
pub(crate) fn current_text_with_pending(&self) -> String {
let mut text = self.textarea.text().to_string();
let mut text = self.current_text();
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
@@ -928,7 +936,7 @@ impl ChatComposer {
}
pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) {
let text = self.textarea.text().to_string();
let text = self.current_text();
self.pending_pastes = pending_pastes
.into_iter()
.filter(|(placeholder, _)| text.contains(placeholder))
@@ -1015,10 +1023,12 @@ impl ChatComposer {
) {
// Clear any existing content, placeholders, and attachments first.
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
self.pending_pastes.clear();
self.attached_images.clear();
self.mention_bindings.clear();
let (text, text_elements) = self.imported_text_for_textarea(text, text_elements);
self.textarea.set_text_with_elements(&text, &text_elements);
for (idx, path) in local_image_paths.into_iter().enumerate() {
@@ -1034,10 +1044,51 @@ impl ChatComposer {
self.sync_popups();
}
fn current_cursor(&self) -> usize {
self.textarea.cursor() + if self.is_bash_mode { 1 } else { 0 }
}
fn history_navigation_cursor(&self) -> usize {
if self.is_bash_mode && self.textarea.cursor() == 0 {
0
} else {
self.current_cursor()
}
}
fn set_current_cursor(&mut self, cursor: usize) {
let visible_cursor = if self.is_bash_mode {
cursor.saturating_sub(1)
} else {
cursor
};
self.textarea
.set_cursor(visible_cursor.min(self.textarea.text().len()));
}
fn current_text_elements(&self) -> Vec<TextElement> {
let shift = if self.is_bash_mode { 1 } else { 0 };
self.textarea
.text_elements()
.into_iter()
.filter_map(|element| Self::shift_text_element(element, shift))
.collect()
}
fn shift_text_element(element: TextElement, shift: isize) -> Option<TextElement> {
let start = element.byte_range.start.checked_add_signed(shift)?;
let end = element.byte_range.end.checked_add_signed(shift)?;
if start >= end {
return None;
}
Some(element.map_range(|_| (start..end).into()))
}
fn snapshot_draft(&self) -> ComposerDraft {
ComposerDraft {
text: self.textarea.text().to_string(),
text_elements: self.textarea.text_elements(),
text: self.current_text(),
text_elements: self.current_text_elements(),
local_image_paths: self
.attached_images
.iter()
@@ -1046,7 +1097,7 @@ impl ChatComposer {
remote_image_urls: self.remote_image_urls.clone(),
mention_bindings: self.snapshot_mention_bindings(),
pending_pastes: self.pending_pastes.clone(),
cursor: self.textarea.cursor(),
cursor: self.current_cursor(),
}
}
@@ -1068,8 +1119,7 @@ impl ChatComposer {
mention_bindings,
);
self.set_pending_pastes(pending_pastes);
self.textarea
.set_cursor(cursor.min(self.textarea.text().len()));
self.set_current_cursor(cursor);
self.sync_popups();
}
@@ -1084,12 +1134,36 @@ impl ChatComposer {
self.sync_popups();
}
/// Convert canonical composer text into the textarea's internal representation.
///
/// Bash mode stores the leading `!` as prompt state instead of editable text,
/// so full-buffer imports must absorb that prefix before rebuilding the textarea.
fn imported_text_for_textarea(
&mut self,
text: String,
text_elements: Vec<TextElement>,
) -> (String, Vec<TextElement>) {
if let Some(stripped) = text.strip_prefix('!') {
self.is_bash_mode = true;
(
stripped.to_string(),
text_elements
.into_iter()
.filter_map(|element| Self::shift_text_element(element, /*shift*/ -1))
.collect(),
)
} else {
self.is_bash_mode = false;
(text, text_elements)
}
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;
}
let previous = self.current_text();
let text_elements = self.textarea.text_elements();
let text_elements = self.current_text_elements();
let local_image_paths = self
.attached_images
.iter()
@@ -1115,7 +1189,11 @@ impl ChatComposer {
/// Get the current composer text.
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
if self.is_bash_mode {
format!("!{}", self.textarea.text())
} else {
self.textarea.text().to_string()
}
}
/// Rehydrate a history entry into the composer with shell-like cursor placement.
@@ -1146,7 +1224,7 @@ impl ChatComposer {
}
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
self.textarea.text_elements()
self.current_text_elements()
}
#[cfg(test)]
@@ -1325,6 +1403,7 @@ impl ChatComposer {
pub(crate) fn insert_str(&mut self, text: &str) {
self.textarea.insert_str(text);
self.sync_bash_mode_from_text();
self.sync_popups();
}
@@ -1426,6 +1505,7 @@ impl ChatComposer {
if cmd == SlashCommand::Skills {
self.stage_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
return (InputResult::Command(cmd), true);
}
@@ -1466,6 +1546,7 @@ impl ChatComposer {
if !starts_with_cmd {
self.textarea
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
self.is_bash_mode = false;
}
if !self.textarea.text().is_empty() {
self.textarea.set_cursor(self.textarea.text().len());
@@ -1482,6 +1563,7 @@ impl ChatComposer {
let CommandItem::Builtin(cmd) = sel;
self.stage_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
return (InputResult::Command(cmd), true);
}
// Fallback to default newline handling if no command selected.
@@ -2241,9 +2323,9 @@ impl ChatComposer {
record_history: bool,
slash_validation: SlashValidation,
) -> Option<(String, Vec<TextElement>)> {
let mut text = self.textarea.text().to_string();
let mut text = self.current_text();
let original_input = text.clone();
let original_text_elements = self.textarea.text_elements();
let original_text_elements = self.current_text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self
.attached_images
@@ -2255,6 +2337,7 @@ impl ChatComposer {
let input_starts_with_space = original_input.starts_with(' ');
self.recent_submission_mention_bindings.clear();
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
if !self.pending_pastes.is_empty() {
// Expand placeholders so element byte ranges stay aligned.
@@ -2394,6 +2477,7 @@ impl ChatComposer {
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
let in_slash_context = self.slash_commands_enabled()
&& !self.is_bash_mode
&& (matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
@@ -2422,8 +2506,8 @@ impl ChatComposer {
return (InputResult::None, true);
}
let original_input = self.textarea.text().to_string();
let original_text_elements = self.textarea.text_elements();
let original_input = self.current_text();
let original_text_elements = self.current_text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self
.attached_images
@@ -2473,7 +2557,7 @@ impl ChatComposer {
/// Check if the first line is a bare slash command (no args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() {
if !self.slash_commands_enabled() || self.is_bash_mode {
return None;
}
let first_line = self.textarea.text().lines().next().unwrap_or("");
@@ -2489,6 +2573,7 @@ impl ChatComposer {
}
self.stage_slash_command_history();
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
Some(InputResult::Command(cmd))
} else {
None
@@ -2498,7 +2583,7 @@ impl ChatComposer {
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() {
if !self.slash_commands_enabled() || self.is_bash_mode {
return None;
}
let text = self.textarea.text().to_string();
@@ -2783,10 +2868,10 @@ impl ChatComposer {
modifiers: KeyModifiers::CONTROL,
..
} => {
if self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.cursor())
{
if self.history.should_handle_navigation(
&self.current_text(),
self.history_navigation_cursor(),
) {
let replace_entry = match key_event.code {
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
@@ -2819,7 +2904,13 @@ impl ChatComposer {
}
fn is_bang_shell_command(&self) -> bool {
self.textarea.text().trim_start().starts_with('!')
self.current_text().trim_start().starts_with('!')
}
fn shell_mode_footer_line(&self) -> Option<Line<'static>> {
self.is_bang_shell_command()
.then_some(())
.map(|_| Line::from(vec![Span::from("Bash mode").light_red()]))
}
/// Applies any due `PasteBurst` flush at time `now`.
@@ -2837,8 +2928,7 @@ impl ChatComposer {
true
}
FlushResult::Typed(ch) => {
self.textarea.insert_str(ch.to_string().as_str());
self.sync_popups();
self.insert_str(ch.to_string().as_str());
true
}
FlushResult::None => false,
@@ -2972,7 +3062,16 @@ impl ChatComposer {
Some(self.textarea.element_payloads())
};
if self.is_bash_mode
&& matches!(input.code, KeyCode::Backspace)
&& self.textarea.cursor() == 0
{
self.is_bash_mode = false;
return (InputResult::None, true);
}
self.textarea.input(input);
self.sync_bash_mode_from_text();
if let Some(elements_before) = elements_before {
self.reconcile_deleted_elements(elements_before);
@@ -3001,6 +3100,13 @@ impl ChatComposer {
(InputResult::None, true)
}
fn sync_bash_mode_from_text(&mut self) {
if !self.is_bash_mode && self.textarea.text().starts_with('!') {
self.textarea.replace_range(0..1, "");
self.is_bash_mode = true;
}
}
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
let elements_after: HashSet<String> =
self.textarea.element_payloads().into_iter().collect();
@@ -3156,7 +3262,7 @@ impl ChatComposer {
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.cursor());
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor());
// When browsing input history (shell-style Up/Down recall), skip all popup
// synchronization so nothing steals focus from continued history navigation.
if browsing_history {
@@ -3170,8 +3276,10 @@ impl ChatComposer {
}
let mention_token = self.current_mention_token();
let allow_command_popup =
self.slash_commands_enabled() && file_token.is_none() && mention_token.is_none();
let allow_command_popup = self.slash_commands_enabled()
&& !self.is_bash_mode
&& file_token.is_none()
&& mention_token.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
@@ -3255,6 +3363,9 @@ impl ChatComposer {
}
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
if self.is_bash_mode {
return None;
}
let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;
if name.contains('/') {
return None;
@@ -3856,6 +3967,8 @@ impl ChatComposer {
self.side_conversation_context_label.as_ref()
{
Some(side_conversation_context_line(label))
} else if let Some(line) = self.shell_mode_footer_line() {
Some(line)
} else if status_line_active {
let full =
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
@@ -4027,7 +4140,13 @@ impl ChatComposer {
}
if !textarea_rect.is_empty() {
let prompt = if self.input_enabled {
if is_zellij {
if self.is_bash_mode {
if is_zellij {
Span::from("!").light_red()
} else {
Span::from("!").light_red().bold()
}
} else if is_zellij {
Span::styled("", style.fg(ratatui::style::Color::Cyan))
} else {
"".bold()
@@ -4046,7 +4165,7 @@ impl ChatComposer {
}
let mut state = self.textarea_state.borrow_mut();
let textarea_is_empty = self.textarea.text().is_empty();
let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode;
if let Some(mask_char) = mask_char {
self.textarea.render_ref_masked(
textarea_rect,
@@ -4475,6 +4594,78 @@ mod tests {
.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_shell_command_absorbs_bang",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from(
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
)));
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
},
);
}
#[test]
fn shell_command_cursor_uses_absorbed_prefix() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let area = Rect::new(0, 0, 40, 5);
composer.set_text_content("!git".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
assert_eq!(composer.cursor_pos(area), Some((5, 1)));
composer.set_text_content("! git".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
assert_eq!(composer.cursor_pos(area), Some((6, 1)));
}
#[test]
fn shell_command_uses_bash_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from(
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
)));
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
let area = Rect::new(0, 0, 100, 9);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let prompt_cell = &buf[(0, 1)];
assert_eq!(prompt_cell.symbol(), "!");
assert_eq!(prompt_cell.style().fg, Some(Color::LightRed));
let footer_y = area.height - 1;
let footer_text = (0..area.width)
.map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' '))
.collect::<String>();
let bash_label_x = footer_text
.find("Bash mode")
.expect("expected bash mode footer label");
assert_eq!(
buf[(bash_label_x as u16, footer_y)].style().fg,
Some(Color::LightRed)
);
}
#[test]
@@ -7026,11 +7217,38 @@ mod tests {
assert!(matches!(result, InputResult::None));
assert!(
composer.textarea.text().starts_with("!ls"),
composer.current_text().starts_with("!ls"),
"expected Tab not to submit or clear a `!` command"
);
}
#[test]
fn bang_prefixed_slash_text_submits_literal_shell_command() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['!', '/', 'd', 'i', 'f', 'f']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
result,
InputResult::Submitted { ref text, .. } if text == "!/diff"
));
}
#[test]
fn slash_mention_dispatches_command_and_inserts_at() {
use crossterm::event::KeyCode;
@@ -7632,6 +7850,42 @@ mod tests {
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}
#[test]
fn history_navigation_from_start_of_bang_command_recalls_older_entry() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "!git");
composer.textarea.set_cursor(/*pos*/ 0);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "first");
}
#[test]
fn set_text_content_reattaches_images_without_placeholder_metadata() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -8552,6 +8806,66 @@ mod tests {
assert_eq!(composer.textarea.cursor(), composer.current_text().len());
}
#[test]
fn apply_external_edit_absorbs_bash_prefix_without_duplication() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
composer.apply_external_edit("!git status".to_string());
assert!(composer.is_bash_mode);
assert_eq!(composer.textarea.text(), "git status");
assert_eq!(composer.current_text(), "!git status");
}
#[test]
fn apply_external_edit_can_leave_bash_mode() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
composer.apply_external_edit("git status".to_string());
assert!(!composer.is_bash_mode);
assert_eq!(composer.textarea.text(), "git status");
assert_eq!(composer.current_text(), "git status");
}
#[test]
fn apply_external_edit_can_enter_bash_mode() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("git status".to_string(), Vec::new(), Vec::new());
composer.apply_external_edit("!git status".to_string());
assert!(composer.is_bash_mode);
assert_eq!(composer.textarea.text(), "git status");
assert_eq!(composer.current_text(), "!git status");
}
#[test]
fn apply_external_edit_drops_missing_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"! git status "
" "
" "
" "
" "
" "
" "
" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used Bash mode "