Files
codex/codex-rs/tui/src/goal_display.rs
Eric Traut d898cc8f3f Format multi-day goal durations in the TUI (#20558)
## Why

Goal mode shows elapsed time in compact hour/minute form. That is easy
to scan for shorter runs, but once a goal runs past 24 hours, large hour
counts become harder to read at a glance.

## What changed

Updated `codex-rs/tui/src/goal_display.rs` so unbudgeted goal elapsed
time keeps the existing compact format below one day, then switches to a
day-aware format once the elapsed time reaches 24 hours:

- `23h 59m`
- `1d 0h 0m`
- `2d 23h 42m`

The formatter now covers the 24-hour boundary in unit tests, and the TUI
status-line snapshot for a completed elapsed goal now exercises the
multi-day display.

## Verification

- `cargo test -p codex-tui`

Here's my longest-running test task:

<img width="186" height="23" alt="image"
src="https://github.com/user-attachments/assets/cedfcdab-7f6e-44e6-8495-8a39f63973fb"
/>
2026-04-30 22:42:07 -07:00

108 lines
3.5 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 hours >= 24 {
let days = hours / 24;
let remaining_hours = hours % 24;
return format!("{days}d {remaining_hours}h {remaining_minutes}m");
}
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");
let just_before_one_day = 24 * 60 * 60 - 1;
assert_eq!(format_goal_elapsed_seconds(just_before_one_day), "23h 59m");
let one_day = 24 * 60 * 60;
assert_eq!(format_goal_elapsed_seconds(one_day), "1d 0h 0m");
let almost_three_days = 2 * 24 * 60 * 60 + 23 * 60 * 60 + 42 * 60;
assert_eq!(format_goal_elapsed_seconds(almost_three_days), "2d 23h 42m");
}
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."
);
}
}