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:
Eric Traut authored and copyberry committed 2026-08-20 05:49:49 +00:00
1 parent 8e7f646974
commit 3434c2545b
10 files changed
+519 -36

No files matched your search

+21
View File
@@ -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);
+2 -2
View File
@@ -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 {
+49 -7
View File
@@ -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,
) )
+46 -1
View File
@@ -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
{ {
+23 -3
View File
@@ -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() => {
@@ -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) =
+179 -23
View File
@@ -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();