From ef071cf816950dc416b2a975e7ed023eea639026 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Tue, 21 Apr 2026 00:15:49 -0700 Subject: [PATCH] show bash mode in the TUI (#18271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 372 ++++++++++++++++-- ...ooter_mode_shell_command_absorbs_bang.snap | 13 + 2 files changed, 356 insertions(+), 29 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index df506fb6f..a529d169c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, + 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 = 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 { + 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 { + 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, + ) -> (String, Vec) { + 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 { 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 { - 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)> { - 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 { - 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 { - 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> { + 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) { let elements_after: HashSet = 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> { + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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::(); @@ -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::(); + 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::(); + 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::(); + 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::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap new file mode 100644 index 000000000..2b44670df --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap @@ -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 "