Add goal TUI UX (5 / 5) (#18077)

Adds the TUI user experience for goals on top of the core runtime from
PR 4.

## Why

Users need a direct TUI control surface for long-running goals. The UI
should make the current goal visible, support common goal actions
without waiting for a model turn, and avoid confusing end-of-turn
notifications while an active goal is immediately continuing.

## What changed

- Added `/goal` summary rendering for the current goal, including
active, paused, budget-limited, and complete states.
- Added `/goal <objective>` creation/replacement through the app-server
goal API rather than a model prompt.
- Added `/goal clear`, `/goal pause`, and `/goal unpause` command
variants.
- Added a confirmation menu when the user enters a new goal while
another goal already exists.
- Updated `/goal` help and summary tip text so it reflects the supported
command variants without advertising slash-command token budgets.
- Added footer/statusline goal indicators, including elapsed time and
token budget display when a budget exists from API/tool-created goals.
- Consumes goal updated/cleared notifications so the TUI stays in sync
with external app-server changes.
- Suppresses end-of-turn desktop notifications only when a goal is still
active and follow-up work is expected.
- Preserves slash-command history behavior and avoids leaking queued
`/goal` state into unrelated submissions.

## Verification

- Added TUI unit and snapshot coverage for goal command availability,
summary rendering, control commands, replacement menu behavior,
status/footer display, notification handling, and command history.
This commit is contained in:
Eric Traut
2026-04-24 21:16:45 -07:00
committed by GitHub
Unverified
parent 4167628622
commit f1c963d77e
32 changed files with 2709 additions and 177 deletions
+1
View File
@@ -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;
+18
View File
@@ -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
+184
View File
@@ -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 <objective>".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<SelectionAction> = 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()
});
}
}
+30
View File
@@ -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,
+61
View File
@@ -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<ThreadGoalGetResponse> {
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<String>,
status: Option<ThreadGoalStatus>,
token_budget: Option<Option<i64>>,
) -> Result<ThreadGoalSetResponse> {
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<ThreadGoalClearResponse> {
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
+53 -24
View File
@@ -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<CollaborationModeIndicator>,
goal_status_indicator: Option<GoalStatusIndicator>,
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<CollaborationModeIndicator>,
goal_status_indicator: Option<&GoalStatusIndicator>,
show_cycle_hint: bool,
) -> Option<Line<'static>> {
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<CollaborationModeIndicator>,
@@ -613,6 +633,10 @@ impl ChatComposer {
self.collaboration_mode_indicator = indicator;
}
pub fn set_goal_status_indicator(&mut self, indicator: Option<GoalStatusIndicator>) {
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)
@@ -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<CommandPopupFlags> 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,
+40
View File
@@ -95,6 +95,14 @@ pub(crate) enum CollaborationModeIndicator {
Execute,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum GoalStatusIndicator {
Active { usage: Option<String> },
Paused,
BudgetLimited { usage: Option<String> },
Complete { usage: Option<String> },
}
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<Line<'static>> {
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()])
+13
View File
@@ -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<GoalStatusIndicator>) {
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();
@@ -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();
File diff suppressed because it is too large Load Diff
+65
View File
@@ -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<Line<'static>> {
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",
}
}
+226
View File
@@ -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<Instant>,
) -> Option<GoalStatusIndicator> {
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<GoalStatusIndicator> {
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<i64>,
tokens_used: i64,
time_used_seconds: i64,
) -> Option<String> {
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<i64>, tokens_used: i64) -> Option<String> {
token_budget.map(|token_budget| {
format!(
"{} / {} tokens",
format_tokens_compact(tokens_used),
format_tokens_compact(token_budget)
)
})
}
fn completed_goal_usage(
token_budget: Option<i64>,
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,
)
}
}
+113 -3
View File
@@ -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 <objective>";
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
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/review_mode.rs
expression: last
---
■ Goal budget reached - the turn was stopped.
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/review_mode.rs
expression: last
---
■ Goal budget reached - the turn was stopped.
@@ -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) "
@@ -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) "
@@ -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.
+1
View File
@@ -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;
@@ -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(),
@@ -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<i64>,
) -> 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<crate::app_event::AppEvent>,
) -> String {
drain_insert_history(rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n")
}
@@ -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,
@@ -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.
@@ -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<Op>) -> 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<_>>(),
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;
@@ -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::<String>();
assert_eq!(expected, actual);
}
}
fn test_thread_goal(
status: codex_app_server_protocol::ThreadGoalStatus,
token_budget: Option<i64>,
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;
+93
View File
@@ -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<i64>, 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."
);
}
}
+1
View File
@@ -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;
+9
View File
@@ -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());
}
}