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

+49 -7
View File
@@ -77,6 +77,7 @@
//!
//! - Expands pending paste placeholders so element ranges align with the final text.
//! - 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.
//! - 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)]
pub enum QueuedInputAction {
Plain,
/// Preserve model-input provenance when paste expansion reveals a leading shell sigil.
Literal,
ParseSlash,
RunShell,
}
@@ -3007,13 +3010,29 @@ impl ChatComposer {
}
self.draft.recent_submission_mention_bindings = original_mention_bindings.clone();
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 {
text: text.clone(),
text_elements: text_elements.clone(),
text: history_text,
text_elements: history_text_elements,
local_image_paths: self.attachments.local_image_paths(),
remote_image_urls: self.attachments.remote_image_urls(),
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()
} else {
Vec::new()
@@ -3091,6 +3110,10 @@ impl ChatComposer {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
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 defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
let preserve_pending_pastes = defer_slash_validation
@@ -3115,7 +3138,20 @@ impl ChatComposer {
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 (
InputResult::Queued {
text,
@@ -3152,13 +3188,19 @@ impl ChatComposer {
if let Some((text, text_elements)) =
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 {
text,
text_elements,
action: QueuedInputAction::Plain,
pending_pastes: Vec::new(),
action: QueuedInputAction::Literal,
pending_pastes: original_pending_pastes,
},
true,
)