mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +00:00
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.
227 lines
6.8 KiB
Rust
227 lines
6.8 KiB
Rust
//! 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,
|
|
)
|
|
}
|
|
}
|