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.
This commit is contained in:
Eric Traut
2026-04-27 09:54:25 -07:00
committed by GitHub
Unverified
parent 6c51bf0c7c
commit 0e2300c02c
5 changed files with 39 additions and 11 deletions
@@ -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<Line<'static>> {
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::<AppEvent>();
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::<String>();
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)
);
}
@@ -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 "
+19 -2
View File
@@ -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(),
@@ -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));
}
@@ -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(