mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Preserve queued TUI input semantics (#39604)
## Why Paste placeholders and deferred slash-command parsing can hide the meaning of an input until it is expanded or a session is configured. Draining such input as an ordinary message can accidentally treat pasted text beginning with `!` as a shell command or lose `/plan` behavior. ## What changed - Track paste-expanded inputs that begin with `!` as literal model input through history, queue editing, retries, and startup queues. - Defer `/plan` parsing when session or model state is unavailable, then submit it in plan mode with shell escapes disabled. - Drain queued input only after session configuration, and keep later queued prompts pending when an initial prompt cannot be submitted. - Suppress queue auto-send before applying a replayed thread session so input from the outgoing thread is not submitted during restoration. ## Testing Add coverage for direct, recalled, edited, interrupted, rejected, startup-queued, and plan-mode paste flows, including placeholder collisions and rejected initial images. GitOrigin-RevId: d482220d8df21754f166fea5b54e9945f622a978
This commit is contained in:
1 parent
8e7f646974
commit
3434c2545b
10 files changed
+519
-36
No files matched your search
@@ -917,6 +917,27 @@ async fn replay_thread_snapshot_restores_draft_and_queued_input() {
|
|||||||
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
||||||
make_chatwidget_manual_with_sender().await;
|
make_chatwidget_manual_with_sender().await;
|
||||||
app.chat_widget = chat_widget;
|
app.chat_widget = chat_widget;
|
||||||
|
let outgoing_thread_id = ThreadId::new();
|
||||||
|
app.chat_widget.handle_thread_session(test_thread_session(
|
||||||
|
outgoing_thread_id,
|
||||||
|
test_path_buf("/tmp/outgoing-project"),
|
||||||
|
));
|
||||||
|
app.chat_widget.handle_server_notification(
|
||||||
|
turn_started_notification(outgoing_thread_id, "outgoing-turn"),
|
||||||
|
/*replay_kind*/ None,
|
||||||
|
);
|
||||||
|
app.chat_widget
|
||||||
|
.apply_external_edit("outgoing queued input".to_string());
|
||||||
|
app.chat_widget
|
||||||
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||||
|
app.chat_widget
|
||||||
|
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||||
|
app.chat_widget.handle_server_notification(
|
||||||
|
turn_completed_notification(outgoing_thread_id, "outgoing-turn", TurnStatus::Completed),
|
||||||
|
/*replay_kind*/ None,
|
||||||
|
);
|
||||||
|
app.chat_widget
|
||||||
|
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||||||
|
|
||||||
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
|
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
|
||||||
|
|
||||||
|
|||||||
@@ -1522,6 +1522,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
let suppress_replay_notices =
|
let suppress_replay_notices =
|
||||||
replay_filter::snapshot_has_pending_interactive_request(&snapshot);
|
replay_filter::snapshot_has_pending_interactive_request(&snapshot);
|
||||||
|
self.chat_widget
|
||||||
|
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||||
if let Some(session) = snapshot.session {
|
if let Some(session) = snapshot.session {
|
||||||
if session.reasoning_effort != Some(ReasoningEffortConfig::Ultra) {
|
if session.reasoning_effort != Some(ReasoningEffortConfig::Ultra) {
|
||||||
self.chat_widget
|
self.chat_widget
|
||||||
@@ -1535,8 +1537,6 @@ impl App {
|
|||||||
self.chat_widget.handle_thread_session(session);
|
self.chat_widget.handle_thread_session(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.chat_widget
|
|
||||||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
|
||||||
self.chat_widget.restore_thread_input_state(
|
self.chat_widget.restore_thread_input_state(
|
||||||
snapshot.input_state,
|
snapshot.input_state,
|
||||||
ThreadInputStateRestoreMode {
|
ThreadInputStateRestoreMode {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
//!
|
//!
|
||||||
//! - Expands pending paste placeholders so element ranges align with the final text.
|
//! - Expands pending paste placeholders so element ranges align with the final text.
|
||||||
//! - Trims whitespace and rebases text elements accordingly.
|
//! - Trims whitespace and rebases text elements accordingly.
|
||||||
|
//! - Treats a leading `!` revealed only by paste expansion as literal model input, not shell input.
|
||||||
//! - Prunes local attached images so only placeholders that survive expansion are sent.
|
//! - Prunes local attached images so only placeholders that survive expansion are sent.
|
||||||
//! - Preserves remote image URLs as separate attachments even when text is empty.
|
//! - Preserves remote image URLs as separate attachments even when text is empty.
|
||||||
//!
|
//!
|
||||||
@@ -424,6 +425,8 @@ fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum QueuedInputAction {
|
pub enum QueuedInputAction {
|
||||||
Plain,
|
Plain,
|
||||||
|
/// Preserve model-input provenance when paste expansion reveals a leading shell sigil.
|
||||||
|
Literal,
|
||||||
ParseSlash,
|
ParseSlash,
|
||||||
RunShell,
|
RunShell,
|
||||||
}
|
}
|
||||||
@@ -3007,13 +3010,29 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
self.draft.recent_submission_mention_bindings = original_mention_bindings.clone();
|
self.draft.recent_submission_mention_bindings = original_mention_bindings.clone();
|
||||||
if record_history && (!text.is_empty() || !self.attachments.is_empty()) {
|
if record_history && (!text.is_empty() || !self.attachments.is_empty()) {
|
||||||
|
let preserve_literal_paste = self.slash_commands_enabled()
|
||||||
|
&& text.starts_with('!')
|
||||||
|
&& !original_input.trim_start().starts_with('!');
|
||||||
|
let (history_text, history_text_elements) = if preserve_literal_paste {
|
||||||
|
let history_text = original_input.trim().to_string();
|
||||||
|
let history_text_elements = Self::trim_text_elements(
|
||||||
|
&original_input,
|
||||||
|
&history_text,
|
||||||
|
original_text_elements,
|
||||||
|
);
|
||||||
|
(history_text, history_text_elements)
|
||||||
|
} else {
|
||||||
|
(text.clone(), text_elements.clone())
|
||||||
|
};
|
||||||
self.history.record_local_submission(HistoryEntry {
|
self.history.record_local_submission(HistoryEntry {
|
||||||
text: text.clone(),
|
text: history_text,
|
||||||
text_elements: text_elements.clone(),
|
text_elements: history_text_elements,
|
||||||
local_image_paths: self.attachments.local_image_paths(),
|
local_image_paths: self.attachments.local_image_paths(),
|
||||||
remote_image_urls: self.attachments.remote_image_urls(),
|
remote_image_urls: self.attachments.remote_image_urls(),
|
||||||
mention_bindings: original_mention_bindings,
|
mention_bindings: original_mention_bindings,
|
||||||
pending_pastes: if pending_paste_handling == PendingPasteHandling::Preserve {
|
pending_pastes: if pending_paste_handling == PendingPasteHandling::Preserve
|
||||||
|
|| preserve_literal_paste
|
||||||
|
{
|
||||||
original_pending_pastes.clone()
|
original_pending_pastes.clone()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -3091,6 +3110,10 @@ impl ChatComposer {
|
|||||||
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
|
||||||
self.handle_paste(pasted);
|
self.handle_paste(pasted);
|
||||||
}
|
}
|
||||||
|
let visible_shell_command = self.is_bang_shell_command();
|
||||||
|
let original_input = self.current_text();
|
||||||
|
let original_text_elements = self.current_text_elements();
|
||||||
|
let original_pending_pastes = self.draft.pending_pastes.clone();
|
||||||
let raw_text = self.draft.textarea.text();
|
let raw_text = self.draft.textarea.text();
|
||||||
let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
|
let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
|
||||||
let preserve_pending_pastes = defer_slash_validation
|
let preserve_pending_pastes = defer_slash_validation
|
||||||
@@ -3115,7 +3138,20 @@ impl ChatComposer {
|
|||||||
PendingPasteHandling::Expand
|
PendingPasteHandling::Expand
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let action = slash_input::queued_input_action(&text, defer_slash_validation);
|
let action = if text.starts_with('!') && !visible_shell_command {
|
||||||
|
QueuedInputAction::Literal
|
||||||
|
} else {
|
||||||
|
slash_input::queued_input_action(&text, defer_slash_validation)
|
||||||
|
};
|
||||||
|
let (text, text_elements, pending_pastes) = if action == QueuedInputAction::Literal
|
||||||
|
{
|
||||||
|
let text = original_input.trim().to_string();
|
||||||
|
let text_elements =
|
||||||
|
Self::trim_text_elements(&original_input, &text, original_text_elements);
|
||||||
|
(text, text_elements, original_pending_pastes)
|
||||||
|
} else {
|
||||||
|
(text, text_elements, pending_pastes)
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
InputResult::Queued {
|
InputResult::Queued {
|
||||||
text,
|
text,
|
||||||
@@ -3152,13 +3188,19 @@ impl ChatComposer {
|
|||||||
if let Some((text, text_elements)) =
|
if let Some((text, text_elements)) =
|
||||||
self.prepare_submission_text(/*record_history*/ true)
|
self.prepare_submission_text(/*record_history*/ true)
|
||||||
{
|
{
|
||||||
if should_queue {
|
if self.slash_commands_enabled()
|
||||||
|
&& text.starts_with('!')
|
||||||
|
&& !original_input.trim_start().starts_with('!')
|
||||||
|
{
|
||||||
|
let text = original_input.trim().to_string();
|
||||||
|
let text_elements =
|
||||||
|
Self::trim_text_elements(&original_input, &text, original_text_elements);
|
||||||
(
|
(
|
||||||
InputResult::Queued {
|
InputResult::Queued {
|
||||||
text,
|
text,
|
||||||
text_elements,
|
text_elements,
|
||||||
action: QueuedInputAction::Plain,
|
action: QueuedInputAction::Literal,
|
||||||
pending_pastes: Vec::new(),
|
pending_pastes: original_pending_pastes,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ impl ChatWidget {
|
|||||||
|
|
||||||
/// If idle and there are queued inputs, submit exactly one to start the next turn.
|
/// If idle and there are queued inputs, submit exactly one to start the next turn.
|
||||||
pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool {
|
pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool {
|
||||||
if self.misalignment_policy_violation || self.input_queue.suppress_queue_autosend {
|
if !self.is_session_configured()
|
||||||
|
|| self.misalignment_policy_violation
|
||||||
|
|| self.input_queue.suppress_queue_autosend
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if self.blocks_direct_input {
|
if self.blocks_direct_input {
|
||||||
@@ -160,6 +163,48 @@ impl ChatWidget {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
QueuedInputAction::Literal => {
|
||||||
|
let QueuedUserMessage {
|
||||||
|
user_message,
|
||||||
|
pending_pastes,
|
||||||
|
..
|
||||||
|
} = queued_message;
|
||||||
|
let mut restored_pending_pastes = self.bottom_pane.composer_pending_pastes();
|
||||||
|
let mut used_paste_placeholders = restored_pending_pastes
|
||||||
|
.iter()
|
||||||
|
.map(|(placeholder, _)| placeholder.clone())
|
||||||
|
.collect();
|
||||||
|
let (mut user_message, pending_pastes) =
|
||||||
|
super::user_messages::remap_colliding_paste_placeholders(
|
||||||
|
user_message,
|
||||||
|
pending_pastes,
|
||||||
|
&mut used_paste_placeholders,
|
||||||
|
);
|
||||||
|
if !self.current_model().trim().is_empty()
|
||||||
|
&& (self.current_model_supports_images()
|
||||||
|
|| (user_message.local_images.is_empty()
|
||||||
|
&& user_message.remote_image_urls.is_empty()))
|
||||||
|
{
|
||||||
|
(user_message.text, user_message.text_elements) =
|
||||||
|
crate::bottom_pane::ChatComposer::expand_pending_pastes(
|
||||||
|
&user_message.text,
|
||||||
|
user_message.text_elements,
|
||||||
|
&pending_pastes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
submitted_follow_up = self
|
||||||
|
.submit_user_message_with_shell_escape_policy(
|
||||||
|
user_message,
|
||||||
|
ShellEscapePolicy::Disallow,
|
||||||
|
)
|
||||||
|
.is_some();
|
||||||
|
if !submitted_follow_up {
|
||||||
|
restored_pending_pastes.extend(pending_pastes);
|
||||||
|
self.bottom_pane
|
||||||
|
.set_composer_pending_pastes(restored_pending_pastes);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
QueuedInputAction::ParseSlash => {
|
QueuedInputAction::ParseSlash => {
|
||||||
let drain = self.submit_queued_slash_prompt(queued_message);
|
let drain = self.submit_queued_slash_prompt(queued_message);
|
||||||
if drain == QueueDrain::Stop {
|
if drain == QueueDrain::Stop {
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ impl ChatWidget {
|
|||||||
self.transcript.saw_copy_source_this_turn = false;
|
self.transcript.saw_copy_source_this_turn = false;
|
||||||
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
|
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
|
||||||
self.refresh_connector_mentions(/*force_refresh*/ false);
|
self.refresh_connector_mentions(/*force_refresh*/ false);
|
||||||
|
let initial_user_message_pending = self.initial_user_message.is_some();
|
||||||
self.submit_initial_user_message_if_pending();
|
self.submit_initial_user_message_if_pending();
|
||||||
|
if self.mcp_startup_status.is_none()
|
||||||
|
&& (!initial_user_message_pending || self.is_user_turn_pending_or_running())
|
||||||
|
{
|
||||||
|
self.maybe_send_next_queued_input();
|
||||||
|
}
|
||||||
if display == SessionConfiguredDisplay::Normal
|
if display == SessionConfiguredDisplay::Normal
|
||||||
&& let Some(forked_from_id) = forked_from_id
|
&& let Some(forked_from_id) = forked_from_id
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -784,7 +784,7 @@ impl ChatWidget {
|
|||||||
if !self.apply_plan_slash_command() {
|
if !self.apply_plan_slash_command() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let user_message = self.prepared_inline_user_message(
|
let mut user_message = self.prepared_inline_user_message(
|
||||||
args,
|
args,
|
||||||
text_elements,
|
text_elements,
|
||||||
local_images,
|
local_images,
|
||||||
@@ -792,14 +792,34 @@ impl ChatWidget {
|
|||||||
mention_bindings,
|
mention_bindings,
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
|
if !self.is_session_configured()
|
||||||
|
|| self.current_model().trim().is_empty()
|
||||||
|
|| (!self.current_model_supports_images()
|
||||||
|
&& (!user_message.local_images.is_empty()
|
||||||
|
|| !user_message.remote_image_urls.is_empty()))
|
||||||
|
{
|
||||||
|
const PLAN_PREFIX: &str = "/plan ";
|
||||||
|
user_message.text.insert_str(0, PLAN_PREFIX);
|
||||||
|
for element in &mut user_message.text_elements {
|
||||||
|
element.byte_range.start += PLAN_PREFIX.len();
|
||||||
|
element.byte_range.end += PLAN_PREFIX.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.is_session_configured() {
|
if self.is_session_configured() {
|
||||||
self.reasoning_buffer.clear();
|
self.reasoning_buffer.clear();
|
||||||
self.reasoning_header = None;
|
self.reasoning_header = None;
|
||||||
self.reasoning_summary_parts.clear();
|
self.reasoning_summary_parts.clear();
|
||||||
self.set_status_header(String::from("Working"));
|
self.set_status_header(String::from("Working"));
|
||||||
self.submit_user_message(user_message);
|
self.submit_user_message_with_shell_escape_policy(
|
||||||
|
user_message,
|
||||||
|
ShellEscapePolicy::Disallow,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
self.queue_user_message(user_message);
|
self.queue_user_message_with_options(
|
||||||
|
user_message,
|
||||||
|
QueuedInputAction::ParseSlash,
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SlashCommand::Goal if !trimmed.is_empty() => {
|
SlashCommand::Goal if !trimmed.is_empty() => {
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/chatwidget/tests/composer_submission.rs
|
||||||
|
expression: normalize_snapshot_paths(render_bottom_popup(&chat, 80))
|
||||||
|
---
|
||||||
|
• Working (0s • esc to interrupt)
|
||||||
|
|
||||||
|
• Queued follow-up inputs
|
||||||
|
↳ [Pasted Content 1006 chars]
|
||||||
|
⌥ + ↑ edit last queued message
|
||||||
|
|
||||||
|
› Ask Codex to do anything
|
||||||
|
|
||||||
|
gpt-5.6-sol default · /tmp/project
|
||||||
@@ -9,6 +9,177 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
fn paste_hidden_shell_payload(chat: &mut ChatWidget) -> String {
|
||||||
|
let payload = format!("!echo {}", "x".repeat(1000));
|
||||||
|
chat.handle_paste(payload.clone());
|
||||||
|
assert_eq!(
|
||||||
|
chat.bottom_pane.composer_text(),
|
||||||
|
format!("[Pasted Content {} chars]", payload.len())
|
||||||
|
);
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_hidden_shell_payload_is_literal(op: Result<Op, TryRecvError>, payload: String) {
|
||||||
|
match op {
|
||||||
|
Ok(Op::UserTurn { items, .. }) => assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![UserInput::Text {
|
||||||
|
text: payload,
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]
|
||||||
|
),
|
||||||
|
other => panic!("expected hidden shell payload as literal input, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_recalled_from_history_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload.clone());
|
||||||
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_queued_during_turn_submits_literal_prompt() {
|
||||||
|
for key in [KeyCode::Tab, KeyCode::Enter] {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||||||
|
if key == KeyCode::Tab {
|
||||||
|
assert_chatwidget_snapshot!(
|
||||||
|
"hidden_shell_paste_queued_preview",
|
||||||
|
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_restored_by_queue_edit_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
|
||||||
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||||
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_restored_by_interrupt_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||||
|
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_restored_after_image_rejection_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let current_model = chat.current_model().to_string();
|
||||||
|
let mut models = chat.model_catalog.try_list_models().expect("model catalog");
|
||||||
|
models
|
||||||
|
.iter_mut()
|
||||||
|
.find(|model| model.model == current_model)
|
||||||
|
.expect("current model")
|
||||||
|
.input_modalities
|
||||||
|
.retain(|modality| *modality != InputModality::Image);
|
||||||
|
chat.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||||
|
chat.set_remote_image_urls(vec!["https://example.com/image.png".to_string()]);
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
chat.set_remote_image_urls(Vec::new());
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_restored_after_unavailable_model_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let model = chat.current_model().to_string();
|
||||||
|
chat.set_model("");
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
chat.set_model(&model);
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejected_hidden_shell_paste_preserves_colliding_draft_paste() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let model = chat.current_model().to_string();
|
||||||
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||||
|
let draft_payload = format!("draft {}", "y".repeat(1000));
|
||||||
|
chat.handle_paste(draft_payload.clone());
|
||||||
|
chat.set_model("");
|
||||||
|
|
||||||
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||||
|
chat.set_model(&model);
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), format!("{payload}\n{draft_payload}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hidden_shell_paste_queued_before_session_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.set_queue_submissions_until_session_configured(/*queue*/ true);
|
||||||
|
let payload = paste_hidden_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
chat.maybe_send_next_queued_input();
|
||||||
|
|
||||||
|
assert_hidden_shell_payload_is_literal(op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
|
async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
|
||||||
let (mut chat, mut rx, mut op_rx) =
|
let (mut chat, mut rx, mut op_rx) =
|
||||||
|
|||||||
@@ -7,6 +7,67 @@ fn set_composer_text(chat: &mut ChatWidget, text: &str) {
|
|||||||
chat.refresh_plan_mode_nudge();
|
chat.refresh_plan_mode_nudge();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn paste_hidden_plan_shell_payload(chat: &mut ChatWidget) -> String {
|
||||||
|
let payload = format!("!echo {}", "x".repeat(1000));
|
||||||
|
chat.bottom_pane
|
||||||
|
.set_composer_text("/plan ".to_string(), Vec::new(), Vec::new());
|
||||||
|
chat.handle_paste(payload.clone());
|
||||||
|
assert_eq!(
|
||||||
|
chat.bottom_pane.composer_text(),
|
||||||
|
format!("/plan [Pasted Content {} chars]", payload.len())
|
||||||
|
);
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_test_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState {
|
||||||
|
crate::session_state::ThreadSessionState {
|
||||||
|
thread_id,
|
||||||
|
forked_from_id: None,
|
||||||
|
fork_parent_title: None,
|
||||||
|
thread_name: None,
|
||||||
|
model: "test-model".to_string(),
|
||||||
|
model_provider_id: "test-provider".to_string(),
|
||||||
|
service_tier: None,
|
||||||
|
approval_policy: AskForApproval::Never,
|
||||||
|
approvals_reviewer: ApprovalsReviewer::User,
|
||||||
|
permission_profile: PermissionProfile::read_only(),
|
||||||
|
active_permission_profile: None,
|
||||||
|
cwd: test_path_buf("/home/user/project").abs(),
|
||||||
|
runtime_workspace_roots: Vec::new(),
|
||||||
|
instruction_source_paths: Vec::new(),
|
||||||
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||||
|
collaboration_mode: None,
|
||||||
|
personality: None,
|
||||||
|
message_history: None,
|
||||||
|
network_proxy: None,
|
||||||
|
rollout_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_literal_plan_prompt(chat: &ChatWidget, op: Result<Op, TryRecvError>, payload: String) {
|
||||||
|
match op {
|
||||||
|
Ok(Op::UserTurn {
|
||||||
|
items,
|
||||||
|
collaboration_mode,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![UserInput::Text {
|
||||||
|
text: payload,
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
collaboration_mode.map(|mode| mode.mode),
|
||||||
|
Some(ModeKind::Plan)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected literal plan prompt, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
|
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
|
||||||
assert!(contains_plan_keyword("plan"));
|
assert!(contains_plan_keyword("plan"));
|
||||||
@@ -1475,29 +1536,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
|
|||||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||||||
|
|
||||||
let configured = crate::session_state::ThreadSessionState {
|
chat.handle_thread_session(plan_test_session(ThreadId::new()));
|
||||||
thread_id: ThreadId::new(),
|
|
||||||
forked_from_id: None,
|
|
||||||
fork_parent_title: None,
|
|
||||||
thread_name: None,
|
|
||||||
model: "test-model".to_string(),
|
|
||||||
model_provider_id: "test-provider".to_string(),
|
|
||||||
service_tier: None,
|
|
||||||
approval_policy: AskForApproval::Never,
|
|
||||||
approvals_reviewer: ApprovalsReviewer::User,
|
|
||||||
permission_profile: PermissionProfile::read_only(),
|
|
||||||
active_permission_profile: None,
|
|
||||||
cwd: test_path_buf("/home/user/project").abs(),
|
|
||||||
runtime_workspace_roots: Vec::new(),
|
|
||||||
instruction_source_paths: Vec::new(),
|
|
||||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
||||||
collaboration_mode: None,
|
|
||||||
personality: None,
|
|
||||||
message_history: None,
|
|
||||||
network_proxy: None,
|
|
||||||
rollout_path: None,
|
|
||||||
};
|
|
||||||
chat.handle_thread_session(configured);
|
|
||||||
|
|
||||||
chat.bottom_pane
|
chat.bottom_pane
|
||||||
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
|
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
|
||||||
@@ -1518,6 +1557,123 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
|
|||||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plan_slash_command_with_hidden_shell_paste_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let payload = paste_hidden_plan_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_literal_plan_prompt(&chat, op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plan_slash_command_with_hidden_shell_paste_rejected_image_remains_literal() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let current_model = chat.current_model().to_string();
|
||||||
|
let mut models = chat.model_catalog.try_list_models().expect("model catalog");
|
||||||
|
models
|
||||||
|
.iter_mut()
|
||||||
|
.find(|model| model.model == current_model)
|
||||||
|
.expect("current model")
|
||||||
|
.input_modalities
|
||||||
|
.retain(|modality| *modality != InputModality::Image);
|
||||||
|
chat.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||||
|
let payload = paste_hidden_plan_shell_payload(&mut chat);
|
||||||
|
chat.set_remote_image_urls(vec!["https://example.com/image.png".to_string()]);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
chat.set_remote_image_urls(Vec::new());
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_literal_plan_prompt(&chat, op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plan_slash_command_with_hidden_shell_paste_unavailable_model_remains_literal() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
let model = chat.current_model().to_string();
|
||||||
|
chat.set_model("");
|
||||||
|
let payload = paste_hidden_plan_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
chat.set_model(&model);
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
|
||||||
|
assert_literal_plan_prompt(&chat, op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plan_slash_command_with_hidden_shell_paste_queued_during_turn_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
chat.thread_id = Some(ThreadId::new());
|
||||||
|
handle_turn_started(&mut chat, "turn-1");
|
||||||
|
let payload = paste_hidden_plan_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||||
|
assert_eq!(
|
||||||
|
chat.input_queue
|
||||||
|
.queued_user_messages
|
||||||
|
.front()
|
||||||
|
.unwrap()
|
||||||
|
.action,
|
||||||
|
QueuedInputAction::ParseSlash
|
||||||
|
);
|
||||||
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||||
|
|
||||||
|
assert_literal_plan_prompt(&chat, op_rx.try_recv(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plan_slash_command_with_hidden_shell_paste_queued_before_session_submits_literal_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
let payload = paste_hidden_plan_shell_payload(&mut chat);
|
||||||
|
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
chat.queue_user_message(UserMessage::from("queued follow-up"));
|
||||||
|
chat.finish_mcp_startup(Vec::new(), Vec::new());
|
||||||
|
assert_eq!(
|
||||||
|
chat.queued_user_message_texts(),
|
||||||
|
vec![format!("/plan {payload}"), "queued follow-up".to_string()]
|
||||||
|
);
|
||||||
|
chat.handle_thread_session(plan_test_session(ThreadId::new()));
|
||||||
|
|
||||||
|
assert_literal_plan_prompt(&chat, Ok(next_submit_op(&mut op_rx)), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejected_initial_image_does_not_submit_later_queued_prompt() {
|
||||||
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
let current_model = chat.current_model().to_string();
|
||||||
|
let mut models = chat.model_catalog.try_list_models().expect("model catalog");
|
||||||
|
models
|
||||||
|
.iter_mut()
|
||||||
|
.find(|model| model.model == current_model)
|
||||||
|
.expect("current model")
|
||||||
|
.input_modalities
|
||||||
|
.retain(|modality| *modality != InputModality::Image);
|
||||||
|
chat.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||||
|
let mut initial_message = UserMessage::from("initial prompt");
|
||||||
|
initial_message.remote_image_urls = vec!["https://example.com/image.png".to_string()];
|
||||||
|
chat.initial_user_message = Some(initial_message);
|
||||||
|
chat.queue_user_message(UserMessage::from("queued follow-up"));
|
||||||
|
chat.finish_mcp_startup(Vec::new(), Vec::new());
|
||||||
|
|
||||||
|
let mut session = plan_test_session(ThreadId::new());
|
||||||
|
session.model = current_model;
|
||||||
|
chat.handle_thread_session(session);
|
||||||
|
|
||||||
|
assert_no_submit_op(&mut op_rx);
|
||||||
|
assert_eq!(chat.bottom_pane.composer_text(), "initial prompt");
|
||||||
|
assert_eq!(
|
||||||
|
chat.queued_user_message_texts(),
|
||||||
|
vec!["queued follow-up".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn collaboration_modes_defaults_to_code_on_startup() {
|
async fn collaboration_modes_defaults_to_code_on_startup() {
|
||||||
let chat = make_startup_chat_with_cli_overrides(vec![(
|
let chat = make_startup_chat_with_cli_overrides(vec![(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::app_event::AppEvent;
|
|||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::bottom_pane::ChatComposer;
|
use crate::bottom_pane::ChatComposer;
|
||||||
use crate::bottom_pane::InputResult;
|
use crate::bottom_pane::InputResult;
|
||||||
|
use crate::bottom_pane::QueuedInputAction;
|
||||||
use crate::render::renderable::Renderable;
|
use crate::render::renderable::Renderable;
|
||||||
|
|
||||||
/// Action returned from feeding a key event into the ComposerInput.
|
/// Action returned from feeding a key event into the ComposerInput.
|
||||||
@@ -62,6 +63,14 @@ impl ComposerInput {
|
|||||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||||
let action = match self.inner.handle_key_event(key).0 {
|
let action = match self.inner.handle_key_event(key).0 {
|
||||||
InputResult::Submitted { text, .. } => ComposerAction::Submitted(text),
|
InputResult::Submitted { text, .. } => ComposerAction::Submitted(text),
|
||||||
|
InputResult::Queued {
|
||||||
|
text,
|
||||||
|
text_elements,
|
||||||
|
action: QueuedInputAction::Literal,
|
||||||
|
pending_pastes,
|
||||||
|
} => ComposerAction::Submitted(
|
||||||
|
ChatComposer::expand_pending_pastes(&text, text_elements, &pending_pastes).0,
|
||||||
|
),
|
||||||
_ => ComposerAction::None,
|
_ => ComposerAction::None,
|
||||||
};
|
};
|
||||||
self.drain_app_events();
|
self.drain_app_events();
|
||||||
|
|||||||
Reference in new issue
Block a user