Escape command drafts when recovering question answers (#49678)

## Why

When a turn ends, recovered question answers are appended to the composer draft. If that draft starts with a shell or slash command, submitting the combined text can interpret it as a command instead of a prompt.

## What changed

Escape command prefixes before appending recovered answers so the combined draft stays editable and submits as literal prompt text. Convert shell mode's separate `!` prefix into escaped composer text, and leave slash-prefixed paths such as `/tmp/file` unchanged.

Keep prefix escaping and answer recovery in the same Vim undo edit.

## Testing

Add regression coverage for immediate and queued submission, whitespace around command prefixes, slash-prefixed paths, and already escaped commands. Extend Vim undo coverage to shell and slash command drafts, including shell mode, and add a snapshot of the recovered shell draft.

GitOrigin-RevId: 03c40f72a834a65bf367103cb9170a86dc35ad98
This commit is contained in:
Ian MacLeod
2026-09-30 17:06:14 +00:00
committed by copyberry
parent ed0cc1a4ab
commit fcbed044c8
5 changed files with 103 additions and 0 deletions
@@ -99,6 +99,9 @@
//! The append flushes buffered input, dismisses unused sparkle eligibility, and adds a newline
//! after existing text. The separator and recovered answer form one Vim edit; large answers
//! use atomic paste placeholders backed by their original text.
//! Recovery escapes a shell or slash command prefix before appending answers, keeping the
//! combined draft editable and its later submission literal. Shell mode's separate `!` becomes
//! text in the composer; slash-prefixed paths remain unchanged because they submit as prompts.
//!
//! # Startup Draft Handoff
//!
@@ -2,6 +2,7 @@
//! Recovered appends cancel pending Vim commands before moving the cursor and stay out of dot repeat.
//! Normal-mode recovery is one undoable edit; active insert/replace sessions keep their grouping.
//! Paste payloads stay intact and sparkle stays dismissed.
//! Command prefixes become escaped, editable prompt text before answers are appended.
use super::super::textarea::VimPersistentState;
use super::*;
@@ -30,6 +31,19 @@ impl ChatComposer {
.textarea
.swap_vim_persistent_state(&mut vim_state);
let started_vim_edit = self.begin_direct_vim_edit();
// Recovered answers turn the draft into a prompt, so escape any command prefix
// before appending them. Shell mode stores its `!` outside the textarea.
let text = self.draft.textarea.text();
let slash_command_draft = self.slash_input().should_parse_on_dequeue(text)
&& parse_slash_name(text.trim_start()).is_none_or(|(name, _, _)| !name.contains('/'));
if self.draft.is_bash_mode {
self.draft.is_bash_mode = false;
self.draft.textarea.insert_str_at(/*pos*/ 0, "\\!");
} else if self.is_bang_shell_command() || slash_command_draft {
let prefix_len =
self.draft.textarea.text().len() - self.draft.textarea.text().trim_start().len();
self.draft.textarea.insert_str_at(prefix_len, "\\");
}
self.move_cursor_to_end();
if !self.current_text().is_empty() {
self.insert_str("\n");
@@ -159,6 +159,21 @@ fn recovered_answers_undo_as_one_vim_edit() {
ctrl_r(&mut composer);
assert_eq!(composer.draft_snapshot(), after);
}
for command in ["!echo", "/diff"] {
let mut composer = vim_composer(command);
let before = composer.draft_snapshot();
composer.append_recovered_drafts("answer");
assert_eq!(composer.current_text(), format!("\\{command}\nanswer"));
keys(&mut composer, "u");
assert_eq!(composer.draft_snapshot(), before);
}
let mut composer = vim_composer("echo");
composer.draft.is_bash_mode = true;
let before = composer.draft_snapshot();
composer.append_recovered_drafts("answer");
assert_eq!(composer.current_text(), "\\!echo\nanswer");
keys(&mut composer, "u");
assert_eq!(composer.draft_snapshot(), before);
snapshot_composer_state_with_width(
"recovered_answer_cancels_vim_search",
/*width*/ 40,
@@ -1,6 +1,7 @@
//! Live terminal turns recover open and collapsed question drafts into the composer.
use super::*;
use crate::bottom_pane::QueuedInputAction;
use codex_protocol::items::AsyncUserInputQuestion;
use pretty_assertions::assert_eq;
@@ -14,6 +15,68 @@ fn questions(chat: &mut ChatWidget, message_id: &str) {
);
}
#[tokio::test]
async fn question_turn_end_escapes_command_drafts_before_submission() {
for (draft, escaped, queued_action) in [
("!echo partial", "\\!echo partial", QueuedInputAction::Plain),
(
" !echo partial",
" \\!echo partial",
QueuedInputAction::Plain,
),
("/", "\\/", QueuedInputAction::Plain),
("/diff", "\\/diff", QueuedInputAction::Plain),
("\t/diff", "\t\\/diff", QueuedInputAction::Plain),
(" /diff", " /diff", QueuedInputAction::Plain),
("/tmp/file", "/tmp/file", QueuedInputAction::ParseSlash),
("\\/diff", "\\/diff", QueuedInputAction::Plain),
] {
for queued in [false, true] {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.show_welcome_banner = false;
handle_turn_started(&mut chat, "turn");
questions(&mut chat, "question");
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.handle_paste("answer".into());
chat.bottom_pane
.set_composer_text(draft.into(), Vec::new(), Vec::new());
handle_turn_completed(&mut chat, "turn", /*duration_ms*/ None);
let expected = format!("{escaped}\nanswer");
let submitted_text = expected.trim().to_string();
assert_eq!(chat.bottom_pane.composer_text(), expected);
if draft == "!echo partial" && !queued {
insta::assert_snapshot!(
"question_turn_end_escaped_shell_command",
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
);
}
if queued {
chat.on_task_started();
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
let queued_message = chat.input_queue.queued_user_messages.front().unwrap();
assert_eq!(queued_message.text, submitted_text);
assert_eq!(queued_message.action, queued_action);
assert!(ops.try_recv().is_err());
} else {
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let Op::UserTurn { items, .. } = ops.try_recv().unwrap() else {
panic!("expected user turn");
};
assert_eq!(
items,
vec![UserInput::Text {
text: submitted_text,
text_elements: Vec::new(),
}]
);
}
}
}
}
#[tokio::test]
async fn question_turn_end_appends_open_drafts_in_order_once() {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/question_turn_end_tests.rs
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))"
---
› \!echo partial
answer
GPT-5.6-Sol default · /tmp/project