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
+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;