diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 62e074d6cf..dbf0cc5daa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -194,6 +194,7 @@ mod session_lifecycle; mod side; mod startup_prompts; mod thread_events; +mod thread_goal_actions; mod thread_routing; mod thread_session_state; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 2c6c39a3d8..71292ab933 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -471,6 +471,24 @@ impl App { AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } + AppEvent::OpenThreadGoalMenu { thread_id } => { + self.open_thread_goal_menu(app_server, thread_id).await; + } + AppEvent::SetThreadGoalObjective { + thread_id, + objective, + mode, + } => { + self.set_thread_goal_objective(app_server, thread_id, objective, mode) + .await; + } + AppEvent::SetThreadGoalStatus { thread_id, status } => { + self.set_thread_goal_status(app_server, thread_id, status) + .await; + } + AppEvent::ClearThreadGoal { thread_id } => { + self.clear_thread_goal(app_server, thread_id).await; + } AppEvent::SendAddCreditsNudgeEmail { credit_type } => { if self .chat_widget diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs new file mode 100644 index 0000000000..bf589b6a5b --- /dev/null +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -0,0 +1,184 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::ThreadGoalSetMode; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::goal_display::goal_status_label; +use crate::goal_display::goal_usage_summary; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_protocol::ThreadId; + +impl App { + pub(super) async fn open_thread_goal_menu( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) { + let result = app_server.thread_goal_get(thread_id).await; + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + + let response = match result { + Ok(response) => response, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to read thread goal: {err}")); + return; + } + }; + + let Some(goal) = response.goal else { + self.chat_widget.add_info_message( + "Usage: /goal ".to_string(), + Some("No goal is currently set.".to_string()), + ); + return; + }; + + self.chat_widget.show_goal_summary(goal); + } + + pub(super) async fn set_thread_goal_objective( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + objective: String, + mode: ThreadGoalSetMode, + ) { + if mode == ThreadGoalSetMode::ConfirmIfExists { + let result = app_server.thread_goal_get(thread_id).await; + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + + match result { + Ok(response) if response.goal.is_some() => { + self.show_replace_thread_goal_confirmation(thread_id, objective); + return; + } + Ok(_) => {} + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to read thread goal: {err}")); + return; + } + } + } + + let result = app_server + .thread_goal_set( + thread_id, + Some(objective), + Some(ThreadGoalStatus::Active), + /*token_budget*/ None, + ) + .await; + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + + match result { + Ok(response) => self.chat_widget.add_info_message( + format!("Goal {}", goal_status_label(response.goal.status)), + Some(goal_usage_summary(&response.goal)), + ), + Err(err) => self + .chat_widget + .add_error_message(format!("Failed to set thread goal: {err}")), + } + } + + pub(super) async fn set_thread_goal_status( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + status: ThreadGoalStatus, + ) { + let result = app_server + .thread_goal_set( + thread_id, + /*objective*/ None, + Some(status), + /*token_budget*/ None, + ) + .await; + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + + match result { + Ok(response) => self.chat_widget.add_info_message( + format!("Goal {}", goal_status_label(response.goal.status)), + Some(goal_usage_summary(&response.goal)), + ), + Err(err) => self + .chat_widget + .add_error_message(format!("Failed to update thread goal: {err}")), + } + } + + pub(super) async fn clear_thread_goal( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) { + let result = app_server.thread_goal_clear(thread_id).await; + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + + match result { + Ok(response) => { + if response.cleared { + self.chat_widget + .add_info_message("Goal cleared".to_string(), /*hint*/ None); + } else { + self.chat_widget.add_info_message( + "No goal to clear".to_string(), + Some("This thread does not currently have a goal.".to_string()), + ); + } + } + Err(err) => self + .chat_widget + .add_error_message(format!("Failed to clear thread goal: {err}")), + } + } + + fn show_replace_thread_goal_confirmation(&mut self, thread_id: ThreadId, objective: String) { + let replace_objective = objective.clone(); + let replace_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SetThreadGoalObjective { + thread_id, + objective: replace_objective.clone(), + mode: ThreadGoalSetMode::ReplaceExisting, + }); + })]; + let items = vec![ + SelectionItem { + name: "Replace current goal".to_string(), + description: Some("Set the new objective and start it now".to_string()), + actions: replace_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Keep the current goal".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Replace goal?".to_string()), + subtitle: Some(format!("New objective: {objective}")), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8654684ace..fa3549e6a1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -54,6 +55,12 @@ pub(crate) enum RealtimeAudioDeviceKind { Speaker, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ThreadGoalSetMode { + ConfirmIfExists, + ReplaceExisting, +} + impl RealtimeAudioDeviceKind { pub(crate) fn title(self) -> &'static str { match self { @@ -187,6 +194,29 @@ pub(crate) enum AppEvent { origin: RateLimitRefreshOrigin, }, + /// Open the current thread goal summary/action menu. + OpenThreadGoalMenu { + thread_id: ThreadId, + }, + + /// Set or replace the current thread goal objective. + SetThreadGoalObjective { + thread_id: ThreadId, + objective: String, + mode: ThreadGoalSetMode, + }, + + /// Pause or unpause the current thread goal. + SetThreadGoalStatus { + thread_id: ThreadId, + status: ThreadGoalStatus, + }, + + /// Clear the current thread goal. + ClearThreadGoal { + thread_id: ThreadId, + }, + /// Result of refreshing rate limits. RateLimitsLoaded { origin: RateLimitRefreshOrigin, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index e29a0dc18c..44e745e16e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -43,6 +43,13 @@ use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadGoalClearParams; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalGetParams; +use codex_app_server_protocol::ThreadGoalGetResponse; +use codex_app_server_protocol::ThreadGoalSetParams; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadInjectItemsParams; use codex_app_server_protocol::ThreadInjectItemsResponse; use codex_app_server_protocol::ThreadListParams; @@ -667,6 +674,60 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn thread_goal_get( + &mut self, + thread_id: ThreadId, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadGoalGet { + request_id, + params: ThreadGoalGetParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/goal/get failed in TUI") + } + + pub(crate) async fn thread_goal_set( + &mut self, + thread_id: ThreadId, + objective: Option, + status: Option, + token_budget: Option>, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadGoalSet { + request_id, + params: ThreadGoalSetParams { + thread_id: thread_id.to_string(), + objective, + status, + token_budget, + }, + }) + .await + .wrap_err("thread/goal/set failed in TUI") + } + + pub(crate) async fn thread_goal_clear( + &mut self, + thread_id: ThreadId, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadGoalClear { + request_id, + params: ThreadGoalClearParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/goal/clear failed in TUI") + } + pub(crate) async fn logout_account(&mut self) -> Result<()> { let request_id = self.next_request_id(); let _: LogoutAccountResponse = self diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a529d169c4..9b67e29b57 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -121,6 +121,7 @@ //! overall state machine, since it affects which transitions are even possible from a given UI //! state. //! +use crate::bottom_pane::footer::goal_status_indicator_line; use crate::bottom_pane::footer::mode_indicator_line; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -156,6 +157,7 @@ use super::file_search_popup::FileSearchPopup; use super::footer::CollaborationModeIndicator; use super::footer::FooterMode; use super::footer::FooterProps; +use super::footer::GoalStatusIndicator; use super::footer::SummaryLeft; use super::footer::can_show_left_with_context; use super::footer::context_window_line; @@ -371,9 +373,11 @@ pub(crate) struct ChatComposer { collaboration_modes_enabled: bool, config: ChatComposerConfig, collaboration_mode_indicator: Option, + goal_status_indicator: Option, connectors_enabled: bool, plugins_command_enabled: bool, fast_command_enabled: bool, + goal_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, @@ -427,6 +431,15 @@ enum SlashValidation { const FOOTER_SPACING_HEIGHT: u16 = 0; +fn status_line_right_indicator( + collaboration_mode_indicator: Option, + goal_status_indicator: Option<&GoalStatusIndicator>, + show_cycle_hint: bool, +) -> Option> { + mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) + .or_else(|| goal_status_indicator_line(goal_status_indicator)) +} + impl ChatComposer { fn builtin_command_flags(&self) -> BuiltinCommandFlags { BuiltinCommandFlags { @@ -434,6 +447,7 @@ impl ChatComposer { connectors_enabled: self.connectors_enabled, plugins_command_enabled: self.plugins_command_enabled, fast_command_enabled: self.fast_command_enabled, + goal_command_enabled: self.goal_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, audio_device_selection_enabled: self.audio_device_selection_enabled, @@ -516,9 +530,11 @@ impl ChatComposer { collaboration_modes_enabled: false, config, collaboration_mode_indicator: None, + goal_status_indicator: None, connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, + goal_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -606,6 +622,10 @@ impl ChatComposer { self.fast_command_enabled = enabled; } + pub fn set_goal_command_enabled(&mut self, enabled: bool) { + self.goal_command_enabled = enabled; + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -613,6 +633,10 @@ impl ChatComposer { self.collaboration_mode_indicator = indicator; } + pub fn set_goal_status_indicator(&mut self, indicator: Option) { + self.goal_status_indicator = indicator; + } + pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.personality_command_enabled = enabled; } @@ -3475,6 +3499,7 @@ impl ChatComposer { let connectors_enabled = self.connectors_enabled; let plugins_command_enabled = self.plugins_command_enabled; let fast_command_enabled = self.fast_command_enabled; + let goal_command_enabled = self.goal_command_enabled; let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; @@ -3483,6 +3508,7 @@ impl ChatComposer { connectors_enabled, plugins_command_enabled, fast_command_enabled, + goal_command_enabled, personality_command_enabled, realtime_conversation_enabled, audio_device_selection_enabled, @@ -3963,31 +3989,34 @@ impl ChatComposer { show_queue_hint, ) }; - let right_line = if let Some(label) = - self.side_conversation_context_label.as_ref() - { - Some(side_conversation_context_line(label)) - } else if let Some(line) = self.shell_mode_footer_line() { - Some(line) - } else if status_line_active { - let full = - mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint); - let compact = mode_indicator_line( - self.collaboration_mode_indicator, - /*show_cycle_hint*/ false, - ); - let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); - if can_show_left_with_context(hint_rect, left_width, full_width) { - full + let right_line = + if let Some(label) = self.side_conversation_context_label.as_ref() { + Some(side_conversation_context_line(label)) + } else if let Some(line) = self.shell_mode_footer_line() { + Some(line) + } else if status_line_active { + let full = status_line_right_indicator( + self.collaboration_mode_indicator, + self.goal_status_indicator.as_ref(), + show_cycle_hint, + ); + let compact = status_line_right_indicator( + self.collaboration_mode_indicator, + self.goal_status_indicator.as_ref(), + /*show_cycle_hint*/ false, + ); + let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); + if can_show_left_with_context(hint_rect, left_width, full_width) { + full + } else { + compact + } } else { - compact - } - } else { - Some(context_window_line( - footer_props.context_window_percent, - footer_props.context_window_used_tokens, - )) - }; + Some(context_window_line( + footer_props.context_window_percent, + footer_props.context_window_used_tokens, + )) + }; let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); if status_line_active && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 28c749cb57..3b880a5f1a 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -34,6 +34,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, pub(crate) fast_command_enabled: bool, + pub(crate) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, @@ -48,6 +49,7 @@ impl From for slash_commands::BuiltinCommandFlags { connectors_enabled: value.connectors_enabled, plugins_command_enabled: value.plugins_command_enabled, fast_command_enabled: value.fast_command_enabled, + goal_command_enabled: value.goal_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, audio_device_selection_enabled: value.audio_device_selection_enabled, @@ -357,6 +359,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, + goal_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -378,6 +381,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, + goal_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -399,6 +403,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, + goal_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -427,6 +432,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, + goal_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: false, audio_device_selection_enabled: false, @@ -448,6 +454,7 @@ mod tests { connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, + goal_command_enabled: false, personality_command_enabled: true, realtime_conversation_enabled: true, audio_device_selection_enabled: false, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 4c8b181b2a..4f4f1d9ae2 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -95,6 +95,14 @@ pub(crate) enum CollaborationModeIndicator { Execute, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum GoalStatusIndicator { + Active { usage: Option }, + Paused, + BudgetLimited { usage: Option }, + Complete { usage: Option }, +} + const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; const FOOTER_CONTEXT_GAP_COLS: u16 = 1; @@ -483,6 +491,38 @@ pub(crate) fn mode_indicator_line( indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)])) } +pub(crate) fn goal_status_indicator_line( + indicator: Option<&GoalStatusIndicator>, +) -> Option> { + let indicator = indicator?; + let label = match indicator { + GoalStatusIndicator::Active { usage } => { + if let Some(usage) = usage { + format!("Pursuing goal ({usage})") + } else { + "Pursuing goal".to_string() + } + } + GoalStatusIndicator::Paused => "Goal paused (/goal to unpause)".to_string(), + GoalStatusIndicator::BudgetLimited { usage } => { + if let Some(usage) = usage { + format!("Goal unmet ({usage})") + } else { + "Goal abandoned".to_string() + } + } + GoalStatusIndicator::Complete { usage } => { + if let Some(usage) = usage { + format!("Goal achieved ({usage})") + } else { + "Goal achieved".to_string() + } + } + }; + + Some(Line::from(vec![Span::from(label).magenta()])) +} + pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> { if let Some(rest) = label.strip_prefix("Side ") { Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()]) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c9dc4db263..a2067fb7e7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -90,6 +90,9 @@ mod skill_popup; mod skills_toggle_view; pub(crate) mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; +pub(crate) use footer::GoalStatusIndicator; +#[cfg(test)] +pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; @@ -332,6 +335,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_goal_status_indicator(&mut self, indicator: Option) { + self.composer.set_goal_status_indicator(indicator); + self.request_redraw(); + } + pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.composer.set_personality_command_enabled(enabled); self.request_redraw(); @@ -342,6 +350,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_goal_command_enabled(&mut self, enabled: bool) { + self.composer.set_goal_command_enabled(enabled); + self.request_redraw(); + } + pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) { self.composer.set_realtime_conversation_enabled(enabled); self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 78d99e3e67..f75d759d5e 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -16,6 +16,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, pub(crate) fast_command_enabled: bool, + pub(crate) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, @@ -35,6 +36,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) + .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) @@ -75,6 +77,7 @@ mod tests { connectors_enabled: true, plugins_command_enabled: true, fast_command_enabled: true, + goal_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, audio_device_selection_enabled: true, @@ -120,6 +123,13 @@ mod tests { assert_eq!(find_builtin_command("fast", flags), None); } + #[test] + fn goal_command_is_hidden_when_disabled() { + let mut flags = all_enabled_flags(); + flags.goal_command_enabled = false; + assert_eq!(find_builtin_command("goal", flags), None); + } + #[test] fn realtime_command_is_hidden_when_realtime_is_disabled() { let mut flags = all_enabled_flags(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4aa49d7119..4eb4bd0cc9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -100,6 +100,8 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ModelVerification as AppServerModelVerification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadGoal as AppThreadGoal; +use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ToolRequestUserInputParams; @@ -207,6 +209,8 @@ use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; use codex_protocol::protocol::TerminalInteractionEvent; +#[cfg(test)] +use codex_protocol::protocol::ThreadGoalStatus as ProtocolThreadGoalStatus; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortReason; @@ -322,6 +326,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; +use crate::bottom_pane::GoalStatusIndicator; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -367,6 +372,11 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +mod goal_status; +use self::goal_status::GoalStatusState; +#[cfg(test)] +use self::goal_status::goal_status_indicator_from_app_goal; +mod goal_menu; mod interrupts; use self::interrupts::InterruptManager; mod session_header; @@ -907,6 +917,7 @@ pub(crate) struct ChatWidget { suppress_queue_autosend: bool, thread_id: Option, last_turn_id: Option, + budget_limited_turn_ids: HashSet, thread_name: Option, thread_rename_block_message: Option, active_side_conversation: bool, @@ -928,10 +939,20 @@ pub(crate) struct ChatWidget { suppress_initial_user_message_submit: bool, // User inputs queued while a turn is in progress. queued_user_messages: VecDeque, + // History records for queued user messages. Slash commands such as `/goal` + // can render history that differs from the text submitted to core, so this + // stays in lockstep with `queued_user_messages`, with missing entries + // treated as user-message text. + queued_user_message_history_records: VecDeque, // A user turn has been submitted to core, but `TurnStarted` has not arrived yet. user_turn_pending_start: bool, // User messages that tried to steer a non-regular turn and must be retried first. rejected_steers_queue: VecDeque, + // History records for rejected steers. Slash commands such as `/goal` can + // render history that differs from the text submitted to core, so this stays + // in lockstep with `rejected_steers_queue`, with missing entries treated as + // user-message text. + rejected_steer_history_records: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -1026,6 +1047,10 @@ pub(crate) struct ChatWidget { status_line_branch_pending: bool, // True once we've attempted a branch lookup for the current CWD. status_line_branch_lookup_complete: bool, + // Current thread-goal status shown in the status line when plan mode is inactive. + current_goal_status_indicator: Option, + current_goal_status: Option, + goal_status_active_turn_started_at: Option, external_editor_state: ExternalEditorState, realtime_conversation: RealtimeConversationUiState, last_rendered_user_message_event: Option, @@ -1088,6 +1113,18 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +#[derive(Clone, Debug, PartialEq)] +enum UserMessageHistoryRecord { + UserMessageText, + Override(UserMessageHistoryOverride), +} + +#[derive(Clone, Debug, PartialEq)] +struct UserMessageHistoryOverride { + text: String, + text_elements: Vec, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShellEscapePolicy { Allow, @@ -1158,8 +1195,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + pending_steer_history_records: VecDeque, rejected_steers_queue: VecDeque, + rejected_steer_history_records: VecDeque, queued_user_messages: VecDeque, + queued_user_message_history_records: VecDeque, user_turn_pending_start: bool, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, @@ -1195,6 +1235,7 @@ impl From<&str> for UserMessage { struct PendingSteer { user_message: UserMessage, + history_record: UserMessageHistoryRecord, compare_key: PendingSteerCompareKey, } @@ -1247,28 +1288,10 @@ fn append_text_with_rebased_elements( })); } -// 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 -// in the text (e.g., [Image #2] appearing before [Image #1]). -fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { - let UserMessage { - text, - text_elements, - local_images, - remote_image_urls, - mention_bindings, - } = message; - if local_images.is_empty() { - return UserMessage { - text, - text_elements, - local_images, - remote_image_urls, - mention_bindings, - }; - } - +fn build_placeholder_mapping( + local_images: Vec, + next_label: &mut usize, +) -> (HashMap, Vec) { let mut mapping: HashMap = HashMap::new(); let mut remapped_images = Vec::new(); for attachment in local_images { @@ -1280,6 +1303,17 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) path: attachment.path, }); } + (mapping, remapped_images) +} + +fn remap_placeholders_in_text( + text: String, + text_elements: Vec, + mapping: &HashMap, +) -> (String, Vec) { + if mapping.is_empty() { + return (text, text_elements); + } let mut elements = text_elements; elements.sort_by_key(|elem| elem.byte_range.start); @@ -1316,16 +1350,93 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(segment); } - UserMessage { - text: rebuilt, - local_images: remapped_images, + (rebuilt, rebuilt_elements) +} + +// 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 +// in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to +// history overrides so restored drafts and rendered transcript entries agree. +fn remap_placeholders_for_message_and_history_record( + message: UserMessage, + history_record: UserMessageHistoryRecord, + next_label: &mut usize, +) -> (UserMessage, UserMessageHistoryRecord) { + let UserMessage { + text, + text_elements, + local_images, remote_image_urls, - text_elements: rebuilt_elements, mention_bindings, - } + } = message; + let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label); + let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping); + let history_record = match history_record { + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + let (text, text_elements) = + remap_placeholders_in_text(history.text, history.text_elements, &mapping); + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text, + text_elements, + }) + } + record => record, + }; + + ( + UserMessage { + text, + local_images: remapped_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + history_record, + ) +} + +#[cfg(test)] +fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { + remap_placeholders_for_message_and_history_record( + message, + UserMessageHistoryRecord::UserMessageText, + next_label, + ) + .0 +} + +fn remap_user_messages_with_history_records( + messages: Vec<(UserMessage, UserMessageHistoryRecord)>, +) -> Vec<(UserMessage, UserMessageHistoryRecord)> { + let total_remote_images = messages + .iter() + .map(|(message, _)| message.remote_image_urls.len()) + .sum::(); + let mut next_image_label = total_remote_images + 1; + messages + .into_iter() + .map(|(message, history_record)| { + remap_placeholders_for_message_and_history_record( + message, + history_record, + &mut next_image_label, + ) + }) + .collect() } fn merge_user_messages(messages: Vec) -> UserMessage { + let messages = remap_user_messages_with_history_records( + messages + .into_iter() + .map(|message| (message, UserMessageHistoryRecord::UserMessageText)) + .collect(), + ); + merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)) +} + +fn merge_remapped_user_messages(messages: impl IntoIterator) -> UserMessage { let mut combined = UserMessage { text: String::new(), text_elements: Vec::new(), @@ -1333,11 +1444,6 @@ fn merge_user_messages(messages: Vec) -> UserMessage { remote_image_urls: Vec::new(), mention_bindings: Vec::new(), }; - let total_remote_images = messages - .iter() - .map(|message| message.remote_image_urls.len()) - .sum::(); - let mut next_image_label = total_remote_images + 1; for (idx, message) in messages.into_iter().enumerate() { if idx > 0 { @@ -1349,7 +1455,7 @@ fn merge_user_messages(messages: Vec) -> UserMessage { local_images, remote_image_urls, mention_bindings, - } = remap_placeholders_for_message(message, &mut next_image_label); + } = message; append_text_with_rebased_elements( &mut combined.text, &mut combined.text_elements, @@ -1364,6 +1470,101 @@ fn merge_user_messages(messages: Vec) -> UserMessage { combined } +fn user_message_for_restore( + message: UserMessage, + history_record: &UserMessageHistoryRecord, +) -> UserMessage { + match history_record { + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage { + text: history.text.clone(), + text_elements: history.text_elements.clone(), + ..message + }, + UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => { + message + } + } +} + +fn user_message_preview_text( + message: &UserMessage, + history_record: Option<&UserMessageHistoryRecord>, +) -> String { + match history_record { + Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => { + history.text.clone() + } + Some(UserMessageHistoryRecord::Override(_)) + | Some(UserMessageHistoryRecord::UserMessageText) + | None => message.text.clone(), + } +} + +fn user_message_event_for_display( + message: UserMessage, + history_record: &UserMessageHistoryRecord, +) -> UserMessageEvent { + let message = user_message_for_restore(message, history_record); + UserMessageEvent { + message: message.text, + images: Some(message.remote_image_urls), + local_images: message + .local_images + .into_iter() + .map(|image| image.path) + .collect(), + text_elements: message.text_elements, + } +} + +fn merge_user_messages_with_history_record( + messages: Vec<(UserMessage, UserMessageHistoryRecord)>, +) -> (UserMessage, UserMessageHistoryRecord) { + let messages = remap_user_messages_with_history_records(messages); + let history_record = if messages + .iter() + .all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText) + { + UserMessageHistoryRecord::UserMessageText + } else { + let mut history_text = String::new(); + let mut history_text_elements = Vec::new(); + let mut history_segment_count = 0usize; + let mut append_history_segment = |text: &str, text_elements: Vec| { + if history_segment_count > 0 { + history_text.push('\n'); + } + append_text_with_rebased_elements( + &mut history_text, + &mut history_text_elements, + text, + text_elements, + ); + history_segment_count += 1; + }; + for (message, record) in &messages { + match record { + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + append_history_segment(&history.text, history.text_elements.clone()); + } + UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {} + UserMessageHistoryRecord::Override(_) + | UserMessageHistoryRecord::UserMessageText => { + append_history_segment(&message.text, message.text_elements.clone()); + } + } + } + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: history_text, + text_elements: history_text_elements, + }) + }; + ( + merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)), + history_record, + ) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ReplayKind { ResumeInitialMessages, @@ -2145,6 +2346,11 @@ impl ChatWidget { self.thread_id = Some(event.session_id); self.last_turn_id = None; self.thread_name = event.thread_name.clone(); + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.goal_status_active_turn_started_at = None; + self.budget_limited_turn_ids.clear(); + self.update_collaboration_mode_indicator(); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.to_path_buf()); @@ -2191,6 +2397,7 @@ impl ChatWidget { self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); + self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); @@ -2517,6 +2724,7 @@ impl ChatWidget { fn on_task_started(&mut self) { self.user_turn_pending_start = false; self.agent_turn_running = true; + self.goal_status_active_turn_started_at = Some(Instant::now()); self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); self.saw_copy_source_this_turn = false; @@ -2611,6 +2819,7 @@ impl ChatWidget { self.pending_status_indicator_restore = false; self.user_turn_pending_start = false; self.agent_turn_running = false; + self.goal_status_active_turn_started_at = None; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ false); self.update_task_running_state(); @@ -2632,11 +2841,20 @@ impl ChatWidget { self.saw_plan_item_this_turn = false; } // If there is a queued user message, send exactly one now to begin the next turn. - self.maybe_send_next_queued_input(); - // Emit a notification when the turn completes (suppressed if focused). - self.notify(Notification::AgentTurnComplete { - response: notification_response, - }); + let follow_up_started = self.maybe_send_next_queued_input(); + let active_goal_continuing = self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active); + // Emit a notification when the agent is truly waiting for the user. + // Queued follow-up input and active goal continuation both start the + // next turn immediately, so notifying at that boundary would feel like + // a false "needs attention". + if !follow_up_started && !active_goal_continuing { + self.notify(Notification::AgentTurnComplete { + response: notification_response, + }); + } self.maybe_show_pending_rate_limit_prompt(); } @@ -2716,21 +2934,55 @@ impl ChatWidget { !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() } - fn pop_next_queued_user_message(&mut self) -> Option { + fn pop_next_queued_user_message( + &mut self, + ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { if self.rejected_steers_queue.is_empty() { - self.queued_user_messages.pop_front() + self.queued_user_messages.pop_front().map(|user_message| { + let history_record = self + .queued_user_message_history_records + .pop_front() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + (user_message, history_record) + }) } else { - Some(QueuedUserMessage::from(merge_user_messages( - self.rejected_steers_queue.drain(..).collect(), - ))) + let rejected_messages = self.rejected_steers_queue.drain(..).collect::>(); + let mut history_records = self + .rejected_steer_history_records + .drain(..) + .collect::>(); + history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let (message, history_record) = merge_user_messages_with_history_record( + rejected_messages + .into_iter() + .zip(history_records) + .collect::>(), + ); + Some((QueuedUserMessage::from(message), history_record)) } } fn pop_latest_queued_user_message(&mut self) -> Option { - self.queued_user_messages - .pop_back() - .map(QueuedUserMessage::into_user_message) - .or_else(|| self.rejected_steers_queue.pop_back()) + if let Some(user_message) = self.queued_user_messages.pop_back() { + let history_record = self + .queued_user_message_history_records + .pop_back() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + Some(user_message_for_restore( + user_message.into_user_message(), + &history_record, + )) + } else { + let user_message = self.rejected_steers_queue.pop_back()?; + let history_record = self + .rejected_steer_history_records + .pop_back() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + Some(user_message_for_restore(user_message, &history_record)) + } } pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { @@ -2742,6 +2994,8 @@ impl ChatWidget { }; self.rejected_steers_queue .push_back(pending_steer.user_message); + self.rejected_steer_history_records + .push_back(pending_steer.history_record); self.refresh_pending_input_preview(); true } @@ -3029,6 +3283,7 @@ impl ChatWidget { // Reset running state and clear streaming buffers. self.user_turn_pending_start = false; self.agent_turn_running = false; + self.goal_status_active_turn_started_at = None; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ false); self.update_task_running_state(); @@ -3425,7 +3680,8 @@ impl ChatWidget { ); } - /// Handle a turn aborted due to user interrupt (Esc). + /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, + /// or review completion. /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { @@ -3443,7 +3699,7 @@ impl ChatWidget { )); } else { self.add_to_history(history_cell::new_error_event( - "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), + self.interrupted_turn_message(reason), )); } } @@ -3451,13 +3707,15 @@ impl ChatWidget { // Core clears pending_input before emitting TurnAborted, so any unacknowledged steers // still tracked here must be restored locally instead of waiting for a later commit. if send_pending_steers_immediately { - let pending_steers: Vec = self + let pending_steers = self .pending_steers .drain(..) - .map(|pending| pending.user_message) - .collect(); + .map(|pending| (pending.user_message, pending.history_record)) + .collect::>(); if !pending_steers.is_empty() { - self.submit_user_message(merge_user_messages(pending_steers)); + let (user_message, history_record) = + 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); } @@ -3489,16 +3747,41 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + let rejected_messages = self.rejected_steers_queue.drain(..).collect::>(); + let mut rejected_history_records = self + .rejected_steer_history_records + .drain(..) + .collect::>(); + rejected_history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let mut to_merge: Vec = rejected_messages + .into_iter() + .zip(rejected_history_records.iter()) + .map(|(message, history_record)| user_message_for_restore(message, history_record)) + .collect(); to_merge.extend( self.pending_steers .drain(..) - .map(|steer| steer.user_message), + .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), + ); + let queued_messages = self.queued_user_messages.drain(..).collect::>(); + let mut queued_history_records = self + .queued_user_message_history_records + .drain(..) + .collect::>(); + queued_history_records.resize( + queued_messages.len(), + UserMessageHistoryRecord::UserMessageText, ); to_merge.extend( - self.queued_user_messages - .drain(..) - .map(QueuedUserMessage::into_user_message), + 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() @@ -3544,8 +3827,15 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + pending_steer_history_records: self + .pending_steers + .iter() + .map(|pending| pending.history_record.clone()) + .collect(), rejected_steers_queue: self.rejected_steers_queue.clone(), + rejected_steer_history_records: self.rejected_steer_history_records.clone(), queued_user_messages: self.queued_user_messages.clone(), + queued_user_message_history_records: self.queued_user_message_history_records.clone(), user_turn_pending_start: self.user_turn_pending_start, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), @@ -3560,6 +3850,8 @@ impl ChatWidget { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; self.agent_turn_running = input_state.agent_turn_running; + self.goal_status_active_turn_started_at = + self.agent_turn_running.then_some(Instant::now()); self.user_turn_pending_start = input_state.user_turn_pending_start; self.update_collaboration_mode_indicator(); self.refresh_model_dependent_surfaces(); @@ -3588,25 +3880,45 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } + let mut pending_steer_history_records = input_state.pending_steer_history_records; + pending_steer_history_records.resize( + input_state.pending_steers.len(), + UserMessageHistoryRecord::UserMessageText, + ); self.pending_steers = input_state .pending_steers .into_iter() - .map(|user_message| PendingSteer { + .zip(pending_steer_history_records) + .map(|(user_message, history_record)| PendingSteer { compare_key: PendingSteerCompareKey { message: user_message.text.clone(), image_count: user_message.local_images.len() + user_message.remote_image_urls.len(), }, + history_record, user_message, }) .collect(); self.rejected_steers_queue = input_state.rejected_steers_queue; + self.rejected_steer_history_records = input_state.rejected_steer_history_records; + self.rejected_steer_history_records.resize( + self.rejected_steers_queue.len(), + UserMessageHistoryRecord::UserMessageText, + ); self.queued_user_messages = input_state.queued_user_messages; + self.queued_user_message_history_records = + input_state.queued_user_message_history_records; + self.queued_user_message_history_records.resize( + self.queued_user_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); } else { self.agent_turn_running = false; + self.goal_status_active_turn_started_at = None; self.user_turn_pending_start = false; self.pending_steers.clear(); self.rejected_steers_queue.clear(); + self.rejected_steer_history_records.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -3616,6 +3928,7 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); self.queued_user_messages.clear(); + self.queued_user_message_history_records.clear(); } self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); @@ -4398,6 +4711,14 @@ impl ChatWidget { self.refresh_status_line(); } + fn interrupted_turn_message(&self, reason: TurnAbortReason) -> String { + if reason == TurnAbortReason::BudgetLimited { + return "Goal budget reached - the turn was stopped.".to_string(); + } + + "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() + } + fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { let DeprecationNoticeEvent { summary, details } = event; self.add_to_history(history_cell::new_deprecation_notice(summary, details)); @@ -4586,6 +4907,7 @@ impl ChatWidget { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); self.bottom_pane.pre_draw_tick(); + self.refresh_goal_status_indicator_for_time_tick(); if self.should_animate_terminal_title_spinner() { self.refresh_terminal_title(); } @@ -5252,6 +5574,7 @@ impl ChatWidget { suppress_queue_autosend: false, thread_id: None, last_turn_id: None, + budget_limited_turn_ids: HashSet::new(), thread_name: None, thread_rename_block_message: None, active_side_conversation: false, @@ -5260,8 +5583,10 @@ impl ChatWidget { forked_from: None, interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, queued_user_messages: VecDeque::new(), + queued_user_message_history_records: VecDeque::new(), user_turn_pending_start: false, rejected_steers_queue: VecDeque::new(), + rejected_steer_history_records: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -5299,6 +5624,9 @@ impl ChatWidget { status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, + current_goal_status_indicator: None, + current_goal_status: None, + goal_status_active_turn_started_at: None, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, @@ -5320,6 +5648,7 @@ impl ChatWidget { widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); widget.sync_plugins_command_enabled(); + widget.sync_goal_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_binding); @@ -5759,6 +6088,8 @@ impl ChatWidget { if !self.is_session_configured() || self.is_user_turn_pending_or_running() { self.queued_user_messages .push_back(QueuedUserMessage::new(user_message, action)); + self.queued_user_message_history_records + .push_back(UserMessageHistoryRecord::UserMessageText); self.refresh_pending_input_preview(); } else { self.submit_user_message(user_message); @@ -5792,8 +6123,23 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let _ = self - .submit_user_message_with_shell_escape_policy(user_message, ShellEscapePolicy::Allow); + let _accepted = self.submit_user_message_with_history_record( + user_message, + UserMessageHistoryRecord::UserMessageText, + ); + } + + fn submit_user_message_with_history_record( + &mut self, + user_message: UserMessage, + history_record: UserMessageHistoryRecord, + ) -> bool { + self.submit_user_message_with_history_and_shell_escape_policy( + user_message, + history_record, + ShellEscapePolicy::Allow, + ) + .0 } fn submit_user_message_with_shell_escape_policy( @@ -5801,12 +6147,53 @@ impl ChatWidget { user_message: UserMessage, shell_escape_policy: ShellEscapePolicy, ) -> Option { + self.submit_user_message_with_history_and_shell_escape_policy( + user_message, + UserMessageHistoryRecord::UserMessageText, + shell_escape_policy, + ) + .1 + } + + fn submit_user_message_with_history_and_shell_escape_policy( + &mut self, + user_message: UserMessage, + history_record: UserMessageHistoryRecord, + shell_escape_policy: ShellEscapePolicy, + ) -> (bool, Option) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages .push_front(QueuedUserMessage::from(user_message)); + self.queued_user_message_history_records + .push_front(history_record); self.refresh_pending_input_preview(); - return None; + return (true, None); + } + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return (false, None); + } + if (!user_message.local_images.is_empty() || !user_message.remote_image_urls.is_empty()) + && !self.current_model_supports_images() + { + let UserMessage { + text, + text_elements, + local_images, + mention_bindings, + remote_image_urls, + } = user_message_for_restore(user_message, &history_record); + self.restore_blocked_image_submission( + text, + text_elements, + local_images, + mention_bindings, + remote_image_urls, + ); + return (false, None); } let UserMessage { text, @@ -5815,21 +6202,6 @@ impl ChatWidget { text_elements, mention_bindings, } = user_message; - if text.is_empty() && local_images.is_empty() && remote_image_urls.is_empty() { - return None; - } - if (!local_images.is_empty() || !remote_image_urls.is_empty()) - && !self.current_model_supports_images() - { - self.restore_blocked_image_submission( - text, - text_elements, - local_images, - mention_bindings, - remote_image_urls, - ); - return None; - } let render_in_history = !self.agent_turn_running; let mut items: Vec = Vec::new(); @@ -5844,10 +6216,7 @@ impl ChatWidget { stripped.trim().to_string(), )), }; - if let Some(app_command) = app_command { - return Some(app_command); - } - return None; + return (app_command.is_some(), app_command); } for image_url in &remote_image_urls { @@ -5980,7 +6349,17 @@ impl ChatWidget { self.add_error_message( "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), ); - return None; + self.restore_user_message_to_composer(user_message_for_restore( + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + &history_record, + )); + return (false, None); } let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask @@ -5997,6 +6376,7 @@ impl ChatWidget { text_elements: text_elements.clone(), mention_bindings: mention_bindings.clone(), }, + history_record: history_record.clone(), compare_key: Self::pending_steer_compare_key_from_items(&items), }); let personality = self @@ -6033,24 +6413,33 @@ impl ChatWidget { ); if !self.submit_op(op.clone()) { - return None; + return (false, None); } if render_in_history { self.user_turn_pending_start = true; } - // Persist the text to cross-session message history. Mentions are - // encoded into placeholder syntax so recall can reconstruct the - // mention bindings in a future session. - if !text.is_empty() { - let encoded_mentions = mention_bindings - .iter() - .map(|binding| LinkedMention { - mention: binding.mention.clone(), - path: binding.path.clone(), - }) - .collect::>(); - let history_text = encode_history_mentions(&text, &encoded_mentions); + // Persist the submitted text to cross-session message history. Mentions are encoded into + // placeholder syntax so recall can reconstruct the mention bindings in a future session. + let encoded_mentions = mention_bindings + .iter() + .map(|binding| LinkedMention { + mention: binding.mention.clone(), + path: binding.path.clone(), + }) + .collect::>(); + let history_text = match &history_record { + UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { + Some(encode_history_mentions(&text, &encoded_mentions)) + } + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + Some(encode_history_mentions(&history.text, &encoded_mentions)) + } + UserMessageHistoryRecord::UserMessageText | UserMessageHistoryRecord::Override(_) => { + None + } + }; + if let Some(history_text) = history_text { self.submit_op(Op::AddToHistory { text: history_text }); } @@ -6061,44 +6450,65 @@ impl ChatWidget { } // Show replayable user content in conversation history. - if render_in_history && !text.is_empty() { - let local_image_paths = local_images - .into_iter() - .map(|img| img.path) - .collect::>(); - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_parts( - text.clone(), - text_elements.clone(), - local_image_paths.clone(), - remote_image_urls.clone(), - )); - self.record_visible_user_turn_for_copy(); - self.add_to_history(history_cell::new_user_prompt( + let display_user_message = render_in_history.then(|| { + user_message_for_restore( + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + &history_record, + ) + }); + if let Some(display_user_message) = display_user_message { + let UserMessage { text, - text_elements, - local_image_paths, + local_images, remote_image_urls, - )); - } else if render_in_history && !remote_image_urls.is_empty() { - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_parts( + text_elements, + mention_bindings: _, + } = display_user_message; + if !text.is_empty() { + let local_image_paths = local_images + .into_iter() + .map(|img| img.path) + .collect::>(); + self.last_rendered_user_message_event = + Some(Self::rendered_user_message_event_from_parts( + text.clone(), + text_elements.clone(), + local_image_paths.clone(), + remote_image_urls.clone(), + )); + self.add_to_history(history_cell::new_user_prompt( + text, + text_elements, + local_image_paths, + remote_image_urls, + )); + self.record_visible_user_turn_for_copy(); + } else if !remote_image_urls.is_empty() { + self.last_rendered_user_message_event = + Some(Self::rendered_user_message_event_from_parts( + String::new(), + Vec::new(), + Vec::new(), + remote_image_urls.clone(), + )); + self.add_to_history(history_cell::new_user_prompt( String::new(), Vec::new(), Vec::new(), - remote_image_urls.clone(), + remote_image_urls, )); - self.record_visible_user_turn_for_copy(); - self.add_to_history(history_cell::new_user_prompt( - String::new(), - Vec::new(), - Vec::new(), - remote_image_urls, - )); + self.record_visible_user_turn_for_copy(); + } } self.needs_final_message_separator = false; - Some(op) + (true, Some(op)) } /// Restore the blocked submission draft without losing mention resolution state. @@ -6533,8 +6943,6 @@ impl ChatWidget { notification.token_usage, ))); } - ServerNotification::ThreadGoalUpdated(_) => {} - ServerNotification::ThreadGoalCleared(_) => {} ServerNotification::ThreadNameUpdated(notification) => { match ThreadId::from_string(¬ification.thread_id) { Ok(thread_id) => self.on_thread_name_updated( @@ -6552,6 +6960,12 @@ impl ChatWidget { } } } + ServerNotification::ThreadGoalUpdated(notification) => { + self.on_thread_goal_updated(notification.goal, notification.turn_id); + } + ServerNotification::ThreadGoalCleared(notification) => { + self.on_thread_goal_cleared(notification.thread_id.as_str()); + } ServerNotification::TurnStarted(notification) => { self.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; @@ -6827,7 +7241,15 @@ impl ChatWidget { } TurnStatus::Interrupted => { self.last_non_retry_error = None; - self.on_interrupted_turn(TurnAbortReason::Interrupted); + let reason = if self + .budget_limited_turn_ids + .remove(notification.turn.id.as_str()) + { + TurnAbortReason::BudgetLimited + } else { + TurnAbortReason::Interrupted + }; + self.on_interrupted_turn(reason); } TurnStatus::Failed => { if let Some(error) = notification.turn.error { @@ -7091,7 +7513,29 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::ThreadGoalUpdated(_) => {} + EventMsg::ThreadGoalUpdated(event) => { + let goal = event.goal; + self.on_thread_goal_updated( + AppThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: match goal.status { + ProtocolThreadGoalStatus::Active => AppThreadGoalStatus::Active, + ProtocolThreadGoalStatus::Paused => AppThreadGoalStatus::Paused, + ProtocolThreadGoalStatus::BudgetLimited => { + AppThreadGoalStatus::BudgetLimited + } + ProtocolThreadGoalStatus::Complete => AppThreadGoalStatus::Complete, + }, + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at, + updated_at: goal.updated_at, + }, + event.turn_id, + ); + } // NOTE: All three AgentMessage arms feed `record_agent_markdown` even // when the message is otherwise not rendered (thread-snapshot replay, // non-review live messages). This ensures the copy source stays @@ -7192,7 +7636,16 @@ impl ChatWidget { EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { - self.on_interrupted_turn(ev.reason); + let reason = if ev + .turn_id + .as_deref() + .is_some_and(|turn_id| self.budget_limited_turn_ids.remove(turn_id)) + { + TurnAbortReason::BudgetLimited + } else { + ev.reason + }; + self.on_interrupted_turn(reason); } TurnAbortReason::Replaced => { self.submit_pending_steers_after_interrupt = false; @@ -7204,6 +7657,9 @@ impl ChatWidget { self.on_interrupted_turn(ev.reason); } TurnAbortReason::BudgetLimited => { + if let Some(turn_id) = ev.turn_id.as_deref() { + self.budget_limited_turn_ids.remove(turn_id); + } self.on_interrupted_turn(ev.reason); } }, @@ -7446,17 +7902,8 @@ impl ChatWidget { { if let Some(pending) = self.pending_steers.pop_front() { self.refresh_pending_input_preview(); - let pending_event = UserMessageEvent { - message: pending.user_message.text, - images: Some(pending.user_message.remote_image_urls), - local_images: pending - .user_message - .local_images - .into_iter() - .map(|image| image.path) - .collect(), - text_elements: pending.user_message.text_elements, - }; + let pending_event = + user_message_event_for_display(pending.user_message, &pending.history_record); self.on_user_message_event(pending_event); } else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) { tracing::warn!( @@ -7552,31 +7999,37 @@ impl ChatWidget { } // If idle and there are queued inputs, submit exactly one to start the next turn. - pub(crate) fn maybe_send_next_queued_input(&mut self) { + pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { if self.suppress_queue_autosend { - return; + return false; } if self.is_user_turn_pending_or_running() { - return; + return false; } + let mut submitted_follow_up = false; while !self.is_user_turn_pending_or_running() { - let Some(queued_message) = self.pop_next_queued_user_message() else { + let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else { break; }; match queued_message.action { QueuedInputAction::Plain => { - self.submit_user_message(queued_message.into_user_message()); + submitted_follow_up = self.submit_user_message_with_history_record( + queued_message.into_user_message(), + history_record, + ); break; } QueuedInputAction::ParseSlash => { let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); if drain == QueueDrain::Stop { + submitted_follow_up = self.is_user_turn_pending_or_running(); break; } } QueuedInputAction::RunShell => { let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); if drain == QueueDrain::Stop { + submitted_follow_up = self.is_user_turn_pending_or_running(); break; } } @@ -7584,6 +8037,7 @@ impl ChatWidget { } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); + submitted_follow_up } pub(super) fn is_user_turn_pending_or_running(&self) -> bool { @@ -7604,17 +8058,28 @@ impl ChatWidget { let queued_messages: Vec = self .queued_user_messages .iter() - .map(|m| m.text.clone()) + .enumerate() + .map(|(idx, message)| { + user_message_preview_text( + message, + self.queued_user_message_history_records.get(idx), + ) + }) .collect(); let pending_steers: Vec = self .pending_steers .iter() - .map(|steer| steer.user_message.text.clone()) + .map(|steer| { + user_message_preview_text(&steer.user_message, Some(&steer.history_record)) + }) .collect(); let rejected_steers: Vec = self .rejected_steers_queue .iter() - .map(|message| message.text.clone()) + .enumerate() + .map(|(idx, message)| { + user_message_preview_text(message, self.rejected_steer_history_records.get(idx)) + }) .collect(); self.bottom_pane.set_pending_input_preview( queued_messages, @@ -9851,6 +10316,16 @@ impl ChatWidget { self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); } + if feature == Feature::Goals { + self.sync_goal_command_enabled(); + if !enabled { + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.goal_status_active_turn_started_at = None; + self.budget_limited_turn_ids.clear(); + self.update_collaboration_mode_indicator(); + } + } if feature == Feature::PreventIdleSleep { self.turn_sleep_inhibitor = SleepInhibitor::new(enabled); self.turn_sleep_inhibitor @@ -10106,6 +10581,11 @@ impl ChatWidget { .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); } + fn sync_goal_command_enabled(&mut self) { + self.bottom_pane + .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); + } + fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.model_catalog @@ -10278,7 +10758,55 @@ impl ChatWidget { fn update_collaboration_mode_indicator(&mut self) { let indicator = self.collaboration_mode_indicator(); + let goal_indicator = if indicator.is_none() { + self.goal_status_indicator(Instant::now()) + } else { + None + }; + self.current_goal_status_indicator = goal_indicator.clone(); self.bottom_pane.set_collaboration_mode_indicator(indicator); + self.bottom_pane.set_goal_status_indicator(goal_indicator); + } + + fn refresh_goal_status_indicator_for_time_tick(&mut self) { + if self.collaboration_mode_indicator().is_some() { + return; + } + let goal_indicator = self.goal_status_indicator(Instant::now()); + if goal_indicator != self.current_goal_status_indicator { + self.current_goal_status_indicator = goal_indicator.clone(); + self.bottom_pane.set_goal_status_indicator(goal_indicator); + } + } + + fn goal_status_indicator(&self, now: Instant) -> Option { + if !self.config.features.enabled(Feature::Goals) { + return None; + } + self.current_goal_status + .as_ref() + .and_then(|state| state.indicator(now, self.goal_status_active_turn_started_at)) + } + + fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { + if let Some(active_thread_id) = self.thread_id + && active_thread_id.to_string() != goal.thread_id + { + return; + } + if !self.config.features.enabled(Feature::Goals) { + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.update_collaboration_mode_indicator(); + return; + } + if goal.status == AppThreadGoalStatus::BudgetLimited + && let Some(turn_id) = turn_id + { + self.budget_limited_turn_ids.insert(turn_id); + } + self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); + self.update_collaboration_mode_indicator(); } fn personality_label(personality: Personality) -> &'static str { @@ -10741,8 +11269,8 @@ impl ChatWidget { /// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the /// first press always stops live voice, even when the composer contains the recording meter. /// - /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first - /// quit. + /// When the double-press quit shortcut is enabled, pressing the same shortcut again before + /// expiry requests a shutdown-first quit. fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); if self.realtime_conversation.is_live() { @@ -10768,6 +11296,9 @@ impl ChatWidget { if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if self.is_cancellable_work_active() { + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.bottom_pane.clear_quit_shortcut_hint(); self.submit_op(AppCommand::interrupt()); } else { self.request_quit_without_confirmation(); diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs new file mode 100644 index 0000000000..74c8cde887 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -0,0 +1,65 @@ +//! Goal summary for the bare `/goal` command. + +use super::*; +use crate::goal_display::format_goal_elapsed_seconds; +use crate::status::format_tokens_compact; + +impl ChatWidget { + pub(crate) fn show_goal_summary(&mut self, goal: AppThreadGoal) { + self.add_plain_history_lines(goal_summary_lines(&goal)); + } + + pub(crate) fn on_thread_goal_cleared(&mut self, thread_id: &str) { + if self + .thread_id + .is_some_and(|active_thread_id| active_thread_id.to_string() == thread_id) + { + self.current_goal_status = None; + self.update_collaboration_mode_indicator(); + } + } +} + +fn goal_summary_lines(goal: &AppThreadGoal) -> Vec> { + let mut lines = vec![ + Line::from("Goal".bold()), + Line::from(vec![ + "Status: ".dim(), + goal_status_label(goal.status).to_string().into(), + ]), + Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]), + Line::from(vec![ + "Time used: ".dim(), + format_goal_elapsed_seconds(goal.time_used_seconds).into(), + ]), + Line::from(vec![ + "Tokens used: ".dim(), + format_tokens_compact(goal.tokens_used).into(), + ]), + ]; + if let Some(token_budget) = goal.token_budget { + lines.push(Line::from(vec![ + "Token budget: ".dim(), + format_tokens_compact(token_budget).into(), + ])); + } + let command_hint = match goal.status { + AppThreadGoalStatus::Active => "Commands: /goal pause, /goal clear", + AppThreadGoalStatus::Paused => "Commands: /goal unpause, /goal clear", + AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { + "Commands: /goal clear" + } + }; + lines.push(Line::default()); + lines.push(Line::from(command_hint.dim())); + lines +} + +fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { + match status { + AppThreadGoalStatus::Active => "active", + AppThreadGoalStatus::Paused => "paused", + AppThreadGoalStatus::BudgetLimited => "limited by budget", + AppThreadGoalStatus::Complete => "complete", + } +} diff --git a/codex-rs/tui/src/chatwidget/goal_status.rs b/codex-rs/tui/src/chatwidget/goal_status.rs new file mode 100644 index 0000000000..e641159a3b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/goal_status.rs @@ -0,0 +1,226 @@ +//! Helpers for mapping thread-goal state into the compact status-line indicator. + +use codex_app_server_protocol::ThreadGoal as AppThreadGoal; +use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; +use std::time::Instant; + +use crate::bottom_pane::GoalStatusIndicator; +use crate::goal_display::format_goal_elapsed_seconds; +use crate::status::format_tokens_compact; + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct GoalStatusState { + goal: AppThreadGoal, + observed_at: Instant, +} + +impl GoalStatusState { + pub(super) fn new(goal: AppThreadGoal, observed_at: Instant) -> Self { + Self { goal, observed_at } + } + + pub(super) fn is_active(&self) -> bool { + self.goal.status == AppThreadGoalStatus::Active + } + + pub(super) fn indicator( + &self, + now: Instant, + active_turn_started_at: Option, + ) -> Option { + let mut goal = self.goal.clone(); + if goal.status == AppThreadGoalStatus::Active + && let Some(active_turn_started_at) = active_turn_started_at + { + let baseline = self.observed_at.max(active_turn_started_at); + let active_seconds = now.saturating_duration_since(baseline).as_secs(); + goal.time_used_seconds = goal + .time_used_seconds + .saturating_add(i64::try_from(active_seconds).unwrap_or(i64::MAX)); + } + goal_status_indicator_from_app_goal(&goal) + } +} + +pub(super) fn goal_status_indicator_from_app_goal( + goal: &AppThreadGoal, +) -> Option { + match goal.status { + AppThreadGoalStatus::Active => Some(GoalStatusIndicator::Active { + usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds), + }), + AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused), + AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited { + usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used), + }), + AppThreadGoalStatus::Complete => Some(GoalStatusIndicator::Complete { + usage: Some(completed_goal_usage( + goal.token_budget, + goal.tokens_used, + goal.time_used_seconds, + )), + }), + } +} + +fn active_goal_usage( + token_budget: Option, + tokens_used: i64, + time_used_seconds: i64, +) -> Option { + if let Some(token_budget) = token_budget { + return Some(format!( + "{} / {}", + format_tokens_compact(tokens_used), + format_tokens_compact(token_budget) + )); + } + + Some(format_goal_elapsed_seconds(time_used_seconds)) +} + +fn stopped_goal_budget_usage(token_budget: Option, tokens_used: i64) -> Option { + token_budget.map(|token_budget| { + format!( + "{} / {} tokens", + format_tokens_compact(tokens_used), + format_tokens_compact(token_budget) + ) + }) +} + +fn completed_goal_usage( + token_budget: Option, + tokens_used: i64, + time_used_seconds: i64, +) -> String { + if token_budget.is_some() { + return format!("{} tokens", format_tokens_compact(tokens_used)); + } + + format_goal_elapsed_seconds(time_used_seconds) +} + +#[cfg(test)] +mod tests { + use super::GoalStatusState; + use super::active_goal_usage; + use super::completed_goal_usage; + use super::stopped_goal_budget_usage; + use crate::bottom_pane::GoalStatusIndicator; + use codex_app_server_protocol::ThreadGoal as AppThreadGoal; + use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; + use std::time::Duration; + use std::time::Instant; + + #[test] + fn active_goal_usage_prefers_token_budget() { + assert_eq!( + active_goal_usage( + Some(50_000), + /*tokens_used*/ 12_500, + /*time_used_seconds*/ 90 + ), + Some("12.5K / 50K".to_string()) + ); + } + + #[test] + fn active_goal_usage_reports_time_without_budget() { + assert_eq!( + active_goal_usage( + /*token_budget*/ None, /*tokens_used*/ 12_500, + /*time_used_seconds*/ 120, + ), + Some("2m".to_string()) + ); + } + + #[test] + fn stopped_goal_budget_usage_reports_budgeted_tokens() { + assert_eq!( + stopped_goal_budget_usage(Some(50_000), /*tokens_used*/ 63_876), + Some("63.9K / 50K tokens".to_string()) + ); + } + + #[test] + fn stopped_goal_budget_usage_omits_unbudgeted_usage() { + assert_eq!( + stopped_goal_budget_usage(/*token_budget*/ None, /*tokens_used*/ 12_500), + None + ); + } + + #[test] + fn completed_goal_usage_reports_tokens_when_budgeted() { + assert_eq!( + completed_goal_usage( + Some(50_000), + /*tokens_used*/ 40_000, + /*time_used_seconds*/ 120, + ), + "40K tokens".to_string() + ); + } + + #[test] + fn completed_goal_usage_reports_time_without_token_budget() { + assert_eq!( + completed_goal_usage( + /*token_budget*/ None, /*tokens_used*/ 40_000, + /*time_used_seconds*/ 36_720, + ), + "10h 12m".to_string() + ); + } + + #[test] + fn active_goal_status_includes_current_turn_elapsed_time() { + let observed_at = Instant::now(); + let state = active_goal_state(observed_at, /*time_used_seconds*/ 60); + + assert_eq!( + state.indicator( + observed_at + Duration::from_secs(60), + Some(observed_at - Duration::from_secs(120)), + ), + Some(GoalStatusIndicator::Active { + usage: Some("2m".to_string()) + }) + ); + } + + #[test] + fn active_goal_status_does_not_count_idle_time_before_turn_start() { + let observed_at = Instant::now(); + let active_turn_started_at = observed_at + Duration::from_secs(120); + let state = active_goal_state(observed_at, /*time_used_seconds*/ 60); + + assert_eq!( + state.indicator( + active_turn_started_at + Duration::from_secs(60), + Some(active_turn_started_at), + ), + Some(GoalStatusIndicator::Active { + usage: Some("2m".to_string()) + }) + ); + } + + fn active_goal_state(observed_at: Instant, time_used_seconds: i64) -> GoalStatusState { + GoalStatusState::new( + AppThreadGoal { + thread_id: "thread".to_string(), + objective: "do the thing".to_string(), + status: AppThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds, + created_at: 1, + updated_at: 1, + }, + observed_at, + ) + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index febd8aef4f..bbc23b9309 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -6,6 +6,7 @@ //! slash-command recall follows the same submitted-input rule as ordinary text. use super::*; +use crate::app_event::ThreadGoalSetMode; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::slash_commands; @@ -28,6 +29,8 @@ const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str = "'/side' is unavailable while code review is running."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first."; +const GOAL_USAGE: &str = "Usage: /goal "; +const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; impl ChatWidget { /// Dispatch a bare slash command and record its staged local-history entry. @@ -37,6 +40,9 @@ impl ChatWidget { /// rule as normal text. pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) { self.dispatch_command(cmd); + if cmd == SlashCommand::Goal { + self.bottom_pane.drain_pending_submission_state(); + } self.bottom_pane.record_pending_slash_command_history(); } @@ -201,6 +207,20 @@ impl ChatWidget { SlashCommand::Plan => { self.apply_plan_slash_command(); } + SlashCommand::Goal => { + if !self.config.features.enabled(Feature::Goals) { + return; + } + if let Some(thread_id) = self.thread_id { + self.app_event_tx + .send(AppEvent::OpenThreadGoalMenu { thread_id }); + } else { + self.add_info_message( + GOAL_USAGE.to_string(), + Some(GOAL_USAGE_HINT.to_string()), + ); + } + } SlashCommand::Collab => { if !self.collaboration_modes_enabled() { self.add_info_message( @@ -580,6 +600,87 @@ impl ChatWidget { self.queue_user_message(user_message); } } + SlashCommand::Goal if !trimmed.is_empty() => { + if !self.config.features.enabled(Feature::Goals) { + return; + } + enum GoalControlCommand { + Clear, + SetStatus(AppThreadGoalStatus), + } + let control_command = match trimmed.to_ascii_lowercase().as_str() { + "clear" => Some(GoalControlCommand::Clear), + "pause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Paused)), + "unpause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Active)), + _ => None, + }; + if let Some(command) = control_command { + let Some(thread_id) = self.thread_id else { + self.add_info_message( + GOAL_USAGE.to_string(), + Some( + "The session must start before you can change a goal.".to_string(), + ), + ); + return; + }; + match command { + GoalControlCommand::Clear => { + self.app_event_tx + .send(AppEvent::ClearThreadGoal { thread_id }); + } + GoalControlCommand::SetStatus(status) => { + self.app_event_tx + .send(AppEvent::SetThreadGoalStatus { thread_id, status }); + } + } + if source == SlashCommandDispatchSource::Live { + self.bottom_pane.drain_pending_submission_state(); + } + 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 Some(thread_id) = self.thread_id else { + if source == SlashCommandDispatchSource::Live { + self.queue_user_message_with_options( + UserMessage { + text: format!("/goal {args}"), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }, + QueuedInputAction::ParseSlash, + ); + self.bottom_pane.drain_pending_submission_state(); + } else { + self.add_info_message( + GOAL_USAGE.to_string(), + Some("The session must start before you can set a goal.".to_string()), + ); + } + return; + }; + self.app_event_tx.send(AppEvent::SetThreadGoalObjective { + thread_id, + objective: objective.to_string(), + mode: ThreadGoalSetMode::ConfirmIfExists, + }); + if source == SlashCommandDispatchSource::Live { + self.bottom_pane.drain_pending_submission_state(); + } + } SlashCommand::Side if !trimmed.is_empty() => { let Some(parent_thread_id) = self.thread_id else { self.add_error_message( @@ -613,7 +714,7 @@ impl ChatWidget { } _ => self.dispatch_command(cmd), } - if source == SlashCommandDispatchSource::Live { + if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal { self.bottom_pane.drain_pending_submission_state(); } } @@ -675,11 +776,18 @@ impl ChatWidget { return QueueDrain::Stop; } - let args_elements = Self::slash_command_args_elements(rest, rest_offset, &text_elements); + let trimmed_start = rest.trim_start(); + let leading_trimmed = rest.len().saturating_sub(trimmed_start.len()); + let trimmed_rest = trimmed_start.trim_end(); + let args_elements = Self::slash_command_args_elements( + trimmed_rest, + rest_offset + leading_trimmed, + &text_elements, + ); self.dispatch_prepared_command_with_args( cmd, PreparedSlashCommandArgs { - args: rest.trim().to_string(), + args: trimmed_rest.to_string(), text_elements: args_elements, local_images, remote_image_urls, @@ -703,6 +811,7 @@ impl ChatWidget { collaboration_modes_enabled: self.collaboration_modes_enabled(), connectors_enabled: self.connectors_enabled(), plugins_command_enabled: self.config.features.enabled(Feature::Plugins), + goal_command_enabled: self.config.features.enabled(Feature::Goals), fast_command_enabled: self.fast_mode_enabled(), personality_command_enabled: self.config.features.enabled(Feature::Personality), realtime_conversation_enabled: self.realtime_conversation_enabled(), @@ -745,6 +854,7 @@ impl ChatWidget { | SlashCommand::Settings | SlashCommand::Personality | SlashCommand::Plan + | SlashCommand::Goal | SlashCommand::Collab | SlashCommand::Side | SlashCommand::Agent diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__direct_budget_limited_turn_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__direct_budget_limited_turn_message.snap new file mode 100644 index 0000000000..5278cad2d4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__direct_budget_limited_turn_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/review_mode.rs +expression: last +--- +■ Goal budget reached - the turn was stopped. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_active.snap new file mode 100644 index 0000000000..aa723955e7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_active.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: active +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K +Token budget: 80K + +Commands: /goal pause, /goal clear diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_budget_limited.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_budget_limited.snap new file mode 100644 index 0000000000..4dae24a47b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_budget_limited.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: limited by budget +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K +Token budget: 80K + +Commands: /goal clear diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_paused.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_paused.snap new file mode 100644 index 0000000000..83fe79578c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_paused.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: paused +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal unpause, /goal clear diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_goal_budget_limited_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_goal_budget_limited_message.snap new file mode 100644 index 0000000000..5278cad2d4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_goal_budget_limited_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/review_mode.rs +expression: last +--- +■ Goal budget reached - the turn was stopped. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_goal_active_token_budget_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_goal_active_token_budget_footer.snap new file mode 100644 index 0000000000..bbf2f9dea7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_goal_active_token_budget_footer.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.4 Pursuing goal (40K / 50K) " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_goal_complete_elapsed_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_goal_complete_elapsed_footer.snap new file mode 100644 index 0000000000..9b9ba2c999 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_goal_complete_elapsed_footer.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.4 Goal achieved (30m) " diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 94ed216004..87c21e564a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -4,6 +4,7 @@ //! behavior easier to review without paging through the rest of `chatwidget.rs`. use super::*; +use crate::status::format_tokens_compact; /// Items shown in the terminal title when the user has not configured a /// custom selection. Intentionally minimal: spinner + project name. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6ae18ec8ab..376e0771e8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -269,6 +269,7 @@ mod approval_requests; mod background_events; mod composer_submission; mod exec_flow; +mod goal_menu; mod guardian; mod helpers; mod history_replay; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index c095d372d0..d50964edd0 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -872,8 +872,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + pending_steer_history_records: VecDeque::new(), rejected_steers_queue: VecDeque::new(), + rejected_steer_history_records: VecDeque::new(), queued_user_messages: VecDeque::new(), + queued_user_message_history_records: VecDeque::new(), user_turn_pending_start: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), diff --git a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs new file mode 100644 index 0000000000..d90d47ccab --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs @@ -0,0 +1,71 @@ +use super::*; + +#[tokio::test] +async fn goal_menu_active_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::Active, + /*token_budget*/ Some(80_000), + )); + + assert_chatwidget_snapshot!("goal_menu_active", rendered_goal_summary(&mut rx)); +} + +#[tokio::test] +async fn goal_menu_paused_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::Paused, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx)); +} + +#[tokio::test] +async fn goal_menu_budget_limited_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(80_000), + )); + + assert_chatwidget_snapshot!("goal_menu_budget_limited", rendered_goal_summary(&mut rx)); +} + +fn test_goal( + thread_id: ThreadId, + status: AppThreadGoalStatus, + token_budget: Option, +) -> AppThreadGoal { + AppThreadGoal { + thread_id: thread_id.to_string(), + objective: "Keep improving the bare goal command until it feels calm and useful." + .to_string(), + status, + token_budget, + tokens_used: 12_500, + time_used_seconds: 90, + created_at: 1_776_272_400, + updated_at: 1_776_272_460, + } +} + +fn rendered_goal_summary( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> String { + drain_insert_history(rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n") +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 960ab992a0..ca10aeec32 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -256,6 +256,7 @@ pub(super) async fn make_chatwidget_manual( suppress_queue_autosend: false, thread_id: None, last_turn_id: None, + budget_limited_turn_ids: HashSet::new(), thread_name: None, thread_rename_block_message: None, active_side_conversation: false, @@ -267,8 +268,10 @@ pub(super) async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + queued_user_message_history_records: VecDeque::new(), user_turn_pending_start: false, rejected_steers_queue: VecDeque::new(), + rejected_steer_history_records: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -304,6 +307,9 @@ pub(super) async fn make_chatwidget_manual( status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, + current_goal_status_indicator: None, + current_goal_status: None, + goal_status_active_turn_started_at: None, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, @@ -584,6 +590,7 @@ pub(super) fn complete_assistant_message( pub(super) fn pending_steer(text: &str) -> PendingSteer { PendingSteer { user_message: UserMessage::from(text), + history_record: UserMessageHistoryRecord::UserMessageText, compare_key: PendingSteerCompareKey { message: text.to_string(), image_count: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 86bd4a26bf..8061b89fc1 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -441,8 +441,11 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers, + pending_steer_history_records: VecDeque::new(), rejected_steers_queue, + rejected_steer_history_records: VecDeque::new(), queued_user_messages, + queued_user_message_history_records: VecDeque::new(), user_turn_pending_start: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), @@ -1051,6 +1054,24 @@ async fn ctrl_c_shutdown_works_with_caps_lock() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); +} + #[tokio::test] async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1300,6 +1321,132 @@ async fn interrupted_turn_error_message_snapshot() { assert_chatwidget_snapshot!("interrupted_turn_error_message", last); } +#[tokio::test] +async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + + chat.handle_server_notification( + codex_app_server_protocol::ServerNotification::TurnStarted( + codex_app_server_protocol::TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + ), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + codex_app_server_protocol::ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + goal: codex_app_server_protocol::ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "Run until the token budget is limited".to_string(), + status: codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, + token_budget: Some(10_000), + tokens_used: 10_500, + time_used_seconds: 0, + created_at: 0, + updated_at: 1, + }, + }, + ), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + codex_app_server_protocol::ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::Interrupted, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + ), + /*replay_kind*/ None, + ); + + let cells = drain_insert_history(&mut rx); + let last = lines_to_single_string(cells.last().unwrap()); + assert_chatwidget_snapshot!("interrupted_turn_goal_budget_limited_message", last); +} + +#[tokio::test] +async fn direct_budget_limited_turn_uses_budget_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::BudgetLimited, + completed_at: None, + duration_ms: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + let last = lines_to_single_string(cells.last().unwrap()); + assert_chatwidget_snapshot!("direct_budget_limited_turn_message", last); +} + +#[tokio::test] +async fn budget_limited_turn_restores_queued_input_without_submitting() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.queued_user_messages + .push_back(UserMessage::from("follow-up after budget stop").into()); + chat.refresh_pending_input_preview(); + + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::BudgetLimited, + completed_at: None, + duration_ms: None, + }), + }); + + assert!(chat.queued_user_messages.is_empty()); + assert_eq!( + chat.bottom_pane.composer_text(), + "follow-up after budget stop" + ); + assert_no_submit_op(&mut op_rx); +} + // Snapshot test: interrupting specifically to submit pending steers shows an // informational banner instead of the generic "tell the model what to do // differently" error prompt. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 85b868ce76..6da41423f2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -30,6 +30,19 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane.composer_text() } +fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { + loop { + match op_rx.try_recv() { + Ok(Op::AddToHistory { text }) => return text, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"), + Err(TryRecvError::Disconnected) => { + panic!("expected AddToHistory op but channel closed") + } + } + } +} + #[tokio::test] async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -664,6 +677,445 @@ async fn inline_slash_command_is_available_from_local_recall_after_dispatch() { assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title"); } +#[tokio::test] +async fn goal_slash_command_emits_set_goal_event() { + 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 command = "/goal --tokens 98.5K improve benchmark coverage"; + + 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, + mode, + } = event + else { + panic!("expected SetThreadGoalObjective, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + assert_eq!(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); +} + +#[tokio::test] +async fn goal_slash_command_uses_plain_text_for_mentions() { + 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); + chat.bottom_pane.set_composer_text_with_mention_bindings( + "/goal use $figma for the mockup".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + mention: "figma".to_string(), + path: "app://figma".to_string(), + }], + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + 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"); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn goal_slash_command_drops_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 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.bottom_pane.set_composer_text( + command, + vec![TextElement::new( + (placeholder_start..placeholder_start + placeholder.len()).into(), + Some(placeholder.to_string()), + )], + vec![local_image], + ); + + 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]"); + assert!(chat.remote_image_urls().is_empty()); + assert!(chat.bottom_pane.composer_local_image_paths().is_empty()); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn bare_goal_slash_command_drains_pending_submission_state() { + let (mut chat, mut rx, _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-menu.png".to_string(); + let local_image = PathBuf::from("/tmp/goal-menu-local.png"); + chat.set_remote_image_urls(vec![remote_url]); + chat.bottom_pane + .set_composer_text("/goal".to_string(), Vec::new(), vec![local_image]); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::OpenThreadGoalMenu { thread_id: opened }) if opened == thread_id + ); + assert!(chat.remote_image_urls().is_empty()); + assert!(chat.bottom_pane.composer_local_image_paths().is_empty()); +} + +#[tokio::test] +async fn goal_control_slash_commands_emit_goal_events() { + let cases = [ + ("/goal clear", None), + ("/goal pause", Some(AppThreadGoalStatus::Paused)), + ("/goal unpause", Some(AppThreadGoalStatus::Active)), + ]; + + for (command, status) in cases { + let (mut chat, mut rx, _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); + + submit_composer_text(&mut chat, command); + + match status { + Some(status) => { + let event = rx.try_recv().expect("expected goal status event"); + let AppEvent::SetThreadGoalStatus { + thread_id: actual_thread_id, + status: actual_status, + } = event + else { + panic!("expected SetThreadGoalStatus, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + assert_eq!(actual_status, status); + } + None => { + let event = rx.try_recv().expect("expected clear goal event"); + let AppEvent::ClearThreadGoal { + thread_id: actual_thread_id, + } = event + else { + panic!("expected ClearThreadGoal, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + } + } + } +} + +#[tokio::test] +async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + let command = "/goal improve benchmark coverage"; + + submit_composer_text(&mut chat, command); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + let thread_id = ThreadId::new(); + 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"); + assert_no_submit_op(&mut op_rx); +} + +#[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; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + let command = "/goal improve benchmark coverage"; + + submit_composer_text(&mut chat, command); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + let remote_url = "https://example.com/current-draft.png".to_string(); + let local_image = PathBuf::from("/tmp/current-draft-local.png"); + let placeholder = "[Image #3]"; + let draft = format!("draft with {placeholder}"); + let placeholder_start = draft.find(placeholder).expect("placeholder in draft"); + chat.set_remote_image_urls(vec![remote_url.clone()]); + chat.bottom_pane.set_composer_text( + draft.clone(), + vec![TextElement::new( + (placeholder_start..placeholder_start + placeholder.len()).into(), + Some(placeholder.to_string()), + )], + vec![local_image.clone()], + ); + + let thread_id = ThreadId::new(); + 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 + ); + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.bottom_pane.composer_text(), draft); + assert_eq!(chat.remote_image_urls(), vec![remote_url]); + assert_eq!( + chat.bottom_pane.composer_local_image_paths(), + vec![local_image] + ); +} + +#[tokio::test] +async fn restored_queued_goal_slash_command_emits_set_goal_event() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + let command = "/goal improve benchmark coverage"; + + submit_composer_text(&mut chat, command); + let input_state = chat + .capture_thread_input_state() + .expect("expected queued input state"); + + let (mut restored_chat, mut restored_rx, mut restored_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + restored_chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + restored_chat.restore_thread_input_state(Some(input_state)); + let thread_id = ThreadId::new(); + 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 + ); + assert_no_submit_op(&mut restored_op_rx); +} + +#[test] +fn merged_history_record_preserves_raw_text_and_rebased_elements() { + let first = UserMessage { + text: "Ask $figma".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], + mention_bindings: vec![MentionBinding { + mention: "figma".to_string(), + path: "app://figma".to_string(), + }], + }; + let second = UserMessage::from("internal prompt"); + + let (_message, history_record) = merge_user_messages_with_history_record(vec![ + (first, UserMessageHistoryRecord::UserMessageText), + ( + second, + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: "/goal inspect [Image #1]".to_string(), + text_elements: vec![TextElement::new( + (14..24).into(), + Some("[Image #1]".to_string()), + )], + }), + ), + ]); + + assert_eq!( + history_record, + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: "Ask $figma\n/goal inspect [Image #1]".to_string(), + text_elements: vec![ + TextElement::new((4..10).into(), Some("$figma".to_string())), + TextElement::new((25..35).into(), Some("[Image #1]".to_string())), + ], + }) + ); +} + +#[test] +fn merged_history_record_remaps_override_image_placeholders() { + let first_placeholder = "[Image #1]"; + let second_placeholder = "[Image #1]"; + let first = UserMessage { + text: format!("first {first_placeholder}"), + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: PathBuf::from("/tmp/first.png"), + }], + remote_image_urls: Vec::new(), + text_elements: vec![TextElement::new( + (6..16).into(), + Some(first_placeholder.to_string()), + )], + mention_bindings: Vec::new(), + }; + let second = UserMessage { + text: format!("internal {second_placeholder}"), + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: PathBuf::from("/tmp/second.png"), + }], + remote_image_urls: Vec::new(), + text_elements: vec![TextElement::new( + (9..19).into(), + Some(second_placeholder.to_string()), + )], + mention_bindings: Vec::new(), + }; + + let (message, history_record) = merge_user_messages_with_history_record(vec![ + (first, UserMessageHistoryRecord::UserMessageText), + ( + second, + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: format!("goal {second_placeholder}"), + text_elements: vec![TextElement::new( + (5..15).into(), + Some(second_placeholder.to_string()), + )], + }), + ), + ]); + + assert_eq!(message.text, "first [Image #1]\ninternal [Image #2]"); + assert_eq!( + message.text_elements, + vec![ + TextElement::new((6..16).into(), Some("[Image #1]".to_string())), + TextElement::new((26..36).into(), Some("[Image #2]".to_string())), + ] + ); + assert_eq!( + message + .local_images + .iter() + .map(|image| image.placeholder.as_str()) + .collect::>(), + vec!["[Image #1]", "[Image #2]"] + ); + assert_eq!( + history_record, + UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: "first [Image #1]\ngoal [Image #2]".to_string(), + text_elements: vec![ + TextElement::new((6..16).into(), Some("[Image #1]".to_string())), + TextElement::new((22..32).into(), Some("[Image #2]".to_string())), + ], + }) + ); +} + +#[tokio::test] +async fn interrupted_merged_message_history_encodes_mentions_once() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + let text = "use $figma now"; + chat.bottom_pane.set_composer_text_with_mention_bindings( + text.to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + mention: "figma".to_string(), + path: "app://figma".to_string(), + }], + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => { + let [ + UserInput::Text { + text: submitted, .. + }, + ] = items.as_slice() + else { + panic!("expected text item, got {items:?}"); + }; + assert_eq!(submitted, text); + } + other => panic!("expected user turn, got {other:?}"), + } + let encoded = "use [$figma](app://figma) now"; + assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + next_interrupt_op(&mut op_rx); + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => { + let [ + UserInput::Text { + text: submitted, .. + }, + ] = items.as_slice() + else { + panic!("expected resubmitted text item, got {items:?}"); + }; + assert_eq!(submitted, text); + } + other => panic!("expected resubmitted user turn, got {other:?}"), + } + assert_eq!(next_add_to_history_op(&mut op_rx), encoded); +} + #[tokio::test] async fn slash_rename_prefills_existing_thread_name() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1034,6 +1486,91 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { ); } +#[tokio::test] +async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notification() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: codex_app_server_protocol::ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "finish the benchmark".to_string(), + status: codex_app_server_protocol::ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 1, + }, + }, + ), + /*replay_kind*/ None, + ); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))), + }); + + assert_matches!(chat.pending_notification, None); +} + +#[tokio::test] +async fn queued_follow_up_suppresses_agent_turn_complete_notification() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.queue_user_message("Continue".into()); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))), + }); + + assert_matches!(chat.pending_notification, None); + assert!(chat.queued_user_messages.is_empty()); + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. }); +} + +#[tokio::test] +async fn queued_menu_slash_keeps_agent_turn_complete_notification() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + queue_composer_text_with_tab(&mut chat, "/model"); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))), + }); + + assert_matches!( + chat.pending_notification, + Some(Notification::AgentTurnComplete { ref response }) if response == "Done" + ); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Select Model")); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn slash_copy_uses_latest_surviving_response_after_rollback() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 46756a962f..d7aefbe4b4 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,4 +1,5 @@ use super::*; +use crate::bottom_pane::goal_status_indicator_line; use pretty_assertions::assert_eq; /// Receiving a TokenCount event without usage clears the context indicator. @@ -1628,6 +1629,279 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { ); } +#[tokio::test] +async fn status_line_goal_active_token_budget_footer_snapshot() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.show_welcome_banner = false; + chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.refresh_status_line(); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + ), + }, + ), + /*replay_kind*/ None, + ); + + let width = 80; + let height = chat.desired_height(width); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal status footer"); + assert_chatwidget_snapshot!( + "status_line_goal_active_token_budget_footer", + normalized_backend_snapshot(terminal.backend()) + ); +} + +#[tokio::test] +async fn status_line_goal_complete_elapsed_footer_snapshot() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.show_welcome_banner = false; + chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.refresh_status_line(); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Complete, + /*token_budget*/ None, + /*tokens_used*/ 40_000, + ), + }, + ), + /*replay_kind*/ None, + ); + + let width = 80; + let height = chat.desired_height(width); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal status footer"); + assert_chatwidget_snapshot!( + "status_line_goal_complete_elapsed_footer", + normalized_backend_snapshot(terminal.backend()) + ); +} + +#[tokio::test] +async fn session_configured_clears_goal_status_footer() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + ), + }, + ), + /*replay_kind*/ None, + ); + assert_eq!( + chat.current_goal_status_indicator, + Some(GoalStatusIndicator::Active { + usage: Some("40K / 50K".to_string()) + }) + ); + chat.budget_limited_turn_ids.insert("turn-1".to_string()); + + let rollout_file = NamedTempFile::new().unwrap(); + chat.handle_codex_event(Event { + id: "session-2".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-5.4".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, + cwd: test_path_buf("/home/user/project").abs(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }), + }); + + assert_eq!(chat.current_goal_status_indicator, None); + assert!(chat.budget_limited_turn_ids.is_empty()); +} + +#[tokio::test] +async fn thread_goal_update_for_other_thread_is_ignored() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(ThreadId::new()); + let other_thread_id = ThreadId::new().to_string(); + let mut goal = test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 50_000, + ); + goal.thread_id = other_thread_id.clone(); + + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: other_thread_id, + turn_id: Some("turn-other".to_string()), + goal, + }, + ), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_goal_status_indicator, None); + assert!(chat.current_goal_status.is_none()); + assert!(chat.budget_limited_turn_ids.is_empty()); +} + +#[test] +fn goal_status_indicator_formats_statuses_and_budgets() { + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + )), + Some(GoalStatusIndicator::Active { + usage: Some("40K / 50K".to_string()), + }) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::Active { + usage: Some("30m".to_string()), + }) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 51_000, + )), + Some(GoalStatusIndicator::BudgetLimited { + usage: Some("51K / 50K tokens".to_string()), + }) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::BudgetLimited { usage: None }) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Complete, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + )), + Some(GoalStatusIndicator::Complete { + usage: Some("40K tokens".to_string()), + }) + ); +} + +#[test] +fn goal_status_indicator_line_formats_goal_text() { + let cases = [ + ( + GoalStatusIndicator::Active { + usage: Some("4K / 5K".to_string()), + }, + "Pursuing goal (4K / 5K)", + ), + ( + GoalStatusIndicator::BudgetLimited { + usage: Some("4K / 5K tokens".to_string()), + }, + "Goal unmet (4K / 5K tokens)", + ), + ( + GoalStatusIndicator::Paused, + "Goal paused (/goal to unpause)", + ), + ( + GoalStatusIndicator::BudgetLimited { usage: None }, + "Goal abandoned", + ), + ( + GoalStatusIndicator::Complete { + usage: Some("10h 12m".to_string()), + }, + "Goal achieved (10h 12m)", + ), + ( + GoalStatusIndicator::Complete { usage: None }, + "Goal achieved", + ), + ]; + + for (indicator, expected) in cases { + let line = + goal_status_indicator_line(Some(&indicator)).expect("goal indicator should render"); + let actual = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(expected, actual); + } +} + +fn test_thread_goal( + status: codex_app_server_protocol::ThreadGoalStatus, + token_budget: Option, + tokens_used: i64, +) -> codex_app_server_protocol::ThreadGoal { + codex_app_server_protocol::ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "Keep improving the benchmark".to_string(), + status, + token_budget, + tokens_used, + time_used_seconds: 30 * 60, + created_at: 0, + updated_at: 0, + } +} + #[tokio::test] async fn runtime_metrics_websocket_timing_logs_and_final_separator_sums_totals() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs new file mode 100644 index 0000000000..1fdcadd902 --- /dev/null +++ b/codex-rs/tui/src/goal_display.rs @@ -0,0 +1,93 @@ +use crate::status::format_tokens_compact; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadGoalStatus; + +pub(crate) fn format_goal_elapsed_seconds(seconds: i64) -> String { + let seconds = seconds.max(0) as u64; + if seconds < 60 { + return format!("{seconds}s"); + } + + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m"); + } + + let hours = minutes / 60; + let remaining_minutes = minutes % 60; + if remaining_minutes == 0 { + format!("{hours}h") + } else { + format!("{hours}h {remaining_minutes}m") + } +} + +pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str { + match status { + ThreadGoalStatus::Active => "active", + ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::BudgetLimited => "limited by budget", + ThreadGoalStatus::Complete => "complete", + } +} + +pub(crate) fn goal_usage_summary(goal: &ThreadGoal) -> String { + let mut parts = vec![format!("Objective: {}", goal.objective)]; + if goal.time_used_seconds > 0 { + parts.push(format!( + "Time: {}.", + format_goal_elapsed_seconds(goal.time_used_seconds) + )); + } + if let Some(token_budget) = goal.token_budget { + parts.push(format!( + "Tokens: {}/{}.", + format_tokens_compact(goal.tokens_used), + format_tokens_compact(token_budget) + )); + } + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ThreadGoal; + use codex_app_server_protocol::ThreadGoalStatus; + use pretty_assertions::assert_eq; + + #[test] + fn format_goal_elapsed_seconds_is_compact() { + assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 0), "0s"); + assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 59), "59s"); + assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 60), "1m"); + assert_eq!(format_goal_elapsed_seconds(30 * 60), "30m"); + assert_eq!(format_goal_elapsed_seconds(90 * 60), "1h 30m"); + assert_eq!(format_goal_elapsed_seconds(2 * 60 * 60), "2h"); + } + + fn test_thread_goal(token_budget: Option, tokens_used: i64) -> ThreadGoal { + ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "Complete the task described in ../gameboy-long-running-prompt5.txt" + .to_string(), + status: ThreadGoalStatus::BudgetLimited, + token_budget, + tokens_used, + time_used_seconds: 120, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn goal_usage_summary_formats_time_and_budgeted_tokens() { + assert_eq!( + goal_usage_summary(&test_thread_goal( + /*token_budget*/ Some(50_000), + /*tokens_used*/ 63_876, + )), + "Objective: Complete the task described in ../gameboy-long-running-prompt5.txt Time: 2m. Tokens: 63.9K/50K." + ); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 095a2f3477..2dbe067077 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -125,6 +125,7 @@ mod external_editor; mod file_search; mod frames; mod get_git_diff; +mod goal_display; mod history_cell; pub(crate) mod insert_history; pub use insert_history::insert_history_lines; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4c3623993b..28df384b02 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -31,6 +31,7 @@ pub enum SlashCommand { Init, Compact, Plan, + Goal, Collab, Agent, Side, @@ -104,6 +105,7 @@ impl SlashCommand { SlashCommand::Realtime => "toggle realtime voice mode (experimental)", SlashCommand::Settings => "configure realtime microphone/speaker", SlashCommand::Plan => "switch to Plan mode", + SlashCommand::Goal => "set or view the goal for a long-running task", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", SlashCommand::Side => "start a side conversation in an ephemeral fork", @@ -137,6 +139,7 @@ impl SlashCommand { SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan + | SlashCommand::Goal | SlashCommand::Fast | SlashCommand::Mcp | SlashCommand::Side @@ -186,6 +189,7 @@ impl SlashCommand { | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop + | SlashCommand::Goal | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins @@ -239,4 +243,9 @@ mod tests { fn clean_alias_parses_to_stop_command() { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); } + + #[test] + fn goal_command_is_available_during_task() { + assert!(SlashCommand::Goal.available_during_task()); + } }