[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

@@ -123,9 +123,9 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() {
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
Ok(AppEvent::SetThreadGoalDraft {
thread_id: event_thread_id,
objective,
draft,
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
@@ -134,13 +134,13 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() {
}) => {
assert_eq!(event_thread_id, thread_id);
assert_eq!(
objective,
draft.objective,
"Keep improving the bare goal command until it feels calm and useful. with clearer wording"
);
assert_eq!(status, AppThreadGoalStatus::Paused);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
other => panic!("expected SetThreadGoalDraft event, got {other:?}"),
}
assert!(chat.no_modal_or_popup_active());
}
@@ -165,7 +165,7 @@ async fn goal_edit_prompt_preserves_resumable_stopped_statuses() {
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
Ok(AppEvent::SetThreadGoalDraft {
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
@@ -176,7 +176,7 @@ async fn goal_edit_prompt_preserves_resumable_stopped_statuses() {
assert_eq!(status, stopped_status);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
other => panic!("expected SetThreadGoalDraft event, got {other:?}"),
}
}
}
@@ -203,7 +203,7 @@ async fn goal_edit_prompt_resets_terminal_status_to_active() {
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
Ok(AppEvent::SetThreadGoalDraft {
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
@@ -214,7 +214,7 @@ async fn goal_edit_prompt_resets_terminal_status_to_active() {
assert_eq!(status, AppThreadGoalStatus::Active);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
other => panic!("expected SetThreadGoalDraft event, got {other:?}"),
}
}
}
@@ -38,14 +38,12 @@ fn next_goal_objective(
) -> String {
loop {
let event = rx.try_recv().expect("expected goal objective event");
if let AppEvent::SetThreadGoalObjective {
thread_id,
objective,
..
if let AppEvent::SetThreadGoalDraft {
thread_id, draft, ..
} = event
{
assert_eq!(thread_id, expected_thread_id);
return objective;
return draft.objective;
}
}
}
@@ -62,16 +60,16 @@ async fn goal_slash_command_accepts_objective_at_limit() {
submit_composer_text(&mut chat, &command);
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
let AppEvent::SetThreadGoalDraft {
thread_id: actual_thread_id,
objective: actual_objective,
draft,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
panic!("expected SetThreadGoalDraft, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(actual_objective, objective);
assert_eq!(draft.objective, objective);
assert_no_submit_op(&mut op_rx);
}
@@ -86,16 +84,16 @@ async fn goal_slash_command_accepts_multiline_objective_after_blank_first_line()
submit_composer_text(&mut chat, &format!("/goal \n\n{objective}"));
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
let AppEvent::SetThreadGoalDraft {
thread_id: actual_thread_id,
objective: actual_objective,
draft,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
panic!("expected SetThreadGoalDraft, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(actual_objective, objective);
assert_eq!(draft.objective, objective);
assert_no_submit_op(&mut op_rx);
}
@@ -114,7 +112,7 @@ async fn goal_slash_command_emits_oversized_objective() {
}
#[tokio::test]
async fn goal_slash_command_expands_large_pasted_objective() {
async fn goal_slash_command_preserves_large_pasted_objective() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
@@ -130,7 +128,8 @@ async fn goal_slash_command_expands_large_pasted_objective() {
);
submit_current_composer(&mut chat);
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
let draft = next_goal_draft(&mut rx, thread_id);
assert_eq!(draft.pending_pastes[0].1, objective);
assert_no_submit_op(&mut op_rx);
}
@@ -153,3 +152,33 @@ async fn queued_goal_slash_command_emits_oversized_objective_and_stops_queue() {
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn goal_slash_command_emits_only_inserted_paste_text_element() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
let paste = "x".repeat(1_001);
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
chat.bottom_pane.set_composer_text(
format!("/goal keep literal {placeholder} and "),
Vec::new(),
Vec::new(),
);
chat.handle_paste(paste.clone());
submit_current_composer(&mut chat);
let draft = next_goal_draft(&mut rx, thread_id);
assert!(
draft
.objective
.contains(&format!("keep literal {placeholder} and {placeholder}")),
"expected literal placeholder and inserted paste placeholder, got {:?}",
draft.objective
);
assert_eq!(draft.pending_pastes, vec![(placeholder, paste)]);
assert!(chat.bottom_pane.composer_pending_pastes().is_empty());
assert_no_submit_op(&mut op_rx);
}
@@ -65,6 +65,13 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
fn queue_goal_with_large_paste(chat: &mut ChatWidget, paste: String) {
chat.bottom_pane
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
chat.handle_paste(paste);
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
@@ -608,17 +615,17 @@ async fn goal_slash_command_with_extra_os_emits_set_goal_event() {
submit_composer_text(&mut chat, command);
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
let event = rx.try_recv().expect("expected goal draft event");
let AppEvent::SetThreadGoalDraft {
thread_id: actual_thread_id,
objective,
draft,
mode,
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
panic!("expected SetThreadGoalDraft, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "--tokens 98.5K improve benchmark coverage");
assert_eq!(draft.objective, "--tokens 98.5K improve benchmark coverage");
assert_eq!(mode, crate::app_event::ThreadGoalSetMode::ConfirmIfExists);
assert_no_submit_op(&mut op_rx);
assert_eq!(recall_latest_after_clearing(&mut chat), command);
@@ -645,17 +652,8 @@ async fn goal_slash_command_uses_plain_text_for_mentions() {
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "use $figma for the mockup");
let draft = next_goal_draft(&mut rx, thread_id);
assert_eq!(draft.objective, "use $figma for the mockup");
assert_no_submit_op(&mut op_rx);
}
@@ -682,17 +680,8 @@ async fn goal_slash_command_drops_attached_images() {
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "describe [Image #2]");
let draft = next_goal_draft(&mut rx, thread_id);
assert_eq!(draft.objective, "describe [Image #2]");
assert!(chat.remote_image_urls().is_empty());
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
assert_no_submit_op(&mut op_rx);
@@ -813,20 +802,77 @@ async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
chat.thread_id = Some(thread_id);
chat.maybe_send_next_queued_input();
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "improve benchmark coverage");
let draft = next_goal_draft(&mut rx, thread_id);
assert_eq!(draft.objective, "improve benchmark coverage");
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn queued_goal_slash_command_preserves_large_paste() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
handle_turn_started(&mut chat, "turn-1");
let paste = "x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1);
queue_goal_with_large_paste(&mut chat, paste.clone());
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let draft = next_goal_draft(&mut rx, thread_id);
assert_eq!(draft.pending_pastes.len(), 1);
assert_eq!(draft.pending_pastes[0].1, paste);
assert!(draft.objective.contains(&draft.pending_pastes[0].0));
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn queued_goal_slash_command_restores_large_paste_for_edit() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
handle_turn_started(&mut chat, "turn-1");
let paste = "x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1);
queue_goal_with_large_paste(&mut chat, paste.clone());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert_eq!(chat.bottom_pane.composer_pending_pastes()[0].1, paste);
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
submit_current_composer(&mut chat);
let draft = next_goal_draft(&mut rx, thread_id);
assert_eq!(draft.pending_pastes.len(), 1);
assert_eq!(draft.pending_pastes[0].1, paste);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn interrupt_disambiguates_same_sized_goal_pastes() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
handle_turn_started(&mut chat, "turn-1");
let first = "a".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1);
let second = "b".repeat(first.len());
queue_goal_with_large_paste(&mut chat, first);
chat.bottom_pane
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
chat.handle_paste(second.clone());
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let remaining = chat.bottom_pane.composer_pending_pastes();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].1, second);
assert!(chat.bottom_pane.composer_text().contains(&remaining[0].0));
}
#[tokio::test]
async fn queued_goal_slash_command_preserves_current_draft_metadata() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -855,14 +901,7 @@ async fn queued_goal_slash_command_preserves_current_draft_metadata() {
chat.thread_id = Some(thread_id);
chat.maybe_send_next_queued_input();
let event = rx.try_recv().expect("expected goal objective event");
assert_matches!(
event,
AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
..
} if actual_thread_id == thread_id
);
let _ = next_goal_draft(&mut rx, thread_id);
assert_no_submit_op(&mut op_rx);
assert_eq!(chat.bottom_pane.composer_text(), draft);
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
@@ -891,16 +930,7 @@ async fn restored_queued_goal_slash_command_emits_set_goal_event() {
restored_chat.thread_id = Some(thread_id);
restored_chat.maybe_send_next_queued_input();
let event = restored_rx
.try_recv()
.expect("expected goal objective event");
assert_matches!(
event,
AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
..
} if actual_thread_id == thread_id
);
let _ = next_goal_draft(&mut restored_rx, thread_id);
assert_no_submit_op(&mut restored_op_rx);
}