Materialize long TUI goal inputs

This commit is contained in:
Eric Traut
2026-06-10 00:11:41 -07:00
parent 51b3cd51f6
commit d7f7844ed0
13 changed files with 446 additions and 232 deletions
+7 -1
View File
@@ -9,6 +9,7 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::goal_display::GOAL_USAGE;
use crate::goal_display::goal_status_label;
use crate::goal_display::goal_usage_summary;
use crate::goal_files;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_protocol::ThreadId;
@@ -246,6 +247,11 @@ impl App {
fn show_replace_thread_goal_confirmation(&mut self, thread_id: ThreadId, objective: String) {
let replace_objective = objective.clone();
let subtitle = if let Some(path) = goal_files::objective_file_path(&objective) {
format!("New objective file: {}", path.display())
} else {
format!("New objective: {objective}")
};
let replace_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::SetThreadGoalObjective {
thread_id,
@@ -270,7 +276,7 @@ impl App {
];
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Replace goal?".to_string()),
subtitle: Some(format!("New objective: {objective}")),
subtitle: Some(subtitle),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
-2
View File
@@ -839,7 +839,6 @@ impl BottomPane {
self.composer.text_elements()
}
#[cfg(test)]
pub(crate) fn composer_local_images(&self) -> Vec<LocalImageAttachment> {
self.composer.local_images()
}
@@ -889,7 +888,6 @@ impl BottomPane {
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn remote_image_urls(&self) -> Vec<String> {
self.composer.remote_image_urls()
}
-1
View File
@@ -345,7 +345,6 @@ use self::goal_status::GoalStatusState;
#[cfg(test)]
use self::goal_status::goal_status_indicator_from_app_goal;
mod goal_menu;
mod goal_validation;
mod ide_context;
use self::ide_context::IdeContextState;
mod input_queue;
+43 -3
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 {
@@ -13,12 +14,35 @@ impl ChatWidget {
let tx = self.app_event_tx.clone();
let status = edited_goal_status(goal.status);
let token_budget = goal.token_budget;
let objective = match goal_files::objective_text_for_edit(&goal.objective) {
Ok(objective) => objective,
Err(err) => {
self.add_error_message(err.to_string());
goal.objective
}
};
let codex_home = self.config.codex_home.clone();
let view = CustomPromptView::new(
"Edit goal".to_string(),
"Type a goal objective and press Enter".to_string(),
goal.objective,
objective,
/*context_label*/ None,
Box::new(move |objective: String| {
let objective = match goal_files::materialize_goal_draft(
codex_home.as_path(),
goal_files::GoalDraft {
objective,
..Default::default()
},
) {
Ok(objective) => objective,
Err(err) => {
tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_error_event(err.to_string()),
)));
return;
}
};
tx.send(AppEvent::SetThreadGoalObjective {
thread_id,
objective,
@@ -37,6 +61,11 @@ impl ChatWidget {
thread_id: ThreadId,
objective: String,
) {
let subtitle = if let Some(path) = goal_files::objective_file_path(&objective) {
format!("Goal file: {}", path.display())
} else {
format!("Goal: {objective}")
};
let resume_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::SetThreadGoalStatus {
thread_id,
@@ -45,7 +74,7 @@ impl ChatWidget {
})];
self.show_selection_view(SelectionViewParams {
title: Some("Resume paused goal?".to_string()),
subtitle: Some(format!("Goal: {objective}")),
subtitle: Some(subtitle),
footer_hint: Some(standard_popup_hint_line()),
initial_selected_idx: Some(0),
items: vec![
@@ -85,7 +114,7 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
"Status: ".dim(),
goal_status_label(goal.status).to_string().into(),
]),
Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]),
goal_objective_line(&goal.objective),
Line::from(vec![
"Time used: ".dim(),
format_goal_elapsed_seconds(goal.time_used_seconds).into(),
@@ -115,6 +144,17 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
lines
}
fn goal_objective_line(objective: &str) -> Line<'static> {
if let Some(path) = goal_files::objective_file_path(objective) {
Line::from(vec![
"Objective file: ".dim(),
path.display().to_string().into(),
])
} else {
Line::from(vec!["Objective: ".dim(), objective.to_string().into()])
}
}
fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
match status {
AppThreadGoalStatus::Active => "active",
@@ -1,64 +0,0 @@
//! Validation helpers for `/goal` objective text.
use super::*;
use crate::bottom_pane::ChatComposer;
use codex_protocol::num_format::format_with_separators;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
const GOAL_TOO_LONG_FILE_HINT: &str = "Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md.";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GoalObjectiveValidationSource {
Live,
Queued,
}
impl ChatWidget {
pub(super) fn goal_objective_with_pending_pastes_is_allowed(
&mut self,
args: &str,
text_elements: &[TextElement],
) -> bool {
let pending_pastes = self.bottom_pane.composer_pending_pastes();
let objective_chars = if pending_pastes.is_empty() {
args.trim().chars().count()
} else {
let (expanded, _) =
ChatComposer::expand_pending_pastes(args, text_elements.to_vec(), &pending_pastes);
expanded.trim().chars().count()
};
self.goal_objective_char_count_is_allowed(
objective_chars,
GoalObjectiveValidationSource::Live,
)
}
pub(super) fn goal_objective_is_allowed(
&mut self,
objective: &str,
source: GoalObjectiveValidationSource,
) -> bool {
self.goal_objective_char_count_is_allowed(objective.chars().count(), source)
}
fn goal_objective_char_count_is_allowed(
&mut self,
actual_chars: usize,
source: GoalObjectiveValidationSource,
) -> bool {
if actual_chars <= MAX_THREAD_GOAL_OBJECTIVE_CHARS {
return true;
}
let actual_chars = format_with_separators(actual_chars as i64);
let max_chars = format_with_separators(MAX_THREAD_GOAL_OBJECTIVE_CHARS as i64);
self.add_error_message(format!(
"Goal objective is too long: {actual_chars} characters. Limit: {max_chars} characters. {GOAL_TOO_LONG_FILE_HINT}"
));
if source == GoalObjectiveValidationSource::Live {
self.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
self.bottom_pane.drain_pending_submission_state();
}
false
}
}
+51 -34
View File
@@ -5,7 +5,6 @@
//! dispatch step and records the staged entry once the command has been handled, so
//! slash-command recall follows the same submitted-input rule as ordinary text.
use super::goal_validation::GoalObjectiveValidationSource;
use super::*;
use crate::app_event::ThreadGoalSetMode;
use crate::bottom_pane::prompt_args::parse_slash_name;
@@ -14,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 {
@@ -539,9 +539,18 @@ impl ChatWidget {
return;
}
if cmd == SlashCommand::Goal
&& !self.goal_objective_with_pending_pastes_is_allowed(&args, &text_elements)
{
if cmd == SlashCommand::Goal {
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
args,
text_elements,
local_images: self.bottom_pane.composer_local_images(),
remote_image_urls: self.bottom_pane.remote_image_urls(),
mention_bindings: Vec::new(),
source: SlashCommandDispatchSource::Live,
},
);
return;
}
@@ -576,6 +585,12 @@ impl ChatWidget {
}
}
fn clear_live_goal_submission(&mut self) {
self.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
self.bottom_pane.drain_pending_submission_state();
}
fn prepared_inline_user_message(
&mut self,
args: String,
@@ -683,6 +698,9 @@ impl ChatWidget {
}
SlashCommand::Goal if !trimmed.is_empty() => {
if !self.config.features.enabled(Feature::Goals) {
if source == SlashCommandDispatchSource::Live {
self.clear_live_goal_submission();
}
return;
}
enum GoalControlCommand {
@@ -696,7 +714,7 @@ impl ChatWidget {
thread_id: self.thread_id,
});
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
self.clear_live_goal_submission();
}
return;
}
@@ -712,6 +730,9 @@ impl ChatWidget {
"The session must start before you can change a goal.".to_string(),
),
);
if source == SlashCommandDispatchSource::Live {
self.clear_live_goal_submission();
}
return;
};
match command {
@@ -726,34 +747,35 @@ impl ChatWidget {
}
self.append_message_history_entry(format!("/goal {trimmed}"));
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
self.clear_live_goal_submission();
}
return;
}
let objective = args.trim();
if objective.is_empty() {
self.add_error_message("Goal objective must not be empty.".to_string());
self.add_info_message(
GOAL_USAGE.to_string(),
Some(GOAL_USAGE_HINT.to_string()),
);
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
}
return;
}
let validation_source = match source {
SlashCommandDispatchSource::Live => GoalObjectiveValidationSource::Live,
SlashCommandDispatchSource::Queued => GoalObjectiveValidationSource::Queued,
let pending_pastes = if source == SlashCommandDispatchSource::Live {
self.bottom_pane.composer_pending_pastes()
} else {
Vec::new()
};
let objective = match crate::goal_files::materialize_goal_draft(
self.config.codex_home.as_path(),
GoalDraft {
objective: args,
pending_pastes,
local_images,
remote_image_urls,
},
) {
Ok(objective) => objective,
Err(err) => {
self.add_error_message(err.to_string());
return;
}
};
if !self.goal_objective_is_allowed(objective, validation_source) {
return;
}
let Some(thread_id) = self.thread_id else {
if source == SlashCommandDispatchSource::Live {
self.queue_user_message_with_options(
UserMessage {
text: format!("/goal {args}"),
text: format!("/goal {objective}"),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
@@ -761,7 +783,7 @@ impl ChatWidget {
},
QueuedInputAction::ParseSlash,
);
self.bottom_pane.drain_pending_submission_state();
self.clear_live_goal_submission();
} else {
self.add_info_message(
GOAL_USAGE.to_string(),
@@ -772,12 +794,12 @@ impl ChatWidget {
};
self.app_event_tx.send(AppEvent::SetThreadGoalObjective {
thread_id,
objective: objective.to_string(),
objective: objective.clone(),
mode: ThreadGoalSetMode::ConfirmIfExists,
});
self.append_message_history_entry(format!("/goal {trimmed}"));
self.append_message_history_entry(format!("/goal {objective}"));
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
self.clear_live_goal_submission();
}
}
SlashCommand::Side | SlashCommand::Btw if !trimmed.is_empty() => {
@@ -914,11 +936,6 @@ impl ChatWidget {
rest_offset + leading_trimmed,
&text_elements,
);
if cmd == SlashCommand::Goal
&& !self.goal_objective_is_allowed(trimmed_rest, GoalObjectiveValidationSource::Queued)
{
return QueueDrain::Continue;
}
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/goal_menu.rs
expression: rendered_goal_summary(&mut rx)
---
Goal
Status: active
Objective file: /tmp/project/goal.md
Time used: 1m
Tokens used: 12.5K
Token budget: 80K
Commands: /goal edit, /goal pause, /goal clear
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
#[tokio::test]
async fn goal_menu_active_snapshot() {
@@ -70,6 +71,25 @@ async fn goal_menu_budget_limited_snapshot() {
assert_chatwidget_snapshot!("goal_menu_budget_limited", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_managed_file_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
let mut goal = test_goal(
thread_id,
AppThreadGoalStatus::Active,
/*token_budget*/ Some(80_000),
);
goal.objective = format!(
"Goal objective file: {}\nRead that file before continuing.",
test_path_display("/tmp/project/goal.md")
);
chat.show_goal_summary(goal);
assert_chatwidget_snapshot!("goal_menu_managed_file", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn resume_paused_goal_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -145,6 +165,44 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() {
assert!(chat.no_modal_or_popup_active());
}
#[tokio::test]
async fn goal_edit_prompt_hydrates_and_materializes_oversized_objective_file() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
let path = chat.config.codex_home.join("managed-goal.md");
std::fs::write(&path, &objective).expect("write goal file");
let mut goal = test_goal(
thread_id,
AppThreadGoalStatus::Paused,
/*token_budget*/ Some(80_000),
);
goal.objective = format!(
"Goal objective file: {}\nRead that file before continuing.",
path.display()
);
chat.show_goal_edit_prompt(thread_id, goal);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
objective: actual_objective,
..
}) => {
let path =
crate::goal_files::objective_file_path(&actual_objective).unwrap_or_else(|| {
panic!("expected goal file objective, got {actual_objective:?}")
});
assert_eq!(
std::fs::read_to_string(path).expect("read goal file"),
objective
);
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
}
}
#[tokio::test]
async fn goal_edit_prompt_preserves_resumable_stopped_statuses() {
for stopped_status in [
@@ -1,7 +1,7 @@
use super::*;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
if let Some(message) = message {
@@ -33,25 +33,34 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
fn drain_app_events(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> Vec<AppEvent> {
std::iter::from_fn(|| rx.try_recv().ok()).collect()
fn goal_file_path(objective: &str) -> PathBuf {
crate::goal_files::objective_file_path(objective)
.unwrap_or_else(|| panic!("expected goal file objective, got {objective:?}"))
}
fn rendered_insert_history(events: &[AppEvent]) -> String {
events
.iter()
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(
cell.display_lines(/*width*/ 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
fn path_after_prefix(text: &str, prefix: &str) -> PathBuf {
let rest = text
.split_once(prefix)
.unwrap_or_else(|| panic!("expected {prefix:?} in {text:?}"))
.1;
PathBuf::from(rest.lines().next().expect("path line").trim())
}
fn next_goal_objective(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
expected_thread_id: ThreadId,
) -> String {
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(thread_id, expected_thread_id);
objective
}
#[tokio::test]
@@ -65,17 +74,7 @@ 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 {
thread_id: actual_thread_id,
objective: actual_objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(actual_objective, objective);
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
assert_no_submit_op(&mut op_rx);
}
@@ -89,55 +88,39 @@ 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 {
thread_id: actual_thread_id,
objective: actual_objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(actual_objective, objective);
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn goal_slash_command_rejects_oversized_objective() {
async fn goal_slash_command_materializes_oversized_objective() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = Some(ThreadId::new());
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
submit_composer_text(&mut chat, &format!("/goal {objective}"));
let events = drain_app_events(&mut rx);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
"oversized goal should not emit a SetThreadGoalObjective event: {events:?}"
);
let rendered = rendered_insert_history(&events);
assert!(rendered.contains("Goal objective is too long"));
assert!(rendered.contains("Put longer instructions in a file"));
assert!(
!rendered.contains("Message exceeds the maximum length"),
"expected goal-specific length error, got {rendered:?}"
let actual_objective = next_goal_objective(&mut rx, thread_id);
let path = goal_file_path(&actual_objective);
assert_eq!(
std::fs::read_to_string(path).expect("read goal file"),
objective
);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn goal_slash_command_rejects_large_paste_using_expanded_length() {
async fn goal_slash_command_materializes_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);
chat.thread_id = Some(ThreadId::new());
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.bottom_pane
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
chat.handle_paste(objective);
chat.handle_paste(objective.clone());
assert!(
chat.bottom_pane.composer_text().contains("[Pasted Content"),
@@ -145,56 +128,21 @@ async fn goal_slash_command_rejects_large_paste_using_expanded_length() {
);
submit_current_composer(&mut chat);
let events = drain_app_events(&mut rx);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
"oversized pasted goal should not emit a SetThreadGoalObjective event: {events:?}"
);
let rendered = rendered_insert_history(&events);
assert!(rendered.contains("Goal objective is too long"));
assert!(rendered.contains("Put longer instructions in a file"));
assert!(
!rendered.contains("Message exceeds the maximum length"),
"expected goal-specific length error, got {rendered:?}"
let actual_objective = next_goal_objective(&mut rx, thread_id);
let path = path_after_prefix(&actual_objective, "pasted text file: ");
assert_eq!(
std::fs::read_to_string(path).expect("read paste file"),
objective
);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn goal_slash_command_giant_paste_uses_goal_specific_error() {
async fn queued_goal_slash_command_materializes_oversized_objective_and_stops_queue() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = Some(ThreadId::new());
chat.bottom_pane
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
chat.handle_paste("x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1));
submit_current_composer(&mut chat);
let events = drain_app_events(&mut rx);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
"giant pasted goal should not emit a SetThreadGoalObjective event: {events:?}"
);
let rendered = rendered_insert_history(&events);
assert!(rendered.contains("Goal objective is too long"));
assert!(rendered.contains("Put longer instructions in a file"));
assert!(
!rendered.contains("Message exceeds the maximum length"),
"expected goal-specific length error, got {rendered:?}"
);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_input() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = Some(ThreadId::new());
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
handle_turn_started(&mut chat, "turn-1");
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
@@ -204,26 +152,22 @@ async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_i
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let events = drain_app_events(&mut rx);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
"oversized queued goal should not emit a SetThreadGoalObjective event: {events:?}"
let (actual_thread_id, actual_objective) = loop {
match rx.try_recv().expect("expected goal objective event") {
AppEvent::SetThreadGoalObjective {
thread_id,
objective,
..
} => break (thread_id, objective),
_ => continue,
}
};
assert_eq!(actual_thread_id, thread_id);
let path = goal_file_path(&actual_objective);
assert_eq!(
std::fs::read_to_string(path).expect("read goal file"),
objective
);
let rendered = rendered_insert_history(&events);
assert!(rendered.contains("Goal objective is too long"));
assert!(rendered.contains("Put longer instructions in a file"));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "continue".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued follow-up after oversized goal, got {other:?}"),
}
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_no_submit_op(&mut op_rx);
}
@@ -660,24 +660,25 @@ async fn goal_slash_command_uses_plain_text_for_mentions() {
}
#[tokio::test]
async fn goal_slash_command_drops_attached_images() {
async fn goal_slash_command_materializes_attached_images() {
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 remote_url = "https://example.com/goal.png".to_string();
let local_image = PathBuf::from("/tmp/goal-local.png");
let local_image = chat.config.codex_home.join("goal-local.png");
std::fs::write(&local_image, b"png bytes").expect("write local image");
let placeholder = "[Image #2]";
let command = format!("/goal describe {placeholder}");
let placeholder_start = command.find(placeholder).expect("placeholder in command");
chat.set_remote_image_urls(vec![remote_url]);
chat.set_remote_image_urls(vec![remote_url.clone()]);
chat.bottom_pane.set_composer_text(
command,
vec![TextElement::new(
(placeholder_start..placeholder_start + placeholder.len()).into(),
Some(placeholder.to_string()),
)],
vec![local_image],
vec![local_image.to_path_buf()],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -692,7 +693,20 @@ async fn goal_slash_command_drops_attached_images() {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "describe [Image #2]");
assert!(objective.contains("describe image file: "));
assert!(objective.contains("Referenced image URLs:"));
assert!(objective.contains(&remote_url));
let copied_image = objective
.split_once("image file: ")
.expect("image file reference")
.1
.lines()
.next()
.expect("image path");
assert_eq!(
std::fs::read(copied_image).expect("read copied image"),
b"png bytes"
);
assert!(chat.remote_image_urls().is_empty());
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
assert_no_submit_op(&mut op_rx);
+19 -1
View File
@@ -42,7 +42,12 @@ pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str {
}
pub(crate) fn goal_usage_summary(goal: &ThreadGoal) -> String {
let mut parts = vec![format!("Objective: {}", goal.objective)];
let objective = if let Some(path) = crate::goal_files::objective_file_path(&goal.objective) {
format!("Objective file: {}", path.display())
} else {
format!("Objective: {}", goal.objective)
};
let mut parts = vec![objective];
if goal.time_used_seconds > 0 {
parts.push(format!(
"Time: {}.",
@@ -108,4 +113,17 @@ mod tests {
"Objective: Complete the task described in ../gameboy-long-running-prompt5.txt Time: 2m. Tokens: 63.9K/50K."
);
}
#[test]
fn goal_usage_summary_formats_managed_file_objective() {
let mut goal = test_thread_goal(/*token_budget*/ None, /*tokens_used*/ 0);
goal.objective =
"Goal objective file: /tmp/project/goal.md\nRead that file before continuing."
.to_string();
assert_eq!(
goal_usage_summary(&goal),
"Objective file: /tmp/project/goal.md Time: 2m."
);
}
}
+171
View File
@@ -0,0 +1,171 @@
//! File materialization helpers for TUI goal objectives.
//!
//! Long objectives, pasted text, and local images are written under the Codex
//! home directory. The persisted goal objective keeps references to those files
//! so later continuations can read them by path.
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use crate::bottom_pane::LocalImageAttachment;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use uuid::Uuid;
const GOAL_ATTACHMENT_DIR: &str = "attachments";
const GOAL_FILE_PREFIX: &str = "Goal objective file: ";
const GOAL_FILE_NAME: &str = "goal-objective.md";
#[derive(Debug, Default)]
pub(crate) struct GoalDraft {
pub(crate) objective: String,
pub(crate) pending_pastes: Vec<(String, String)>,
pub(crate) local_images: Vec<LocalImageAttachment>,
pub(crate) remote_image_urls: Vec<String>,
}
pub(crate) fn materialize_goal_draft(codex_home: &Path, draft: GoalDraft) -> Result<String> {
let mut objective = draft.objective.trim().to_string();
if objective.is_empty() {
bail!("Goal objective must not be empty.");
}
let mut output_dir = None;
for (idx, (placeholder, text)) in draft.pending_pastes.iter().enumerate() {
let path = ensure_output_dir(codex_home, &mut output_dir)?
.join(format!("pasted-text-{}.txt", idx + 1));
write_file(&path, text)?;
if !placeholder.is_empty() {
let replacement = format!("pasted text file: {}", path.display());
objective = objective.replace(placeholder, &replacement);
}
}
let mut image_lines = Vec::new();
for (idx, image) in draft.local_images.iter().enumerate() {
let extension = image_extension(&image.path);
let path = ensure_output_dir(codex_home, &mut output_dir)?.join(format!(
"image-{}.{}",
idx + 1,
extension
));
fs::copy(&image.path, &path).with_context(|| {
format!(
"Could not copy goal image from {} to {}",
image.path.display(),
path.display()
)
})?;
if image.placeholder.is_empty() {
image_lines.push(format!("- [Image #{}]: {}", idx + 1, path.display()));
} else {
objective = objective.replace(
&image.placeholder,
&format!("image file: {}", path.display()),
);
}
}
append_section(&mut objective, "Referenced image files:", image_lines);
append_section(
&mut objective,
"Referenced image URLs:",
draft
.remote_image_urls
.into_iter()
.map(|url| format!("- {url}"))
.collect(),
);
if objective.chars().count() > MAX_THREAD_GOAL_OBJECTIVE_CHARS {
let path = ensure_output_dir(codex_home, &mut output_dir)?.join(GOAL_FILE_NAME);
write_file(&path, &objective)?;
objective = objective_file_reference(&path)?;
}
Ok(objective)
}
pub(crate) fn objective_text_for_edit(objective: &str) -> Result<String> {
let Some(path) = objective_file_path(objective) else {
return Ok(objective.to_string());
};
fs::read_to_string(&path)
.with_context(|| format!("Could not read goal objective file {}", path.display()))
}
pub(crate) fn objective_file_path(objective: &str) -> Option<PathBuf> {
objective
.lines()
.next()?
.strip_prefix(GOAL_FILE_PREFIX)
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
fn ensure_output_dir(codex_home: &Path, output_dir: &mut Option<PathBuf>) -> Result<PathBuf> {
if let Some(output_dir) = output_dir {
return Ok(output_dir.clone());
}
let path = codex_home
.join(GOAL_ATTACHMENT_DIR)
.join(Uuid::new_v4().to_string());
fs::create_dir_all(&path).with_context(|| {
format!(
"Could not create goal attachment directory {}",
path.display()
)
})?;
*output_dir = Some(path.clone());
Ok(path)
}
fn write_file(path: &Path, content: &str) -> Result<()> {
fs::write(path, content)
.with_context(|| format!("Could not write goal file {}", path.display()))
}
fn objective_file_reference(path: &Path) -> Result<String> {
let reference = format!(
"{GOAL_FILE_PREFIX}{}\nRead that file before continuing.",
path.display()
);
let actual_chars = reference.chars().count();
if actual_chars > MAX_THREAD_GOAL_OBJECTIVE_CHARS {
bail!(
"Goal objective file reference is too long: {actual_chars} characters. Limit: {MAX_THREAD_GOAL_OBJECTIVE_CHARS} characters."
);
}
Ok(reference)
}
fn append_section(objective: &mut String, heading: &str, lines: Vec<String>) {
if lines.is_empty() {
return;
}
if !objective.ends_with('\n') {
objective.push_str("\n\n");
}
objective.push_str(heading);
objective.push('\n');
objective.push_str(&lines.join("\n"));
}
fn image_extension(path: &Path) -> String {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
extension
.chars()
.filter(char::is_ascii_alphanumeric)
.take(8)
.collect::<String>()
})
.filter(|extension| !extension.is_empty())
.unwrap_or_else(|| "png".to_string())
}
+1
View File
@@ -138,6 +138,7 @@ mod frames;
mod get_git_diff;
mod git_action_directives;
mod goal_display;
mod goal_files;
mod history_cell;
mod hooks_rpc;
mod ide_context;