[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.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-06-12 15:34:04 -07:00
1 parent 216dee1189
commit c0f1ec5afd
17 files changed
+600 -201

No files matched your search

+54 -7
View File
@@ -13,6 +13,7 @@ use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::bottom_pane::slash_commands::SlashCommandItem;
use crate::bottom_pane::slash_commands::find_slash_command;
use crate::goal_display::GOAL_USAGE;
use crate::goal_files::GoalDraft;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
@@ -23,6 +24,7 @@ enum SlashCommandDispatchSource {
struct PreparedSlashCommandArgs {
args: String,
text_elements: Vec<TextElement>,
pending_pastes: Vec<(String, String)>,
local_images: Vec<LocalImageAttachment>,
remote_image_urls: Vec<String>,
mention_bindings: Vec<MentionBinding>,
@@ -553,6 +555,22 @@ impl ChatWidget {
return;
}
if cmd == SlashCommand::Goal {
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
args,
text_elements,
pending_pastes: self.bottom_pane.composer_pending_pastes(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
source: SlashCommandDispatchSource::Live,
},
);
return;
}
let Some((prepared_args, prepared_elements)) =
self.prepare_live_inline_args(args, text_elements)
else {
@@ -563,6 +581,7 @@ impl ChatWidget {
PreparedSlashCommandArgs {
args: prepared_args,
text_elements: prepared_elements,
pending_pastes: Vec::new(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
@@ -587,6 +606,7 @@ impl ChatWidget {
fn clear_live_goal_submission(&mut self) {
self.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
self.bottom_pane.set_composer_pending_pastes(Vec::new());
self.bottom_pane.drain_pending_submission_state();
}
@@ -623,6 +643,7 @@ impl ChatWidget {
let PreparedSlashCommandArgs {
args,
text_elements,
pending_pastes,
local_images,
remote_image_urls,
mention_bindings,
@@ -750,18 +771,34 @@ impl ChatWidget {
}
return;
}
let objective = args.trim();
let draft = GoalDraft {
objective: args,
text_elements,
pending_pastes,
};
let Some(thread_id) = self.thread_id else {
if source == SlashCommandDispatchSource::Live {
const GOAL_PREFIX: &str = "/goal ";
let text_elements = draft
.text_elements
.into_iter()
.map(|element| {
element.map_range(|range| ByteRange {
start: range.start + GOAL_PREFIX.len(),
end: range.end + GOAL_PREFIX.len(),
})
})
.collect();
self.queue_user_message_with_options(
UserMessage {
text: format!("/goal {args}"),
text: format!("{GOAL_PREFIX}{}", draft.objective),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
text_elements,
mention_bindings: Vec::new(),
},
QueuedInputAction::ParseSlash,
draft.pending_pastes,
);
self.clear_live_goal_submission();
} else {
@@ -772,12 +809,13 @@ impl ChatWidget {
}
return;
};
self.app_event_tx.send(AppEvent::SetThreadGoalObjective {
let history_objective = draft.objective.clone();
self.app_event_tx.send(AppEvent::SetThreadGoalDraft {
thread_id,
objective: objective.to_string(),
draft,
mode: ThreadGoalSetMode::ConfirmIfExists,
});
self.append_message_history_entry(format!("/goal {trimmed}"));
self.append_message_history_entry(format!("/goal {history_objective}"));
if source == SlashCommandDispatchSource::Live {
self.clear_live_goal_submission();
}
@@ -831,7 +869,15 @@ impl ChatWidget {
}
}
pub(super) fn submit_queued_slash_prompt(&mut self, user_message: UserMessage) -> QueueDrain {
pub(super) fn submit_queued_slash_prompt(
&mut self,
queued_message: QueuedUserMessage,
) -> QueueDrain {
let QueuedUserMessage {
user_message,
pending_pastes,
..
} = queued_message;
let UserMessage {
text,
local_images,
@@ -921,6 +967,7 @@ impl ChatWidget {
PreparedSlashCommandArgs {
args: trimmed_rest.to_string(),
text_elements: args_elements,
pending_pastes,
local_images,
remote_image_urls,
mention_bindings,