diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6dce3dcc8f6f..febe5de8ee82 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9,6 +9,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::Duration; +use std::time::Instant; use ratatui::style::Modifier; use ratatui::style::Style; @@ -216,6 +217,8 @@ pub(crate) struct ChatWidget<'a> { agent_task: Option, overall_task_status: String, active_plan_title: Option, + /// Runtime timing per-agent (by id) to improve visibility in the HUD + agent_runtime: HashMap, // Sparkline data for showing agent activity (using RefCell for interior mutability) // Each tuple is (value, is_completed) where is_completed indicates if any agent was complete at that time sparkline_data: std::cell::RefCell>, @@ -339,6 +342,16 @@ struct PendingJumpBack { removed_cells: Vec>, // cells removed from the end (from selected user message onward) } +#[derive(Debug, Clone, Default)] +struct AgentRuntime { + /// First time this agent entered Running + started_at: Option, + /// Time of the latest status update we observed + last_update: Option, + /// Time the agent reached a terminal state (Completed/Failed) + completed_at: Option, +} + // ---------- Stable ordering & routing helpers ---------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct OrderKey { @@ -383,10 +396,19 @@ use self::perf::PerfStats; #[derive(Debug, Clone)] struct AgentInfo { + // Stable id to correlate updates + id: String, + // Display name name: String, + // Current status status: AgentStatus, + // Optional model name + model: Option, + // Final success message when completed result: Option, + // Final error message when failed error: Option, + // Most recent progress line from core last_progress: Option, } @@ -484,6 +506,19 @@ enum SystemPlacement { } impl ChatWidget<'_> { + fn fmt_short_duration(&self, d: Duration) -> String { + let s = d.as_secs(); + let h = s / 3600; + let m = (s % 3600) / 60; + let sec = s % 60; + if h > 0 { + format!("{}h{}m", h, m) + } else if m > 0 { + format!("{}m{}s", m, sec) + } else { + format!("{}s", sec) + } + } fn is_branch_worktree_path(path: &std::path::Path) -> bool { for ancestor in path.ancestors() { if ancestor @@ -1839,6 +1874,7 @@ impl ChatWidget<'_> { agent_task: None, overall_task_status: "preparing".to_string(), active_plan_title: None, + agent_runtime: HashMap::new(), sparkline_data: std::cell::RefCell::new(Vec::new()), last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()), stream: crate::streaming::controller::StreamController::new(config.clone()), @@ -2021,6 +2057,7 @@ impl ChatWidget<'_> { agent_task: None, overall_task_status: "preparing".to_string(), active_plan_title: None, + agent_runtime: HashMap::new(), sparkline_data: std::cell::RefCell::new(Vec::new()), last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()), stream: crate::streaming::controller::StreamController::new(config.clone()), @@ -4252,15 +4289,34 @@ impl ChatWidget<'_> { .update_status_text("using browser (CDP)".to_string()); } } - EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { - agents, - context, - task, - }) => { - // Update the active agents list from the event + EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { agents, context, task }) => { + // Update the active agents list from the event and track timing self.active_agents.clear(); + let now = Instant::now(); for agent in agents { + // Update runtime map + let entry = self + .agent_runtime + .entry(agent.id.clone()) + .or_insert_with(AgentRuntime::default); + entry.last_update = Some(now); + match agent.status.as_str() { + "running" => { + if entry.started_at.is_none() { + entry.started_at = Some(now); + } + } + "completed" | "failed" => { + if entry.completed_at.is_none() { + entry.completed_at = entry.completed_at.or(Some(now)); + } + } + _ => {} + } + + // Mirror agent list for rendering self.active_agents.push(AgentInfo { + id: agent.id.clone(), name: agent.name.clone(), status: match agent.status.as_str() { "pending" => AgentStatus::Pending, @@ -4269,6 +4325,7 @@ impl ChatWidget<'_> { "failed" => AgentStatus::Failed, _ => AgentStatus::Pending, }, + model: agent.model, result: agent.result, error: agent.error, last_progress: agent.last_progress, @@ -10802,13 +10859,26 @@ impl ChatWidget<'_> { } else { let mut parts: Vec = Vec::new(); for a in self.active_agents.iter().take(3) { - let s = match a.status { - AgentStatus::Pending => "pending", - AgentStatus::Running => "running", - AgentStatus::Completed => "done", - AgentStatus::Failed => "failed", + let state = match a.status { + AgentStatus::Pending => "pending".to_string(), + AgentStatus::Running => { + // Show elapsed running time when available + if let Some(rt) = self.agent_runtime.get(&a.id) { + if let Some(start) = rt.started_at { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(start); + format!("running {}", self.fmt_short_duration(elapsed)) + } else { + "running".to_string() + } + } else { + "running".to_string() + } + } + AgentStatus::Completed => "done".to_string(), + AgentStatus::Failed => "failed".to_string(), }; - let mut label = format!("{} ({})", a.name, s); + let mut label = format!("{} ({})", a.name, state); if matches!(a.status, AgentStatus::Running) { if let Some(lp) = &a.last_progress { let mut lp_trim = lp.trim().to_string(); @@ -11102,23 +11172,88 @@ impl ChatWidget<'_> { AgentStatus::Failed => crate::colors::error(), }; + // Build status + timing suffix where available let status_text = match agent.status { - AgentStatus::Pending => "pending", - AgentStatus::Running => "running", - AgentStatus::Completed => "completed", - AgentStatus::Failed => "failed", + AgentStatus::Pending => "pending".to_string(), + AgentStatus::Running => { + if let Some(rt) = self.agent_runtime.get(&agent.id) { + if let Some(start) = rt.started_at { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(start); + format!("running {}", self.fmt_short_duration(elapsed)) + } else { + "running".to_string() + } + } else { + "running".to_string() + } + } + AgentStatus::Completed | AgentStatus::Failed => { + if let Some(rt) = self.agent_runtime.get(&agent.id) { + if let (Some(start), Some(done)) = (rt.started_at, rt.completed_at) { + let dur = done.saturating_duration_since(start); + let base = if matches!(agent.status, AgentStatus::Completed) { + "completed" + } else { + "failed" + }; + format!("{} {}", base, self.fmt_short_duration(dur)) + } else { + match agent.status { + AgentStatus::Completed => "completed".to_string(), + AgentStatus::Failed => "failed".to_string(), + _ => unreachable!(), + } + } + } else { + match agent.status { + AgentStatus::Completed => "completed".to_string(), + AgentStatus::Failed => "failed".to_string(), + _ => unreachable!(), + } + } + } }; - text_content.push(RLine::from(vec![ - Span::from(" "), + let mut line_spans: Vec = Vec::new(); + line_spans.push(Span::from(" ")); + line_spans.push( Span::styled( - format!("{}: ", agent.name), + format!("{}", agent.name), Style::default() .fg(crate::colors::text()) .add_modifier(Modifier::BOLD), ), - Span::styled(status_text, Style::default().fg(status_color)), - ])); + ); + if let Some(ref model) = agent.model { + if !model.is_empty() { + line_spans.push(Span::styled( + format!(" ({})", model), + Style::default().fg(crate::colors::text_dim()), + )); + } + } + line_spans.push(Span::from(": ")); + line_spans.push(Span::styled(status_text, Style::default().fg(status_color))); + text_content.push(RLine::from(line_spans)); + + // For running agents, show latest progress hint if available + if matches!(agent.status, AgentStatus::Running) { + if let Some(ref lp) = agent.last_progress { + let mut lp_trim = lp.trim().to_string(); + if lp_trim.len() > 120 { + lp_trim.truncate(120); + lp_trim.push('…'); + } + text_content.push(RLine::from(vec![ + Span::from(" "), + Span::styled( + lp_trim, + Style::default().fg(crate::colors::text_dim()), + ), + ])); + } + } // For completed/failed agents, show their final message or error match agent.status {