mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
216dee1189
commit
c0f1ec5afd
@@ -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 } => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})];
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user