From 0e2300c02c72c8da0ea3e6976d84c48b980399c0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 27 Apr 2026 09:54:25 -0700 Subject: [PATCH] Persist shell mode commands in prompt history (#19618) ## Why `!` shell commands are currently surfaced as "Bash mode", which is misleading for users running shells such as PowerShell or zsh. Those commands also bypass the persistent prompt history path, so they cannot be recalled after starting a new session. Fixes #19613. ## What changed - Rename the TUI footer label and related test wording from "Bash mode" to "Shell mode". - Persist accepted `!` shell commands to prompt history with the leading `!`, so recall restores the composer into shell mode across sessions. - Add coverage for immediate and queued shell-command submissions emitting the prompt-history update. ## Verification - `cargo test -p codex-tui bang_shell` - `cargo test -p codex-tui shell_command_uses_shell_accent_style` - `cargo test -p codex-tui footer_mode_snapshots` - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` Manually verified fix after confirming presence of bug prior to fix. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 14 ++++++------- ...ooter_mode_shell_command_absorbs_bang.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 21 +++++++++++++++++-- .../tui/src/chatwidget/tests/exec_flow.rs | 4 ++++ .../src/chatwidget/tests/slash_commands.rs | 9 +++++++- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9b67e29b5..1a0e39d9c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1160,7 +1160,7 @@ impl ChatComposer { /// Convert canonical composer text into the textarea's internal representation. /// - /// Bash mode stores the leading `!` as prompt state instead of editable text, + /// Shell 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, @@ -2934,7 +2934,7 @@ impl ChatComposer { fn shell_mode_footer_line(&self) -> Option> { self.is_bang_shell_command() .then_some(()) - .map(|_| Line::from(vec![Span::from("Bash mode").light_red()])) + .map(|_| Line::from(vec![Span::from("Shell mode").light_red()])) } /// Applies any due `PasteBurst` flush at time `now`. @@ -4660,7 +4660,7 @@ mod tests { } #[test] - fn shell_command_uses_bash_accent_style() { + fn shell_command_uses_shell_accent_style() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -4688,11 +4688,11 @@ mod tests { 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"); + let shell_label_x = footer_text + .find("Shell mode") + .expect("expected shell mode footer label"); assert_eq!( - buf[(bash_label_x as u16, footer_y)].style().fg, + buf[(shell_label_x as u16, footer_y)].style().fg, Some(Color::LightRed) ); } 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 index 2b44670df..0b09860b5 100644 --- 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 @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used Bash mode " +" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used Shell mode " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1c08c7748..f5454c2bf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6151,9 +6151,26 @@ impl ChatWidget { } } + fn submit_shell_command_with_history( + &mut self, + command: &str, + history_text: &str, + ) -> QueueDrain { + let drain = self.submit_shell_command(command); + if drain == QueueDrain::Stop { + self.submit_op(Op::AddToHistory { + text: history_text.to_string(), + }); + } + drain + } + fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { match user_message.text.strip_prefix('!') { - Some(command) => self.submit_shell_command(command), + Some(command) => { + let history_text = user_message.text.clone(); + self.submit_shell_command_with_history(command, &history_text) + } None => { self.submit_user_message(user_message); QueueDrain::Stop @@ -6249,7 +6266,7 @@ impl ChatWidget { if shell_escape_policy == ShellEscapePolicy::Allow && let Some(stripped) = text.strip_prefix('!') { - let app_command = match self.submit_shell_command(stripped) { + let app_command = match self.submit_shell_command_with_history(stripped, &text) { QueueDrain::Continue => None, QueueDrain::Stop => Some(AppCommand::run_user_shell_command( stripped.trim().to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 206564d06..84d7cd721 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1038,6 +1038,10 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected RunUserShellCommand op, got {other:?}"), } + assert_matches!( + op_rx.try_recv(), + Ok(Op::AddToHistory { text }) if text == "!echo hi" + ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6da41423f..f88846be9 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -210,6 +210,10 @@ async fn queued_bang_shell_dispatches_after_active_turn() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } + assert_matches!( + op_rx.try_recv(), + Ok(Op::AddToHistory { text }) if text == "!echo hi" + ); assert!(chat.queued_user_messages.is_empty()); } @@ -287,7 +291,10 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!( + op_rx.try_recv(), + Ok(Op::AddToHistory { text }) if text == "!echo hi" + ); assert_eq!(chat.queued_user_messages.len(), 1); let begin = begin_exec_with_source(