Files
codex/codex-rs/tui/src/goal_display.rs
T
Eric Traut f1c963d77e 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.
2026-04-24 21:16:45 -07:00

94 lines
2.9 KiB
Rust

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."
);
}
}