Files
codex/codex-rs/tui/src/chatwidget/input_flow.rs
Eric Traut c0f1ec5afd [2 of 3] Support long pasted text in TUI goals (#27509)
## Stack

1. [1 of 3] Support long raw TUI goal objectives - #27508
2. **[2 of 3] Support long pasted text in TUI goals** - this PR
3. [3 of 3] Support images in TUI goals - #27510

## Why

Large text pasted into the TUI composer is represented as a paste
placeholder plus pending paste metadata. For `/goal`, preserving only
the visible placeholder is not enough: the agent would see a short
placeholder string instead of the actual pasted text, and the long-text
support from the first PR would never see the payload.

The TUI also needs to avoid writing stale sidecar files when a user
pastes a large block and then deletes its placeholder before submitting
the goal.

## What Changed

- Introduces a TUI `GoalDraft` for goal submissions so `/goal`, `/goal
edit`, and queued goal commands can carry objective text plus text
elements and pending paste payloads.
- Materializes active pasted-text placeholders to `pasted-text-N.txt`
files through the app-server filesystem path introduced in #27508.
- Rewrites active paste placeholders in the persisted objective to file
references, while leaving literal placeholder-looking text alone.
- Filters out deleted paste placeholders so otherwise-small goals do not
require `$CODEX_HOME` or remote filesystem writes.
- Preserves pending paste metadata when a `/goal` command is queued
before a thread exists.

## Verification

- Added goal materialization tests for active paste placeholders,
deleted paste placeholders, and whitespace-only paste payloads.
- Added/updated TUI slash-command tests for large pasted text, queued
`/goal` commands before thread start, and queued oversized goal
behavior.

## Manual Testing

- Used real terminal bracketed-paste sequences through a remote TUI
session. A 1,228-byte multiline paste became `pasted-text-1.txt`; its
first/last lines and byte count matched exactly, and the persisted
objective referenced the server-host path.
- Pasted a large block, deleted its placeholder, and submitted a small
replacement objective. No new directory or sidecar file was created.
- Added two same-length large pastes to one goal. The composer
disambiguated their visible placeholders, and materialization preserved
order and contents in `pasted-text-1.txt` and `pasted-text-2.txt`.
- Submitted a whitespace-only large paste and verified the goal was
rejected as empty without writing a file.
- Submitted a pasted-text replacement while another goal was active,
verified no file was written before confirmation, then canceled and
confirmed the original goal remained unchanged.
- Combined a large paste with enough raw text to exceed 4,000 characters
after placeholder rewriting. The paste sidecar and `goal-objective.md`
were created in the same remote attachment directory, and `/goal edit`
restored the rewritten objective with its sidecar reference.
2026-06-12 15:34:04 -07:00

218 lines
8.2 KiB
Rust

//! User input submission, queue draining, and draft restore flow for `ChatWidget`.
//!
//! The queue data itself lives in `input_queue`; this module owns the app-level
//! effects around taking composer input, submitting user turns, draining queued
//! follow-ups, and restoring draft state across interrupts or thread switches.
use super::*;
impl ChatWidget {
pub(super) fn handle_composer_input_result(
&mut self,
input_result: InputResult,
had_modal_or_popup: bool,
) {
match input_result {
InputResult::Submitted {
text,
text_elements,
} => {
let user_message = self.user_message_from_submission(text, text_elements);
if user_message.text.is_empty()
&& user_message.local_images.is_empty()
&& user_message.remote_image_urls.is_empty()
{
return;
}
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
if should_submit_now {
if self.only_user_shell_commands_running()
&& !user_message.text.starts_with('!')
{
self.queue_user_message(user_message);
return;
}
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
InputResult::Queued {
text,
text_elements,
action,
pending_pastes,
} => {
let user_message = self.user_message_from_submission(text, text_elements);
self.queue_user_message_with_options(user_message, action, pending_pastes);
}
InputResult::Command(cmd) => {
self.handle_slash_command_dispatch(cmd);
}
InputResult::ServiceTierCommand(command) => {
self.handle_service_tier_command_dispatch(command);
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.handle_slash_command_with_args_dispatch(cmd, args, text_elements);
}
InputResult::None => {}
}
if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() {
self.maybe_send_next_queued_input();
}
self.refresh_plan_mode_nudge();
}
pub(super) fn queue_user_message(&mut self, user_message: UserMessage) {
self.queue_user_message_with_options(user_message, QueuedInputAction::Plain, Vec::new());
}
pub(crate) fn set_queue_submissions_until_session_configured(&mut self, queue: bool) {
self.bottom_pane
.set_queue_submissions(queue && !self.is_session_configured());
}
pub(super) fn queue_user_message_with_options(
&mut self,
user_message: UserMessage,
action: QueuedInputAction,
pending_pastes: Vec<(String, String)>,
) {
if !self.is_session_configured() || self.is_user_turn_pending_or_running() {
self.input_queue
.queued_user_messages
.push_back(QueuedUserMessage {
user_message,
action,
pending_pastes,
});
self.input_queue
.queued_user_message_history_records
.push_back(UserMessageHistoryRecord::UserMessageText);
self.refresh_pending_input_preview();
} else {
self.submit_user_message(user_message);
}
}
/// 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 {
if self.input_queue.suppress_queue_autosend {
return false;
}
if self.is_user_turn_pending_or_running() {
return false;
}
let mut submitted_follow_up = false;
while !self.is_user_turn_pending_or_running() {
let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else {
break;
};
match queued_message.action {
QueuedInputAction::Plain => {
submitted_follow_up = self.submit_user_message_with_history_record(
queued_message.into_user_message(),
history_record,
);
break;
}
QueuedInputAction::ParseSlash => {
let drain = self.submit_queued_slash_prompt(queued_message);
if drain == QueueDrain::Stop {
submitted_follow_up = self.is_user_turn_pending_or_running();
break;
}
}
QueuedInputAction::RunShell => {
let drain = self.submit_queued_shell_prompt(queued_message.into_user_message());
if drain == QueueDrain::Stop {
submitted_follow_up = self.is_user_turn_pending_or_running();
break;
}
}
}
}
// Update the list to reflect the remaining queued messages (if any).
self.refresh_pending_input_preview();
submitted_follow_up
}
pub(super) fn is_user_turn_pending_or_running(&self) -> bool {
self.input_queue.user_turn_pending_start || self.bottom_pane.is_task_running()
}
pub(super) fn only_user_shell_commands_running(&self) -> bool {
self.turn_lifecycle.agent_turn_running
&& !self.running_commands.is_empty()
&& self
.running_commands
.values()
.all(|command| command.source == ExecCommandSource::UserShell)
}
/// Rebuild and update the bottom-pane pending-input preview.
pub(super) fn refresh_pending_input_preview(&mut self) {
let preview = self.input_queue.preview();
self.bottom_pane.set_pending_input_preview(
preview.queued_messages,
preview.pending_steers,
preview.rejected_steers,
);
}
pub(crate) fn submit_user_message_with_mode(
&mut self,
text: String,
mut collaboration_mode: CollaborationModeMask,
) {
if collaboration_mode.mode == Some(ModeKind::Plan)
&& let Some(effort) = self.config.plan_mode_reasoning_effort.clone()
{
collaboration_mode.reasoning_effort = Some(Some(effort));
}
if self.turn_lifecycle.agent_turn_running
&& self.active_collaboration_mask.as_ref() != Some(&collaboration_mode)
{
self.add_error_message(
"Cannot switch collaboration mode while a turn is running.".to_string(),
);
return;
}
self.set_collaboration_mask_from_user_action(collaboration_mode);
let should_queue = self.is_plan_streaming_in_tui();
let user_message = UserMessage {
text,
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
};
if should_queue {
self.queue_user_message(user_message);
} else {
self.submit_user_message(user_message);
}
}
#[cfg(test)]
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
self.input_queue
.rejected_steers_queue
.iter()
.map(|message| message.text.clone())
.chain(
self.input_queue
.queued_user_messages
.iter()
.map(|message| message.text.clone()),
)
.collect()
}
}