[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
2026-06-12 15:34:04 -07:00
committed by GitHub
Unverified
parent 216dee1189
commit c0f1ec5afd
17 changed files with 600 additions and 201 deletions
+3 -3
View File
@@ -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 } => {
+91 -6
View File
@@ -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)
+11 -10
View File
@@ -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<SelectionAction> = 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,
});
})];
+4 -3
View File
@@ -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<ThreadId>,
},
/// 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,
},
+42 -3
View File
@@ -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<TextElement>,
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<TextElement>)> {
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<TextElement>)> {
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());
+4
View File
@@ -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();
+6 -2
View File
@@ -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,
+10 -4
View File
@@ -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;
+81 -55
View File
@@ -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<UserMessage> {
pub(super) fn pop_latest_queued_composer_state(&mut self) -> Option<ThreadComposerState> {
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<UserMessage> {
fn drain_pending_messages_for_restore(&mut self) -> Option<ThreadComposerState> {
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<ThreadInputState> {
@@ -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());
+2 -2
View File
@@ -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();
}
+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,
+16
View File
@@ -213,6 +213,22 @@ macro_rules! assert_chatwidget_snapshot {
}};
}
fn next_goal_draft(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
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;
@@ -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);
}
@@ -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<String>,
) -> (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
+107 -27
View File
@@ -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<TextElement>,
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<GoalFilePath>)> {
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::<Vec<_>>();
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<String> {
}
Ok(reference)
}
async fn ensure_goal_output_dir(
app_server: &mut AppServerSession,
codex_home: Option<&GoalFilePath>,
output_dir: &mut Option<GoalFilePath>,
) -> Result<GoalFilePath> {
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<u8>,
) -> 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}"))
}