mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
f1c963d77e
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.
94 lines
2.9 KiB
Rust
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."
|
|
);
|
|
}
|
|
}
|