mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Materialize long TUI goal inputs
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+12
@@ -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);
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user