From c0f1ec5afdc1102617611560bfc22063fe11108f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 12 Jun 2026 15:34:04 -0700 Subject: [PATCH] [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. --- codex-rs/tui/src/app/event_dispatch.rs | 6 +- codex-rs/tui/src/app/tests.rs | 97 +++++++++++- codex-rs/tui/src/app/thread_goal_actions.rs | 21 +-- codex-rs/tui/src/app_event.rs | 7 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 45 +++++- codex-rs/tui/src/bottom_pane/mod.rs | 4 + codex-rs/tui/src/chatwidget/goal_menu.rs | 8 +- codex-rs/tui/src/chatwidget/input_flow.rs | 14 +- codex-rs/tui/src/chatwidget/input_restore.rs | 136 ++++++++++------- codex-rs/tui/src/chatwidget/interaction.rs | 4 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 61 +++++++- codex-rs/tui/src/chatwidget/tests.rs | 16 ++ .../tui/src/chatwidget/tests/goal_menu.rs | 16 +- .../src/chatwidget/tests/goal_validation.rs | 59 ++++++-- .../src/chatwidget/tests/slash_commands.rs | 142 +++++++++++------- codex-rs/tui/src/chatwidget/user_messages.rs | 31 ++++ codex-rs/tui/src/goal_files.rs | 134 +++++++++++++---- 17 files changed, 600 insertions(+), 201 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0ea4e78f7..afb77df5b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -728,12 +728,12 @@ impl App { AppEvent::OpenThreadGoalEditor { thread_id } => { self.open_thread_goal_editor(app_server, thread_id).await; } - AppEvent::SetThreadGoalObjective { + AppEvent::SetThreadGoalDraft { thread_id, - objective, + draft, mode, } => { - self.set_thread_goal_objective(app_server, thread_id, objective, mode) + self.set_thread_goal_draft(app_server, thread_id, draft, mode) .await; } AppEvent::SetThreadGoalStatus { thread_id, status } => { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c7462776d..10241582f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4155,7 +4155,8 @@ async fn make_test_app_with_channels() -> ( } #[tokio::test] -async fn set_thread_goal_objective_materializes_long_objective_before_goal_set() -> Result<()> { +async fn set_thread_goal_draft_materializes_long_objective_and_confirms_before_paste() -> Result<()> +{ let mut app = make_test_app().await; let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; @@ -4167,10 +4168,13 @@ async fn set_thread_goal_objective_materializes_long_objective_before_goal_set() .await?; let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1); - app.set_thread_goal_objective( + app.set_thread_goal_draft( &mut app_server, thread_id, - objective.clone(), + crate::goal_files::GoalDraft { + objective: objective.clone(), + ..Default::default() + }, crate::app_event::ThreadGoalSetMode::ConfirmIfExists, ) .await; @@ -4209,11 +4213,20 @@ async fn set_thread_goal_objective_materializes_long_objective_before_goal_set() assert_eq!(unix_path.as_str(), "/tmp/codex\\/a"); let attachments_dir = app.chat_widget.config_ref().codex_home.join("attachments"); let attachment_count = std::fs::read_dir(&attachments_dir)?.count(); + let placeholder = "[Pasted Content 5 chars]"; + let paste_draft = crate::goal_files::GoalDraft { + objective: format!("Use {placeholder}"), + text_elements: vec![TextElement::new( + (4..4 + placeholder.len()).into(), + Some(placeholder.to_string()), + )], + pending_pastes: vec![(placeholder.to_string(), "hello".to_string())], + }; - app.set_thread_goal_objective( + app.set_thread_goal_draft( &mut app_server, thread_id, - "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1), + paste_draft.clone(), crate::app_event::ThreadGoalSetMode::ConfirmIfExists, ) .await; @@ -4231,6 +4244,72 @@ async fn set_thread_goal_objective_materializes_long_objective_before_goal_set() .objective, saved_objective ); + + app.set_thread_goal_draft( + &mut app_server, + thread_id, + paste_draft, + crate::app_event::ThreadGoalSetMode::ReplaceExisting, + ) + .await; + let goal = app_server + .thread_goal_get(thread_id) + .await? + .goal + .expect("replacement goal should be set"); + let paste_path = goal + .objective + .strip_prefix("Use pasted text file: ") + .and_then(|text| text.strip_suffix(". Read this file before continuing.")) + .expect("paste file reference"); + assert_eq!(std::fs::read_to_string(paste_path)?, "hello"); + let attachment_count = std::fs::read_dir(&attachments_dir)?.count(); + + let stale_paste = (placeholder.to_string(), "hello".to_string()); + app.set_thread_goal_draft( + &mut app_server, + thread_id, + crate::goal_files::GoalDraft { + objective: "small goal".to_string(), + pending_pastes: vec![stale_paste], + ..Default::default() + }, + crate::app_event::ThreadGoalSetMode::ReplaceExisting, + ) + .await; + assert_eq!( + std::fs::read_dir(&attachments_dir)?.count(), + attachment_count + ); + + let whitespace_placeholder = "[Pasted Content 3 chars]"; + app.set_thread_goal_draft( + &mut app_server, + thread_id, + crate::goal_files::GoalDraft { + objective: whitespace_placeholder.to_string(), + text_elements: vec![TextElement::new( + (0..whitespace_placeholder.len()).into(), + Some(whitespace_placeholder.to_string()), + )], + pending_pastes: vec![(whitespace_placeholder.to_string(), " \n\t".to_string())], + }, + crate::app_event::ThreadGoalSetMode::ReplaceExisting, + ) + .await; + assert_eq!( + std::fs::read_dir(&attachments_dir)?.count(), + attachment_count + ); + assert_eq!( + app_server + .thread_goal_get(thread_id) + .await? + .goal + .expect("small goal should remain set") + .objective, + "small goal" + ); app_server.shutdown().await?; Ok(()) } @@ -4238,7 +4317,13 @@ async fn set_thread_goal_objective_materializes_long_objective_before_goal_set() #[tokio::test] async fn replace_goal_confirmation_snapshot() { let mut app = make_test_app().await; - app.show_replace_thread_goal_confirmation(ThreadId::new(), "New goal".to_string()); + app.show_replace_thread_goal_confirmation( + ThreadId::new(), + goal_files::GoalDraft { + objective: "New goal".to_string(), + ..Default::default() + }, + ); assert_app_snapshot!( "replace_goal_confirmation", render_bottom_popup(&app.chat_widget, /*width*/ 80) diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index ed55be0ca..a6f1ed596 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -125,11 +125,11 @@ impl App { self.chat_widget.show_goal_edit_prompt(thread_id, goal); } - pub(super) async fn set_thread_goal_objective( + pub(super) async fn set_thread_goal_draft( &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, - objective: String, + draft: goal_files::GoalDraft, mode: ThreadGoalSetMode, ) { let codex_home = app_server.codex_home_path(&self.config.codex_home); @@ -142,7 +142,7 @@ impl App { match result { Ok(response) => match response.goal.as_ref() { Some(goal) if should_confirm_before_replacing_goal(goal) => { - self.show_replace_thread_goal_confirmation(thread_id, objective); + self.show_replace_thread_goal_confirmation(thread_id, draft); return; } Some(_) => ThreadGoalSetMode::ReplaceExisting, @@ -158,14 +158,14 @@ impl App { mode }; - let (objective, output_dir) = match goal_files::materialize_goal_objective( + let (objective, output_dir) = match goal_files::materialize_goal_draft( app_server, codex_home.as_ref(), - objective, + draft, ) .await { - Ok(objective) => objective, + Ok(materialized) => materialized, Err(err) => { if self.current_displayed_thread_id() == Some(thread_id) { self.chat_widget.add_error_message(err.to_string()); @@ -286,13 +286,14 @@ impl App { pub(super) fn show_replace_thread_goal_confirmation( &mut self, thread_id: ThreadId, - objective: String, + draft: goal_files::GoalDraft, ) { - let replace_objective = objective.clone(); + let objective = draft.objective.clone(); + let replace_draft = draft; let replace_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::SetThreadGoalObjective { + tx.send(AppEvent::SetThreadGoalDraft { thread_id, - objective: replace_objective.clone(), + draft: replace_draft.clone(), mode: ThreadGoalSetMode::ReplaceExisting, }); })]; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 08b6bca0d..6898289e9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -38,6 +38,7 @@ use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::UserMessage; +use crate::goal_files::GoalDraft; use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; @@ -268,10 +269,10 @@ pub(crate) enum AppEvent { thread_id: Option, }, - /// Set or replace the current thread goal objective. - SetThreadGoalObjective { + /// Materialize and set or replace the current thread goal objective. + SetThreadGoalDraft { thread_id: ThreadId, - objective: String, + draft: GoalDraft, mode: ThreadGoalSetMode, }, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1f3a8db5f..5745d1735 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -189,6 +189,7 @@ use super::mentions_v2::MentionV2Popup; use super::mentions_v2::MentionV2Selection; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::prompt_args::parse_slash_name; use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands::BuiltinCommandFlags; @@ -279,6 +280,7 @@ pub enum InputResult { text: String, text_elements: Vec, action: QueuedInputAction, + pending_pastes: Vec<(String, String)>, }, /// A bare slash command parsed by the composer. /// @@ -303,6 +305,12 @@ pub enum QueuedInputAction { RunShell, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingPasteHandling { + Expand, + Preserve, +} + /// Feature flags for reusing the chat composer in other bottom-pane surfaces. /// /// The default keeps today's behavior intact. Other call sites can opt out of @@ -2611,13 +2619,18 @@ impl ChatComposer { &mut self, record_history: bool, ) -> Option<(String, Vec)> { - self.prepare_submission_text_with_options(record_history, SlashValidation::Immediate) + self.prepare_submission_text_with_options( + record_history, + SlashValidation::Immediate, + PendingPasteHandling::Expand, + ) } fn prepare_submission_text_with_options( &mut self, record_history: bool, slash_validation: SlashValidation, + pending_paste_handling: PendingPasteHandling, ) -> Option<(String, Vec)> { let mut text = self.current_text(); let original_input = text.clone(); @@ -2631,7 +2644,9 @@ impl ChatComposer { self.draft.textarea.set_text_clearing_elements(""); self.draft.is_bash_mode = false; - if !self.draft.pending_pastes.is_empty() { + if pending_paste_handling == PendingPasteHandling::Expand + && !self.draft.pending_pastes.is_empty() + { // Expand placeholders so element byte ranges stay aligned. let (expanded, expanded_elements) = Self::expand_pending_pastes(&text, text_elements, &self.draft.pending_pastes); @@ -2700,7 +2715,11 @@ impl ChatComposer { local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: original_mention_bindings, - pending_pastes: Vec::new(), + pending_pastes: if pending_paste_handling == PendingPasteHandling::Preserve { + original_pending_pastes.clone() + } else { + Vec::new() + }, }); } self.draft.pending_pastes.clear(); @@ -2739,6 +2758,15 @@ impl ChatComposer { } 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 + && !self.draft.pending_pastes.is_empty() + && parse_slash_name(raw_text) + .is_some_and(|(name, _, _)| name == SlashCommand::Goal.command()); + let pending_pastes = if preserve_pending_pastes { + self.draft.pending_pastes.clone() + } else { + Vec::new() + }; if let Some((text, text_elements)) = self.prepare_submission_text_with_options( /*record_history*/ true, if defer_slash_validation { @@ -2746,6 +2774,11 @@ impl ChatComposer { } else { SlashValidation::Immediate }, + if preserve_pending_pastes { + PendingPasteHandling::Preserve + } else { + PendingPasteHandling::Expand + }, ) { let action = slash_input::queued_input_action(&text, defer_slash_validation); return ( @@ -2753,6 +2786,7 @@ impl ChatComposer { text, text_elements, action, + pending_pastes, }, true, ); @@ -2824,6 +2858,7 @@ impl ChatComposer { text, text_elements, action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), }, true, ) @@ -7279,6 +7314,7 @@ mod tests { text: "hi".to_string(), text_elements: Vec::new(), action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), } ); assert!(composer.draft.textarea.text().is_empty()); @@ -8306,6 +8342,7 @@ mod tests { text: "queued before session".to_string(), text_elements: Vec::new(), action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), } ); } @@ -8337,6 +8374,7 @@ mod tests { text, text_elements, action, + .. } => { assert_eq!(text, input); assert!(text_elements.is_empty()); @@ -8514,6 +8552,7 @@ mod tests { text, text_elements, action, + .. } => { assert_eq!(text, expected_text); assert!(text_elements.is_empty()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9bbe47c52..cfddf012d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -848,6 +848,10 @@ impl BottomPane { self.composer.input_enabled() } + pub(crate) fn composer_pending_pastes(&self) -> Vec<(String, String)> { + self.composer.pending_pastes() + } + pub(crate) fn apply_external_edit(&mut self, text: String) { self.composer.apply_external_edit(text); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index ed48b5840..4c406f379 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -2,6 +2,7 @@ use super::*; use crate::goal_display::format_goal_elapsed_seconds; +use crate::goal_files; use crate::status::format_tokens_compact; impl ChatWidget { @@ -19,9 +20,12 @@ impl ChatWidget { goal.objective, /*context_label*/ None, Box::new(move |objective: String| { - tx.send(AppEvent::SetThreadGoalObjective { + tx.send(AppEvent::SetThreadGoalDraft { thread_id, - objective, + draft: goal_files::GoalDraft { + objective, + ..Default::default() + }, mode: crate::app_event::ThreadGoalSetMode::UpdateExisting { status, token_budget, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index ddb118bae..b24a4fde2 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -47,9 +47,10 @@ impl ChatWidget { 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); + self.queue_user_message_with_options(user_message, action, pending_pastes); } InputResult::Command(cmd) => { self.handle_slash_command_dispatch(cmd); @@ -69,7 +70,7 @@ impl ChatWidget { } pub(super) fn queue_user_message(&mut self, user_message: UserMessage) { - self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); + 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) { @@ -81,11 +82,16 @@ impl ChatWidget { &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::new(user_message, action)); + .push_back(QueuedUserMessage { + user_message, + action, + pending_pastes, + }); self.input_queue .queued_user_message_history_records .push_back(UserMessageHistoryRecord::UserMessageText); @@ -117,7 +123,7 @@ impl ChatWidget { break; } QueuedInputAction::ParseSlash => { - let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); + let drain = self.submit_queued_slash_prompt(queued_message); if drain == QueueDrain::Stop { submitted_follow_up = self.is_user_turn_pending_or_running(); break; diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 0a85e0002..bcc4e956a 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -1,5 +1,8 @@ //! Input queue restore and thread-input snapshot behavior for `ChatWidget`. +use std::collections::HashSet; + +use super::user_messages::remap_colliding_paste_placeholders; use super::*; impl ChatWidget { @@ -92,16 +95,21 @@ impl ChatWidget { } } - pub(super) fn pop_latest_queued_user_message(&mut self) -> Option { + pub(super) fn pop_latest_queued_composer_state(&mut self) -> Option { if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { let history_record = self .input_queue .queued_user_message_history_records .pop_back() .unwrap_or(UserMessageHistoryRecord::UserMessageText); - Some(user_message_for_restore( - user_message.into_user_message(), - &history_record, + let QueuedUserMessage { + user_message, + pending_pastes, + .. + } = user_message; + Some(Self::composer_state_from_user_message( + user_message_for_restore(user_message, &history_record), + pending_pastes, )) } else { let user_message = self.input_queue.rejected_steers_queue.pop_back()?; @@ -110,7 +118,10 @@ impl ChatWidget { .rejected_steer_history_records .pop_back() .unwrap_or(UserMessageHistoryRecord::UserMessageText); - Some(user_message_for_restore(user_message, &history_record)) + Some(Self::composer_state_from_user_message( + user_message_for_restore(user_message, &history_record), + Vec::new(), + )) } } @@ -172,10 +183,10 @@ impl ChatWidget { merge_user_messages_with_history_record(pending_steers); self.submit_user_message_with_history_record(user_message, history_record); } else if let Some(combined) = self.drain_pending_messages_for_restore() { - self.restore_user_message_to_composer(combined); + self.restore_composer_state(combined); } } else if let Some(combined) = self.drain_pending_messages_for_restore() { - self.restore_user_message_to_composer(combined); + self.restore_composer_state(combined); } self.refresh_pending_input_preview(); if let Some(prompt) = cancelled_prompt { @@ -193,12 +204,13 @@ impl ChatWidget { /// placeholders in a stable order and rebase text element byte ranges so the restored composer /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. - fn drain_pending_messages_for_restore(&mut self) -> Option { + fn drain_pending_messages_for_restore(&mut self) -> Option { if self.input_queue.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } let composer = self.bottom_pane.composer_draft_snapshot(); + let composer_pending_pastes = composer.pending_pastes; let existing_message = UserMessage { text: composer.text, text_elements: composer.text_elements, @@ -246,32 +258,55 @@ impl ChatWidget { queued_messages.len(), UserMessageHistoryRecord::UserMessageText, ); - to_merge.extend( - queued_messages - .into_iter() - .zip(queued_history_records.iter()) - .map(|(message, history_record)| { - user_message_for_restore(message.into_user_message(), history_record) - }), - ); - if !existing_message.text.is_empty() - || !existing_message.local_images.is_empty() - || !existing_message.remote_image_urls.is_empty() + let mut pending_pastes = Vec::new(); + let mut used_paste_placeholders = HashSet::new(); + for (message, history_record) in queued_messages + .into_iter() + .zip(queued_history_records.iter()) { + let (message, message_pastes) = remap_colliding_paste_placeholders( + user_message_for_restore(message.user_message, history_record), + message.pending_pastes, + &mut used_paste_placeholders, + ); + pending_pastes.extend(message_pastes); + to_merge.push(message); + } + let has_existing_message = !existing_message.text.is_empty() + || !existing_message.local_images.is_empty() + || !existing_message.remote_image_urls.is_empty(); + if has_existing_message { + let (existing_message, composer_pending_pastes) = remap_colliding_paste_placeholders( + existing_message, + composer_pending_pastes, + &mut used_paste_placeholders, + ); to_merge.push(existing_message); + pending_pastes.extend(composer_pending_pastes); } - Some(merge_user_messages(to_merge)) + Some(Self::composer_state_from_user_message( + merge_user_messages(to_merge), + pending_pastes, + )) } pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { - let UserMessage { + self.restore_composer_state(Self::composer_state_from_user_message( + user_message, + Vec::new(), + )); + } + + pub(super) fn restore_composer_state(&mut self, composer: ThreadComposerState) { + let ThreadComposerState { text, local_images, remote_image_urls, text_elements, mention_bindings, - } = user_message; + pending_pastes, + } = composer; let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); self.set_remote_image_urls(remote_image_urls); self.bottom_pane.set_composer_text_with_mention_bindings( @@ -280,6 +315,28 @@ impl ChatWidget { local_image_paths, mention_bindings, ); + self.bottom_pane.set_composer_pending_pastes(pending_pastes); + } + + fn composer_state_from_user_message( + user_message: UserMessage, + pending_pastes: Vec<(String, String)>, + ) -> ThreadComposerState { + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = user_message; + ThreadComposerState { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + pending_pastes, + } } pub(crate) fn capture_thread_input_state(&self) -> Option { @@ -337,31 +394,7 @@ impl ChatWidget { self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; self.update_collaboration_mode_indicator(); self.refresh_model_dependent_surfaces(); - if let Some(composer) = input_state.composer { - let local_image_paths = composer - .local_images - .into_iter() - .map(|img| img.path) - .collect(); - self.set_remote_image_urls(composer.remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - composer.text, - composer.text_elements, - local_image_paths, - composer.mention_bindings, - ); - self.bottom_pane - .set_composer_pending_pastes(composer.pending_pastes); - } else { - self.set_remote_image_urls(Vec::new()); - self.bottom_pane.set_composer_text_with_mention_bindings( - String::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - self.bottom_pane.set_composer_pending_pastes(Vec::new()); - } + self.restore_composer_state(input_state.composer.unwrap_or_default()); let mut pending_steer_history_records = input_state.pending_steer_history_records; pending_steer_history_records.resize( input_state.pending_steers.len(), @@ -402,14 +435,7 @@ impl ChatWidget { self.turn_lifecycle .restore_running(/*running*/ false, Instant::now()); self.input_queue.clear(); - self.set_remote_image_urls(Vec::new()); - self.bottom_pane.set_composer_text_with_mention_bindings( - String::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - self.bottom_pane.set_composer_pending_pastes(Vec::new()); + self.restore_composer_state(Default::default()); } self.turn_lifecycle .restore_running(self.turn_lifecycle.agent_turn_running, Instant::now()); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index d51357bef..25e93088d 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -104,8 +104,8 @@ impl ChatWidget { && self.has_queued_follow_up_messages() && self.bottom_pane.no_modal_or_popup_active() { - if let Some(user_message) = self.pop_latest_queued_user_message() { - self.restore_user_message_to_composer(user_message); + if let Some(composer) = self.pop_latest_queued_composer_state() { + self.restore_composer_state(composer); self.refresh_pending_input_preview(); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index eb9a7b14e..67bf8216a 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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, + pending_pastes: Vec<(String, String)>, local_images: Vec, remote_image_urls: Vec, mention_bindings: Vec, @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0d035943e..1dcb848d5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -213,6 +213,22 @@ macro_rules! assert_chatwidget_snapshot { }}; } +fn next_goal_draft( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + expected_thread_id: ThreadId, +) -> crate::goal_files::GoalDraft { + loop { + let event = rx.try_recv().expect("expected goal draft event"); + if let AppEvent::SetThreadGoalDraft { + thread_id, draft, .. + } = event + { + assert_eq!(thread_id, expected_thread_id); + return draft; + } + } +} + mod app_server; mod approval_requests; mod composer_submission; diff --git a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs index 6ccc0220b..1cb919fcb 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs @@ -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:?}"), } } } diff --git a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs index 379be6ad6..b842f8beb 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs @@ -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); +} diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index dd978c102..8680c04c2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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); } diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index 7134fa300..6cfb739da 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -6,6 +6,7 @@ //! suppress duplicate rows for pending steers. use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::ops::Deref; use std::path::PathBuf; @@ -59,6 +60,7 @@ pub(super) enum ShellEscapePolicy { pub(super) struct QueuedUserMessage { pub(super) user_message: UserMessage, pub(super) action: QueuedInputAction, + pub(super) pending_pastes: Vec<(String, String)>, } impl QueuedUserMessage { @@ -66,6 +68,7 @@ impl QueuedUserMessage { Self { user_message, action, + pending_pastes: Vec::new(), } } @@ -276,6 +279,34 @@ fn remap_placeholders_in_text( (rebuilt, rebuilt_elements) } +pub(super) fn remap_colliding_paste_placeholders( + mut message: UserMessage, + mut pending_pastes: Vec<(String, String)>, + used: &mut HashSet, +) -> (UserMessage, Vec<(String, String)>) { + let mut mapping = HashMap::new(); + for (placeholder, text) in &mut pending_pastes { + if used.insert(placeholder.clone()) { + continue; + } + + let base = format!("[Pasted Content {} chars]", text.chars().count()); + let mut suffix = 2; + let replacement = loop { + let candidate = format!("{base} #{suffix}"); + if used.insert(candidate.clone()) { + break candidate; + } + suffix += 1; + }; + mapping.insert(placeholder.clone(), replacement.clone()); + *placeholder = replacement; + } + (message.text, message.text_elements) = + remap_placeholders_in_text(message.text, message.text_elements, &mapping); + (message, pending_pastes) +} + // When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering // its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so // the combined local_image_paths order matches the labels, even if placeholders were moved diff --git a/codex-rs/tui/src/goal_files.rs b/codex-rs/tui/src/goal_files.rs index 149e767c4..5a832f9b5 100644 --- a/codex-rs/tui/src/goal_files.rs +++ b/codex-rs/tui/src/goal_files.rs @@ -1,12 +1,13 @@ -//! Materializes oversized TUI goal objectives as app-server-host files. +//! Materializes oversized TUI goal objectives and pastes as app-server-host files. use crate::app_server_session::AppServerSession; +use crate::bottom_pane::ChatComposer; use anyhow::Context; use anyhow::Result; use anyhow::bail; -use anyhow::ensure; use codex_app_server_client::AppServerPath; use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; +use codex_protocol::user_input::TextElement; use uuid::Uuid; const GOAL_ATTACHMENT_DIR: &str = "attachments"; @@ -14,38 +15,83 @@ const GOAL_FILE_PREFIX: &str = "Read the Codex goal objective file at "; const GOAL_FILE_SUFFIX: &str = " before continuing."; const GOAL_FILE_NAME: &str = "goal-objective.md"; +#[derive(Clone, Debug, Default)] +pub(crate) struct GoalDraft { + pub(crate) objective: String, + pub(crate) text_elements: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, +} + pub(crate) type GoalFilePath = AppServerPath; -pub(crate) async fn materialize_goal_objective( +pub(crate) async fn materialize_goal_draft( app_server: &mut AppServerSession, codex_home: Option<&GoalFilePath>, - objective: String, + draft: GoalDraft, ) -> Result<(String, Option)> { - let objective = objective.trim().to_string(); - ensure!(!objective.is_empty(), "Goal objective must not be empty."); - - if objective.chars().count() <= MAX_THREAD_GOAL_OBJECTIVE_CHARS { - return Ok((objective, None)); + let mut objective = draft.objective; + if objective.trim().is_empty() { + bail!("Goal objective must not be empty."); + } + let text_elements = draft.text_elements; + if !draft.pending_pastes.is_empty() { + let (expanded_objective, _) = ChatComposer::expand_pending_pastes( + &objective, + text_elements.clone(), + &draft.pending_pastes, + ); + if expanded_objective.trim().is_empty() { + bail!("Goal objective must not be empty."); + } } - let codex_home = codex_home - .context("App server did not report $CODEX_HOME; cannot materialize goal files")?; - let output_dir = codex_home - .join(GOAL_ATTACHMENT_DIR) - .join(Uuid::new_v4().to_string()); - let path = output_dir.join(GOAL_FILE_NAME); - let reference = objective_file_reference(&path)?; - app_server - .fs_create_directory_all_path(&output_dir) - .await - .map_err(|err| anyhow::anyhow!("{err}")) - .with_context(|| format!("Could not create goal attachment directory {output_dir}"))?; - app_server - .fs_write_file_path(&path, objective.as_bytes().to_vec()) - .await - .map_err(|err| anyhow::anyhow!("{err}")) - .with_context(|| format!("Could not write goal file {path}"))?; - Ok((reference, Some(output_dir))) + let mut active_placeholders = text_elements + .iter() + .filter_map(|element| element.placeholder(&objective)) + .filter(|placeholder| !placeholder.is_empty()) + .collect::>(); + let mut output_dir = None; + let mut replacements = Vec::new(); + for (placeholder, text) in draft.pending_pastes.iter() { + let Some(active_idx) = active_placeholders + .iter() + .position(|active| *active == placeholder.as_str()) + else { + continue; + }; + active_placeholders.swap_remove(active_idx); + let path = ensure_goal_output_dir(app_server, codex_home, &mut output_dir) + .await? + .join(format!("pasted-text-{}.txt", replacements.len() + 1)); + write_goal_file(app_server, path.clone(), text.as_bytes().to_vec()).await?; + + replacements.push(( + placeholder.clone(), + format!("pasted text file: {path}. Read this file before continuing."), + )); + } + + let (expanded_objective, _) = + ChatComposer::expand_pending_pastes(&objective, text_elements, &replacements); + objective = expanded_objective.trim().to_string(); + + if objective.chars().count() > MAX_THREAD_GOAL_OBJECTIVE_CHARS { + let path = ensure_goal_output_dir(app_server, codex_home, &mut output_dir) + .await? + .join(GOAL_FILE_NAME); + let reference = match objective_file_reference(&path) { + Ok(reference) => reference, + Err(err) => { + if let Some(output_dir) = output_dir.as_ref() { + let _ = app_server.fs_remove_path(output_dir).await; + } + return Err(err); + } + }; + write_goal_file(app_server, path.clone(), objective.as_bytes().to_vec()).await?; + objective = reference; + } + Ok((objective, output_dir)) } pub(crate) async fn objective_text_for_edit( @@ -92,3 +138,37 @@ pub(crate) fn objective_file_reference(path: &GoalFilePath) -> Result { } Ok(reference) } + +async fn ensure_goal_output_dir( + app_server: &mut AppServerSession, + codex_home: Option<&GoalFilePath>, + output_dir: &mut Option, +) -> Result { + if let Some(output_dir) = output_dir { + return Ok(output_dir.clone()); + } + let codex_home = codex_home + .context("App server did not report $CODEX_HOME; cannot materialize goal files")?; + let path = codex_home + .join(GOAL_ATTACHMENT_DIR) + .join(Uuid::new_v4().to_string()); + app_server + .fs_create_directory_all_path(&path) + .await + .map_err(|err| anyhow::anyhow!("{err}")) + .with_context(|| format!("Could not create goal attachment directory {path}"))?; + *output_dir = Some(path.clone()); + Ok(path) +} + +async fn write_goal_file( + app_server: &mut AppServerSession, + path: GoalFilePath, + bytes: Vec, +) -> Result<()> { + app_server + .fs_write_file_path(&path, bytes) + .await + .map_err(|err| anyhow::anyhow!("{err}")) + .with_context(|| format!("Could not write goal file {path}")) +}