mirror of
https://github.com/openai/codex.git
synced 2026-10-15 16:48:52 +00:00
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:
@@ -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;
|
||||
|
||||
+8
@@ -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
|
||||
Reference in New Issue
Block a user