//! Turn status line — single-row widget showing current turn activity. //! //! Layout: `⠧ Run command 0.2s 1m20s ⇣12k [stop]` //! //! - Spinner (left, slowed to ~7.5fps) //! - Activity label (colored per activity type, truncates if needed) //! - Phase timer `Xs` (gray, never truncates) //! - Queued-send hint `· N queued — Enter to send now` (gray, sendable waits only) //! - Fill space //! - Turn timer `Xm Ys` and optional token count `⇣Nk` (right-aligned, gray) //! - Cancel button `[stop]` (right-aligned, red on hover) //! //! Hidden when idle (0 height). Appears between scrollback and prompt. use std::time::{Duration, Instant}; use kigi_workspace::permission::mcp_pretty_name_if_qualified; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::acp::tracker::{TurnActivity, WaitingReason}; use crate::app::agent::{AgentCommand, AgentState}; use crate::app::agent_view::McpInitProgress; use crate::render::line_utils::truncate_str; use crate::theme::Theme; /// Show each spinner frame for this many animation ticks. /// At ~30fps, 4 ticks = ~133ms per frame = ~7.5 spinner fps. pub(crate) const SPINNER_DIVISOR: u64 = 4; /// Show each monitor-pulse frame for this many animation ticks — twice the /// [`SPINNER_DIVISOR`] dwell (~3.75 fps). The idle "watching" cue should /// breathe calmly rather than read like the active turn spinner, so its /// `○ ◎ ◉ ◎` cycle runs at roughly half the speed (~1.07s per loop). pub(crate) const MONITOR_PULSE_DIVISOR: u64 = 8; /// Pulse speed for every "waiting on you" diamond — the drain-blocked /// status, the pending-user-input status, and the plan-approval status /// all share this cadence. `pulse_brightness` returns `sin²(tick*speed)`, /// which has period π, so at ~30fps this is ~1.3s per cycle /// (`π / (0.08 * 30) ≈ 1.31`). /// /// Always route diamond rendering through [`pending_diamond_color`] so /// the three call sites can never silently drift apart. pub(crate) const USER_WAITING_PULSE_SPEED: f32 = 0.08; /// Compute the pulsing diamond color for any "waiting on you" cue. /// /// Blends `accent` toward `theme.bg_base` using a `sin²` pulse driven by /// [`USER_WAITING_PULSE_SPEED`]. Brightness ranges from 0.3 (dim) to 1.0 /// (full accent) so the diamond stays visible at the trough. /// /// Pass `theme.accent_user` for user-input waits (permission prompts, /// `ask_user_question`, the drain-blocked idle status) and /// `theme.accent_plan` for plan-approval waits. pub(crate) fn pending_diamond_color(theme: &Theme, accent: Color, tick: u64) -> Color { let brightness = crate::theme::pulse_brightness(tick, USER_WAITING_PULSE_SPEED); crate::render::color::blend_color(theme.bg_base, accent, 0.3 + brightness * 0.7) .unwrap_or(accent) } // --------------------------------------------------------------------------- // Output // --------------------------------------------------------------------------- /// Output from rendering the turn status line. #[derive(Debug, Default)] pub struct TurnStatusOutput { /// Hit area for the cancel button, if rendered. /// `None` when the button is not shown (idle, cancelling, drain-blocked). pub cancel_button: Option, /// Hit area for the background-demote button, if rendered. pub bg_button: Option, } /// Mouse-clickable affordances on the turn-status row — the `[stop]` cancel and /// `[↓]` send-to-background buttons — with their current hover state. Passing /// `Some(_)` to [`render_turn_status`] renders the buttons; passing `None` /// marks a keyboard-only host (minimal mode has no mouse capture) and suppresses /// both — that host cancels the turn via `Ctrl+C` and sends to background via /// `Ctrl+G` instead. #[derive(Debug, Clone, Copy, Default)] pub struct MouseButtons { /// Whether the mouse is over the `[stop]` cancel button. pub cancel_hovered: bool, /// Whether the mouse is over the `[↓]` send-to-background button. pub bg_hovered: bool, } /// Counts of idle-surviving "watcher" work — background jobs that can wake /// the agent for a new turn while it sits idle. Running `monitor` tasks emit /// events, scheduled `/loop` tasks fire prompts on a timer, and running /// background subagents inject a `subagent-completed-…` turn when they finish /// — each can start a new turn, so they share one persistent "watching" cue /// above the prompt. Broader than the tasks-pane `Watchers` group (which is /// monitors + loops only); subagents are included here because they too /// auto-wake the agent. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Watchers { /// Running `monitor` background tasks. pub monitors: usize, /// Active scheduled `/loop` tasks. pub loops: usize, /// Running background subagents (they auto-wake the parent on completion). /// While the agent is idle, any running subagent is a background one — a /// foreground subagent would keep the parent in `TurnRunning`. pub subagents: usize, } impl Watchers { /// Total watcher count across all kinds. pub fn total(self) -> usize { self.monitors + self.loops + self.subagents } } /// Build the "watching · …" label for the idle watcher cue, listing only the /// non-zero kinds with correct singular/plural nouns — e.g. /// `"watching · 2 monitors · 1 loop · 1 subagent"`. Assumes /// `watchers.total() > 0`. /// /// Every scheduled task can wake the agent, so all of them are counted as /// `loops` (today every scheduled task is `/loop`-tagged; see /// `ScheduledTaskInfo`). The label is built in a single `String` — no /// intermediate `Vec`/`join` — because the turn-status line re-renders every /// frame, keeping the idle cue's churn to one allocation (cf. the /// static-`&str` right-side arms below). fn watching_label(watchers: Watchers) -> String { use std::fmt::Write as _; // "watching" stem, then " · N noun" appended for each non-zero kind. let mut label = String::with_capacity(32); label.push_str("watching"); if watchers.monitors > 0 { let noun = if watchers.monitors == 1 { "monitor" } else { "monitors" }; let _ = write!(label, " \u{00b7} {} {noun}", watchers.monitors); } if watchers.loops > 0 { let noun = if watchers.loops == 1 { "loop" } else { "loops" }; let _ = write!(label, " \u{00b7} {} {noun}", watchers.loops); } if watchers.subagents > 0 { let noun = if watchers.subagents == 1 { "subagent" } else { "subagents" }; let _ = write!(label, " \u{00b7} {} {noun}", watchers.subagents); } label } /// Whether the turn is blocked in a wait the shell aborts as soon as the /// user sends a message (`get_task_output` with `timeout_ms`, `wait_tasks`, /// `Await*`, and a foreground subagent await — mirrors the shell's blocking /// waits, whose send-now routing cancels the blocked turn and runs the new /// message next). Typing is actionable during these, which is what the /// parked-wait rendering (`AgentView::is_parked_on_sendable_wait` / /// `renders_parked`) builds on. /// /// `Subagent` is included: the shell treats a blocked foreground subagent /// await like the other blocking waits, so Enter sends promptly and pre-wait /// rows read as held. `Model` waits stay excluded — the model is actively /// producing the turn, so a message typed there queues behind real work. Pure /// predicate over the resolved activity; no turn-lifecycle side effects. pub fn is_sendable_wait(activity: &Option) -> bool { matches!( activity, Some(TurnActivity::Waiting( WaitingReason::TaskOutput { waits: true, .. } | WaitingReason::TasksComplete | WaitingReason::Sleep | WaitingReason::Subagent )) ) } /// Render the turn status line into the given area. /// /// The caller is responsible for only allocating a 1-row area when /// `should_show()` returns true (and 0 rows when false). /// /// # Parameters /// - `buttons`: `Some(MouseButtons { .. })` to render the mouse-clickable /// `[stop]` / `[↓]` buttons with their hover state; `None` for a keyboard-only /// host (minimal mode — no mouse capture), which suppresses both buttons. /// - `total_tokens`: Total tokens used (context window usage), shown as `⇣Nk`. /// - `flat_background`: when `true`, right-side timer/buttons use a transparent /// (`Color::Reset`) background instead of `theme.bg_base`, so the row blends /// with the terminal's own background (minimal mode). /// /// # Returns /// A [`TurnStatusOutput`] containing the cancel button hit area (if rendered). #[allow(clippy::too_many_arguments)] pub fn render_turn_status( buf: &mut Buffer, area: Rect, state: &AgentState, activity: &Option, turn_elapsed: Option, activity_started_at: Option, tick: u64, drain_blocked: bool, buttons: Option, has_running_execute: bool, total_tokens: Option, mcp_init_progress: Option<&McpInitProgress>, is_bash_turn: bool, is_pending_user_input: bool, goal_verifying: bool, watchers: Watchers, flat_background: bool, held_queue: usize, held_queue_top_sendable: bool, ) -> TurnStatusOutput { // Resolve the mouse affordances: a keyboard-only host (`None`) suppresses // both buttons and reports no hover. let show_buttons = buttons.is_some(); let cancel_hovered = buttons.is_some_and(|b| b.cancel_hovered); let bg_hovered = buttons.is_some_and(|b| b.bg_hovered); if area.height == 0 || area.width < 10 { return TurnStatusOutput::default(); } let theme = Theme::current(); // MCP startup seed (total == 0) while idle — show "Starting session…" // above the prompt until the shell reports real server counts. Real MCP // progress (total > 0) renders as the compact top-bar chip instead, not // here. Auto-expires via `is_visible()` if the shell never reports. if state.is_idle() && !drain_blocked && let Some(progress) = mcp_init_progress && progress.total == 0 && progress.is_visible() { render_starting_session(buf, area, progress, tick, &theme); return TurnStatusOutput::default(); } // Special case: drain is blocked (user editing front prompt, agent idle). // No cancel button in this state. if drain_blocked && state.is_idle() { // Pulsing diamond in accent_user, blending toward bg. let diamond_color = pending_diamond_color(&theme, theme.accent_user, tick); let spans = vec![ Span::styled( format!("{} ", crate::glyphs::diamond_filled()), Style::default().fg(diamond_color), ), Span::styled( "agent idle ~ waiting on your edit", Style::default().fg(theme.gray), ), ]; buf.set_line(area.x, area.y, &Line::from(spans), area.width); return TurnStatusOutput::default(); } // Special case: agent idle but background watchers (monitors, scheduled // `/loop` tasks, and/or running background subagents) are still alive. // Monitors emit events, loops fire prompts on a timer, and subagents // inject a completion turn on finish — any can wake the agent for another // turn — so keep a persistent cue above the prompt (unlike a scrollback // line, it never scrolls away). Lower priority than the starting-session // and drain-blocked cues handled above. if state.is_idle() && watchers.total() > 0 { // Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence: // the agent is idle, so this "watching" breath runs slower than the // active turn spinner (see MONITOR_PULSE_DIVISOR). let frames = crate::glyphs::monitor_icon_frames(); let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len(); let spans = vec![ Span::styled( format!("{} ", frames[frame_idx]), Style::default().fg(theme.accent_system), ), Span::styled(watching_label(watchers), Style::default().fg(theme.gray)), ]; buf.set_line(area.x, area.y, &Line::from(spans), area.width); return TurnStatusOutput::default(); } // Determine if cancel button should be shown. // Show when: TurnRunning or CommandRunning. // Hide when: Idle, Cancelling (already cancelling), or a keyboard-only host // (no clickable buttons — see `buttons`). let show_cancel = show_buttons && matches!( state, AgentState::TurnRunning | AgentState::CommandRunning { .. } ); // ── Compute activity style and label ── let (activity_style, label, is_tool) = compute_activity(&theme, state, activity, is_bash_turn, goal_verifying); // Early return for idle (shouldn't happen if should_show is respected, but be safe). if matches!(state, AgentState::Idle) { return TurnStatusOutput::default(); } // ── Build right-aligned content first (to know how much space is left) ── // Format: `1m20s` or `1m20s ⇣12k` (with tokens). let turn_timer_str = match (turn_elapsed, total_tokens) { (Some(d), Some(tokens)) if tokens > 0 => { format!( "{} {}{}", format_turn_timer(d), crate::glyphs::token_arrow(), format_tokens_short(tokens) ) } (Some(d), _) => format_turn_timer(d), _ => String::new(), }; let turn_timer_width = turn_timer_str.width(); // Bg button: [↓] normally, [send to bg] when hovered (only for running execute // tools). `show_cancel` already implies a mouse host, so no extra check. let show_bg = show_cancel && has_running_execute; let bg_str = if show_bg { if bg_hovered { " [send to bg]" } else { " [\u{2193}]" } } else { "" }; let bg_width = bg_str.width(); // Cancel button: always `[stop]`. Leading space only when the bg button // is not shown (otherwise they're adjacent). Every arm is a `&'static str` // so the per-frame status line never allocates. Hover state is conveyed by // color (red on hover, see `cancel_style`), not by swapping the label. let cancel_str: &str = match (show_cancel, show_bg) { (false, _) => "", (true, true) => "[stop]", (true, false) => " [stop]", }; let cancel_width = cancel_str.width(); let right_width = turn_timer_width + bg_width + cancel_width; // ── Build components ── // While a tool is blocked on a permission prompt or `ask_user_question`, // swap the spinning moon for a pulsing `◆`. Same animation shape the // drain-blocked and plan-approval indicators already use, so every // "your turn" status reads with one consistent visual cue. let spinner_str = if is_pending_user_input { format!("{} ", crate::glyphs::diamond_filled()) } else { let frames = crate::glyphs::moon_spinner_frames(); let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len(); format!("{} ", frames[frame_idx]) }; let spinner_width = spinner_str.width(); // "Ask" tools (AskUserQuestion): suppress the phase timer so the user // doesn't feel time-pressured while answering questions. let is_asking = is_tool && matches!( activity, Some(TurnActivity::ToolRunning { title, .. }) if title.starts_with("Ask: ") || title.starts_with("Ask ") ); // Phase timer (gray, same as turn timer) — hidden for ask tools let phase_timer_str = if is_asking { String::new() } else { activity_started_at .map(|t| format!(" {}", format_turn_timer(t.elapsed()))) .unwrap_or_default() }; let phase_timer_width = phase_timer_str.width(); // Timer style (gray for both phase and turn timers). // // Right-side elements (turn timer, bg button, cancel button) must set // fg, bg, AND remove_modifier explicitly. fill_background() paints // bg_base on every cell before widgets render, but set_line() for the // left content may overwrite fg/modifiers on cells in the right zone. // A Style with bg:None (the default) cannot restore bg after a reset, // and a Style without remove_modifier cannot clear leaked modifiers. // Right-side cells normally paint `bg_base`; in a flat-background host // (minimal mode) use the terminal's own background so the row stays // transparent like the rest of the live region. let timer_bg = if flat_background { Color::Reset } else { theme.bg_base }; let timer_style = Style::default() .fg(theme.gray) .bg(timer_bg) .remove_modifier(Modifier::all()); // Available width for activity label (only the label truncates) // Layout: spinner + label + phase_timer + queued_hint + gap(1) + turn_timer + cancel let min_gap = 1; let available_for_label = (area.width as usize) .saturating_sub(spinner_width) .saturating_sub(phase_timer_width) .saturating_sub(min_gap) .saturating_sub(right_width); // ── Render left side: spinner + label (truncated) + phase_timer + queued_hint ── let mut left_spans: Vec> = Vec::with_capacity(5); // Spinner color: usually inherits the activity color (green for tools, // secondary for thinking/responding, yellow for retries). While the // tool is parked on the user we render `◆` with a smooth pulse from // dim→bright in `accent_user`, matching the drain-blocked and // plan-approval indicators so every "your turn" status has the same // visual cadence. let spinner_style = if is_pending_user_input { let diamond_color = pending_diamond_color(&theme, theme.accent_user, tick); Style::default().fg(diamond_color) } else { activity_style }; left_spans.push(Span::styled(spinner_str, spinner_style)); // Activity label (potentially truncated) let mut queued_hint: Option> = None; if is_tool { if let Some(TurnActivity::ToolRunning { title, description }) = activity { if is_asking { // Ask tools: render as a unified gray label (like Thinking/Responding), // not as a command invocation — yellow is reserved for shell commands. let detail = title .strip_prefix("Ask: ") .or_else(|| title.strip_prefix("Ask ")) .unwrap_or(title.as_str()); let msg = format!("Waiting on answers for {detail}"); let display = truncate_str(&msg, available_for_label); left_spans.push(Span::styled(display, activity_style)); } else if let Some(desc) = description .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { // Bash (and similar) tools carry a human description — prefer // that over the raw command for the status line so a sleep / // long-running exec reads as `{description}…` rather than // `Run sleep 5 && …`. let msg = crate::acp::tracker::format_waiting_for_subject(desc); let display = truncate_str(&msg, available_for_label); left_spans.push(Span::styled(display, activity_style)); } else if let Some(query) = title.strip_prefix("Web search: ") { // Web search: "Search " (muted) + query (yellow) let prefix = "Search "; let prefix_width = prefix.width(); let query = query.trim_matches('"'); let max_query = available_for_label.saturating_sub(prefix_width).max(5); let display = truncate_str(query, max_query); left_spans.push(Span::styled(prefix, Style::default().fg(theme.gray))); left_spans.push(Span::styled(display, Style::default().fg(theme.command))); } else if let Some(url) = title.strip_prefix("Fetch: ") { // Fetch tools: "Fetch " (muted) + URL (yellow) let prefix = "Fetch "; let prefix_width = prefix.width(); let max_url = available_for_label.saturating_sub(prefix_width).max(5); let display = truncate_str(url, max_url); left_spans.push(Span::styled(prefix, Style::default().fg(theme.gray))); left_spans.push(Span::styled(display, Style::default().fg(theme.command))); } else { // Normal tool: "Run " (muted) + command (syntax-highlighted). // For qualified MCP tool names the activity title is the // raw `server__action` string from ACP; prettify it to // `(Server) Action` so the spinner doesn't show the ugly // delimiter form. Non-MCP titles (bash commands etc.) are // returned untouched by `mcp_pretty_name_if_qualified`. let prefix = "Run "; let pretty = mcp_pretty_name_if_qualified(title.as_str()); let detail = pretty.as_str(); let prefix_width = prefix.width(); let max_cmd = available_for_label.saturating_sub(prefix_width).max(5); let first_line = detail.lines().next().unwrap_or(detail); let display = truncate_str(first_line, max_cmd); left_spans.push(Span::styled(prefix, Style::default().fg(theme.gray))); left_spans.extend(crate::views::tasks_pane::highlight_bash_command(&display)); } } } else { // Sendable wait holding queued messages: the persistent inline hint // saying why the queue is paused and how to send anyway. On the status // row (not an ephemeral tip) so it stays visible for the whole wait, // and dropped before the label truncates on a narrow terminal. // "Enter to send now" is advertised only when Enter would actually // send the top row (bash / client-expanded local rows refuse with a // toast — see `AgentView::held_queue_top_sendable`). let suffix = if held_queue > 0 && is_sendable_wait(activity) { if held_queue_top_sendable { format!(" · {held_queue} queued — Enter to send now") } else { format!(" · {held_queue} queued") } } else { String::new() }; if !suffix.is_empty() && label.width() + suffix.width() <= available_for_label { left_spans.push(Span::styled(label.clone(), activity_style)); queued_hint = Some(Span::styled(suffix, Style::default().fg(theme.gray))); } else { let display = truncate_str(&label, available_for_label); left_spans.push(Span::styled(display, activity_style)); } } // Phase timer (gray, never truncates) if !phase_timer_str.is_empty() { left_spans.push(Span::styled(phase_timer_str, timer_style)); } // After the phase timer, so the elapsed time reads as the wait's, not the hint's. if let Some(hint) = queued_hint { left_spans.push(hint); } // Render left side let left_line = Line::from(left_spans); buf.set_line(area.x, area.y, &left_line, area.width); // ── Render right side: turn_timer + bg + cancel ── let right_start_x = area.x + area.width.saturating_sub(right_width as u16); // Helper: build a fully-specified right-side style (fg + bg + clear mods). let right_style = |fg| { Style::default() .fg(fg) .bg(timer_bg) .remove_modifier(Modifier::all()) }; // Turn timer (gray) let mut x = right_start_x; if !turn_timer_str.is_empty() { let span = Span::styled(turn_timer_str.clone(), timer_style); buf.set_span(x, area.y, &span, turn_timer_width as u16); x += turn_timer_width as u16; } // Bg button — accent_running on hover let bg_button_rect = if show_bg && !bg_str.is_empty() { let bg_x = x; let bg_style = if bg_hovered { right_style(theme.accent_running) } else { right_style(theme.gray) }; let span = Span::styled(bg_str, bg_style); buf.set_span(x, area.y, &span, bg_width as u16); x += bg_width as u16; Some(Rect::new(bg_x, area.y, bg_str.width() as u16, 1)) } else { None }; // Cancel button — accent_error (red) on hover, gray at rest let cancel_button_rect = if show_cancel && !cancel_str.is_empty() { let cancel_x = x; let cancel_style = if cancel_hovered { right_style(theme.accent_error) } else { right_style(theme.gray) }; let span = Span::styled(cancel_str, cancel_style); buf.set_span(x, area.y, &span, cancel_width as u16); Some(Rect::new(cancel_x, area.y, cancel_width as u16, 1)) } else { None }; TurnStatusOutput { cancel_button: cancel_button_rect, bg_button: bg_button_rect, } } /// Compute activity style, label, and whether it's a tool. fn compute_activity( theme: &Theme, state: &AgentState, activity: &Option, is_bash_turn: bool, goal_verifying: bool, ) -> (Style, String, bool) { match (state, activity) { (AgentState::TurnCancelling | AgentState::CommandCancelling { .. }, _) => ( Style::default().fg(theme.accent_error), "Cancelling…".to_string(), false, ), // Goal-mode completion verification runs in-turn after the model // stops streaming. The harness drives the skeptic panel (the model // itself is idle), but the turn's last streaming activity can still // read as `Responding`/`Thinking`; label the whole window // "Verifying…" so the multi-minute panel isn't mislabelled as the // model responding (or a hung "Waiting…"). (AgentState::TurnRunning, _) if goal_verifying => ( Style::default().fg(theme.text_secondary), "Verifying…".to_string(), false, ), (AgentState::TurnRunning, Some(TurnActivity::Thinking)) => ( Style::default().fg(theme.text_secondary), "Thinking…".to_string(), false, ), (AgentState::TurnRunning, Some(TurnActivity::Responding)) => ( Style::default().fg(theme.text_secondary), "Responding…".to_string(), false, ), (AgentState::TurnRunning, Some(TurnActivity::ToolRunning { title, description })) => { // "Ask" tools (AskUserQuestion) use gray spinner like Thinking — // green feels out of place when the user is answering questions. // Human descriptions (e.g. bash `description`) also use muted // secondary — they read as a wait subject (`Wait 5s…`), not a // green `Run ` invocation. let is_ask = title.starts_with("Ask: ") || title.starts_with("Ask "); let has_desc = description .as_deref() .map(str::trim) .is_some_and(|s| !s.is_empty()); let style = if is_ask || has_desc { Style::default().fg(theme.text_secondary) } else { Style::default().fg(theme.accent_success) }; (style, String::new(), true) } (AgentState::TurnRunning, Some(TurnActivity::AutoCompacting)) => ( Style::default().fg(theme.text_secondary), "Compacting…".to_string(), false, ), (AgentState::TurnRunning, Some(TurnActivity::Retrying { attempt, .. })) => ( Style::default().fg(theme.warning), format!("Retrying (attempt {attempt})…"), false, ), (AgentState::TurnRunning, Some(TurnActivity::Waiting(reason))) => ( // Explicit wait reason (model / subagent / task output / tasks / // sleep): name what the agent is blocked on instead of a generic // "Waiting…". See `WaitingReason` and `AgentView::resolve_turn_activity`. Style::default().fg(theme.text_secondary), reason.label(), false, ), (AgentState::TurnRunning, None) if is_bash_turn => ( // Bash turn: not inference, show generic "Running…". Style::default().fg(theme.text_secondary), "Running…".to_string(), false, ), (AgentState::TurnRunning, None) => ( // Fallback: a running inference turn with no resolved activity. The // view resolves this gap into Waiting(Model/Subagent) before render, // so this is now a rarely-hit safety net. Style::default().fg(theme.text_secondary), "Waiting…".to_string(), false, ), ( AgentState::CommandRunning { command: command @ (AgentCommand::CreateWorktree | AgentCommand::RestoreWorktree | AgentCommand::RestoreCode | AgentCommand::ForkSession), .. }, _, ) => ( Style::default().fg(theme.gray), format!("{}…", command.display_name()), false, ), (AgentState::CommandRunning { command, .. }, _) => ( Style::default().fg(theme.text_secondary), format!("{}…", command.display_name()), false, ), (AgentState::Idle, _) => (Style::default(), String::new(), false), } } /// Whether the idle "Starting session…" indicator wants the turn-status row. /// /// True only for a fresh `total == 0` startup seed (gated by /// [`McpInitProgress::is_visible`] so an orphaned seed expires). Real MCP /// progress (`total > 0`) renders as the top-bar chip instead, so it does not /// drive this row. fn starting_session_visible(progress: Option<&McpInitProgress>) -> bool { progress.is_some_and(|p| p.total == 0 && p.is_visible()) } /// Render the idle "Starting session…" indicator above the prompt. /// /// Format: `⠋ Starting session… 0:01` — braille spinner + label + elapsed /// timer. Rendered in `theme.gray_dim` (the dimmest gray) so it reads as /// quiet/ambient, matching the top-bar MCP chip and the directory path — this /// is non-blocking startup, not foreground activity. Shown only while the MCP /// init progress is a startup seed (`total == 0`), before the shell reports /// real server counts; real progress (`total > 0`) renders as the top-bar chip. fn render_starting_session( buf: &mut Buffer, area: Rect, progress: &McpInitProgress, tick: u64, theme: &Theme, ) { let frames = crate::glyphs::braille_spinner_frames(); let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len(); let timer_str = format!(" {}", format_turn_timer(progress.started_at.elapsed())); let style = Style::default().fg(theme.gray_dim); let spans = vec![ Span::styled(format!("{} ", frames[frame_idx]), style), Span::styled("Starting session…", style), Span::styled(timer_str, style), ]; buf.set_line(area.x, area.y, &Line::from(spans), area.width); } /// Whether the turn status line should be visible. /// /// Returns true when a turn is active (Running or Cancelling), when the drain /// is blocked (agent idle, waiting on user edit), while the MCP startup seed /// is showing "Starting session…" (a fresh `total == 0` seed), or when the /// agent is idle but background watchers are still running /// (`watchers.total() > 0`) — running monitors emit events, scheduled `/loop` /// tasks fire prompts, and background subagents inject a completion turn, any /// of which can start a new turn. /// /// Real MCP progress (`total > 0`) renders as a compact chip in the top status /// bar instead, so it does not affect this row. pub fn should_show( state: &AgentState, drain_blocked: bool, mcp_init_progress: Option<&McpInitProgress>, watchers: Watchers, ) -> bool { !state.is_idle() || drain_blocked || starting_session_visible(mcp_init_progress) || watchers.total() > 0 } /// Format a duration for the turn/phase timer. /// /// Re-exports [`crate::util::format_duration`] under the old name for /// backwards compatibility within this module. pub use crate::util::format_duration as format_turn_timer; /// Format a token count for compact display. /// /// - Under 1000: `1`, `10`, `100` (raw number) /// - 1k-100k: `1.23k`, `10.1k` (with decimal) /// - 100k-1m: `100k`, `500k` (whole thousands) /// - 1m+: `1.23m`, `10.1m` (with decimal) fn format_tokens_short(tokens: u64) -> String { if tokens < 1000 { format!("{tokens}") } else if tokens < 100_000 { // 1k-99.9k: show one or two decimals for precision let k = tokens as f64 / 1000.0; if tokens < 10_000 { format!("{k:.2}k") // 1.23k } else { format!("{k:.1}k") // 10.1k } } else if tokens < 1_000_000 { // 100k-999k: whole thousands let k = tokens / 1000; format!("{k}k") } else { // 1m+: show with decimal let m = tokens as f64 / 1_000_000.0; if tokens < 10_000_000 { format!("{m:.2}m") // 1.23m } else { format!("{m:.1}m") // 10.1m } } } #[cfg(test)] mod tests { use std::time::Duration; use super::*; /// Sendable waits = exactly the wait reasons the shell aborts on a queued /// user prompt (blocking task-output / wait_tasks / Await, and a blocked /// foreground subagent await — all take the send-now path). Model waits — /// where typing only queues behind the actively-streaming turn — and /// non-wait activities keep the busy spinner. #[test] fn sendable_wait_matches_shell_interruptible_waits() { let task_wait = |waits| { Some(TurnActivity::Waiting(WaitingReason::TaskOutput { task_ids: vec!["t-1".into()], subject: Some("sleep 300".into()), waits, })) }; assert!(is_sendable_wait(&task_wait(true))); assert!( !is_sendable_wait(&task_wait(false)), "instant polls are not blocking waits" ); assert!(is_sendable_wait(&Some(TurnActivity::Waiting( WaitingReason::TasksComplete )))); assert!(is_sendable_wait(&Some(TurnActivity::Waiting( WaitingReason::Sleep )))); assert!(!is_sendable_wait(&Some(TurnActivity::Waiting( WaitingReason::Model )))); assert!( is_sendable_wait(&Some(TurnActivity::Waiting(WaitingReason::Subagent))), "the shell aborts a blocked foreground subagent await on send-now, \ so Enter during it must read as sendable" ); assert!(!is_sendable_wait(&Some(TurnActivity::Thinking))); assert!(!is_sendable_wait(&None)); } #[test] fn format_subsecond() { assert_eq!(format_turn_timer(Duration::from_millis(500)), "0.5s"); assert_eq!(format_turn_timer(Duration::from_millis(120)), "0.1s"); } #[test] fn format_under_10s_has_decimal() { assert_eq!(format_turn_timer(Duration::from_secs_f64(5.2)), "5.2s"); assert_eq!(format_turn_timer(Duration::from_secs_f64(9.9)), "9.9s"); } #[test] fn format_10s_plus_no_decimal() { assert_eq!(format_turn_timer(Duration::from_secs(10)), "10s"); assert_eq!(format_turn_timer(Duration::from_secs(32)), "32s"); assert_eq!(format_turn_timer(Duration::from_secs(59)), "59s"); } #[test] fn format_minutes() { assert_eq!(format_turn_timer(Duration::from_secs(60)), "1m0s"); assert_eq!(format_turn_timer(Duration::from_secs(80)), "1m20s"); assert_eq!(format_turn_timer(Duration::from_secs(600)), "10m0s"); } #[test] fn activity_label_reads_verifying_while_goal_verifying_overriding_stale_activity() { let theme = Theme::current(); // Running turn, no streaming activity, goal verifying → "Verifying…". let (_, label, _) = compute_activity(&theme, &AgentState::TurnRunning, &None, false, true); assert_eq!(label, "Verifying…"); // Same state without the verifying flag → generic "Waiting…". let (_, label, _) = compute_activity(&theme, &AgentState::TurnRunning, &None, false, false); assert_eq!(label, "Waiting…"); // During verification the model is idle but its last streaming // activity (Responding/Thinking) can linger — the flag overrides it // so the panel reads "Verifying…", not "Responding…" (the bug). for activity in [TurnActivity::Responding, TurnActivity::Thinking] { let (_, label, _) = compute_activity( &theme, &AgentState::TurnRunning, &Some(activity), false, true, ); assert_eq!(label, "Verifying…"); } // Without the flag the streaming label stands. let (_, label, _) = compute_activity( &theme, &AgentState::TurnRunning, &Some(TurnActivity::Responding), false, false, ); assert_eq!(label, "Responding…"); } #[test] fn waiting_reason_renders_specific_label() { use crate::acp::tracker::WaitingReason; let theme = Theme::current(); let cases = [ (WaitingReason::Model, "Waiting for response…"), (WaitingReason::Subagent, "Waiting on subagent…"), (WaitingReason::task_output(), "Waiting on task output…"), ( WaitingReason::TaskOutput { task_ids: vec!["t1".into()], subject: Some("compile release".into()), waits: false, }, "compile release…", ), (WaitingReason::TasksComplete, "Waiting on tasks…"), (WaitingReason::Sleep, "Sleeping…"), ]; for (reason, expected) in cases { let (_, label, is_tool) = compute_activity( &theme, &AgentState::TurnRunning, &Some(TurnActivity::Waiting(reason.clone())), false, false, ); assert_eq!(label, expected, "reason {reason:?}"); assert!(!is_tool, "waiting is not a tool activity"); } } #[test] fn bash_turn_still_renders_running_not_waiting() { let theme = Theme::current(); // A bash (non-inference) turn with no activity keeps its own "Running…" // label — the view leaves it as `None` rather than Waiting(Model). let (_, label, _) = compute_activity(&theme, &AgentState::TurnRunning, &None, true, false); assert_eq!(label, "Running…"); } #[test] fn format_hours() { assert_eq!(format_turn_timer(Duration::from_secs(3600)), "1h0m"); assert_eq!(format_turn_timer(Duration::from_secs(3725)), "1h2m"); } #[test] fn should_show_when_running() { assert!(should_show( &AgentState::TurnRunning, false, None, Watchers::default() )); assert!(should_show( &AgentState::TurnCancelling, false, None, Watchers::default() )); assert!(!should_show( &AgentState::Idle, false, None, Watchers::default() )); } #[test] fn should_show_when_drain_blocked() { assert!(should_show( &AgentState::Idle, true, None, Watchers::default() )); } #[test] fn should_show_when_watchers_running() { // Idle but a watcher (monitor, loop, or subagent) is still running → // row stays visible so the persistent "watching · …" cue can show. assert!(should_show( &AgentState::Idle, false, None, Watchers { monitors: 1, ..Watchers::default() } )); assert!(should_show( &AgentState::Idle, false, None, Watchers { loops: 1, ..Watchers::default() } )); assert!(should_show( &AgentState::Idle, false, None, Watchers { subagents: 1, ..Watchers::default() } )); // Idle with no watchers and nothing else pending → hidden. assert!(!should_show( &AgentState::Idle, false, None, Watchers::default() )); } #[test] fn should_show_when_starting_session() { // A fresh total == 0 seed shows "Starting session…" above the prompt. let seed = McpInitProgress { total: 0, connected: 0, started_at: Instant::now(), }; assert!(should_show( &AgentState::Idle, false, Some(&seed), Watchers::default() )); // Real progress (total > 0) is the top-bar chip — it must NOT drive // this row. let connecting = McpInitProgress { total: 3, connected: 1, started_at: Instant::now(), }; assert!(!should_show( &AgentState::Idle, false, Some(&connecting), Watchers::default() )); // An expired seed must not drive the row either. let expired = McpInitProgress { total: 0, connected: 0, started_at: Instant::now() - McpInitProgress::SEED_EXPIRE - Duration::from_secs(1), }; assert!(!should_show( &AgentState::Idle, false, Some(&expired), Watchers::default() )); } /// Collect every rendered glyph in `area` into a single string. fn buffer_text(buf: &Buffer, area: Rect) -> String { (area.y..area.y + area.height) .map(|y| { (area.x..area.x + area.width) .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string())) .collect::() }) .collect::>() .join("\n") } /// Invoke `render_turn_status` for an idle agent with the given MCP seed. fn render_idle_with_mcp(progress: &McpInitProgress) -> String { let area = Rect::new(0, 0, 60, 1); let mut buf = Buffer::empty(area); render_turn_status( &mut buf, area, &AgentState::Idle, &None, None, None, 0, false, Some(MouseButtons::default()), false, None, Some(progress), false, false, false, Watchers::default(), false, 0, false, ); buffer_text(&buf, area) } /// Invoke `render_turn_status` for an idle agent with the given watcher /// counts at animation tick `tick`. fn render_idle_with_watchers_at_tick(watchers: Watchers, tick: u64) -> String { let area = Rect::new(0, 0, 60, 1); let mut buf = Buffer::empty(area); render_turn_status( &mut buf, area, &AgentState::Idle, &None, None, None, tick, false, Some(MouseButtons::default()), false, None, None, false, false, false, watchers, false, 0, false, ); buffer_text(&buf, area) } /// Invoke `render_turn_status` for an idle agent with the given watcher /// counts at the first animation tick. fn render_idle_with_watchers(watchers: Watchers) -> String { render_idle_with_watchers_at_tick(watchers, 0) } /// Invoke `render_turn_status` for an idle agent with `n` running /// monitors at animation tick `tick`. fn render_idle_with_monitors_at_tick(n: usize, tick: u64) -> String { render_idle_with_watchers_at_tick( Watchers { monitors: n, ..Watchers::default() }, tick, ) } /// Invoke `render_turn_status` for an idle agent with `n` running /// monitors at the first animation tick. fn render_idle_with_monitors(n: usize) -> String { render_idle_with_monitors_at_tick(n, 0) } #[test] fn idle_with_monitors_renders_watching_line() { let text = render_idle_with_monitors(2); assert!( text.contains("watching") && text.contains("2 monitors"), "idle with monitors must render the watching cue, got: {text:?}" ); } #[test] fn idle_with_one_monitor_uses_singular() { let text = render_idle_with_monitors(1); assert!( text.contains("watching \u{00b7} 1 monitor") && !text.contains("monitors"), "single monitor must use the singular noun, got: {text:?}" ); } #[test] fn idle_with_no_monitors_renders_nothing() { let text = render_idle_with_monitors(0); assert!( text.trim().is_empty(), "idle with no monitors must render nothing, got: {text:?}" ); } #[test] fn idle_with_loops_renders_watching_line() { let text = render_idle_with_watchers(Watchers { loops: 2, ..Watchers::default() }); assert!( text.contains("watching") && text.contains("2 loops"), "idle with loops must render the watching cue, got: {text:?}" ); } #[test] fn idle_with_one_loop_uses_singular() { let text = render_idle_with_watchers(Watchers { loops: 1, ..Watchers::default() }); assert!( text.contains("watching \u{00b7} 1 loop") && !text.contains("loops"), "single loop must use the singular noun, got: {text:?}" ); } #[test] fn idle_with_subagents_renders_watching_line() { let text = render_idle_with_watchers(Watchers { subagents: 2, ..Watchers::default() }); assert!( text.contains("watching") && text.contains("2 subagents"), "idle with subagents must render the watching cue, got: {text:?}" ); } #[test] fn idle_with_one_subagent_uses_singular() { let text = render_idle_with_watchers(Watchers { subagents: 1, ..Watchers::default() }); assert!( text.contains("watching \u{00b7} 1 subagent") && !text.contains("subagents"), "single subagent must use the singular noun, got: {text:?}" ); } #[test] fn idle_with_monitors_and_loops_lists_both() { // Both watcher kinds present → one cue lists monitors then loops, // each with its own count, joined by the middle-dot separator. let text = render_idle_with_watchers(Watchers { monitors: 1, loops: 2, ..Watchers::default() }); assert!( text.contains("watching \u{00b7} 1 monitor \u{00b7} 2 loops"), "both kinds must be listed in one cue, got: {text:?}" ); } #[test] fn idle_with_all_watcher_kinds_lists_all() { // Monitors, loops, and subagents present → one cue lists all three in // order (monitors → loops → subagents), middle-dot separated. let text = render_idle_with_watchers(Watchers { monitors: 2, loops: 1, subagents: 3, }); assert!( text.contains("watching \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents"), "all kinds must be listed in one cue, got: {text:?}" ); } #[test] fn idle_with_no_watchers_renders_nothing() { let text = render_idle_with_watchers(Watchers::default()); assert!( text.trim().is_empty(), "idle with no watchers must render nothing, got: {text:?}" ); } #[test] fn queued_hint_renders_after_phase_timer() { let area = Rect::new(0, 0, 80, 1); let mut buf = Buffer::empty(area); render_turn_status( &mut buf, area, &AgentState::TurnRunning, &Some(TurnActivity::Waiting(WaitingReason::Subagent)), None, Some(Instant::now() - Duration::from_secs(359)), 0, false, Some(MouseButtons::default()), false, None, None, false, false, false, Watchers::default(), false, 1, true, ); let text = buffer_text(&buf, area); assert!( text.contains("Waiting on subagent… 5m59s · 1 queued — Enter to send now"), "phase timer must sit between the wait label and the queued hint, got: {text:?}" ); } #[test] fn watching_label_lists_only_nonzero_kinds() { assert_eq!( watching_label(Watchers { monitors: 2, ..Watchers::default() }), "watching \u{00b7} 2 monitors" ); assert_eq!( watching_label(Watchers { loops: 1, ..Watchers::default() }), "watching \u{00b7} 1 loop" ); assert_eq!( watching_label(Watchers { subagents: 1, ..Watchers::default() }), "watching \u{00b7} 1 subagent" ); assert_eq!( watching_label(Watchers { monitors: 1, loops: 2, ..Watchers::default() }), "watching \u{00b7} 1 monitor \u{00b7} 2 loops" ); assert_eq!( watching_label(Watchers { monitors: 1, loops: 1, subagents: 2, }), "watching \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents" ); } #[test] fn idle_monitor_icon_animates_across_ticks() { // The leading glyph cycles through monitor_icon_frames() as `tick` // advances, so two ticks a full frame apart (0 vs MONITOR_PULSE_DIVISOR) // must render different icons — proving the cue is animated, not static. let frame0 = render_idle_with_monitors_at_tick(1, 0); let frame1 = render_idle_with_monitors_at_tick(1, MONITOR_PULSE_DIVISOR); let icon0 = frame0.chars().next(); let icon1 = frame1.chars().next(); assert_ne!( icon0, icon1, "monitor icon must animate between frames, got {frame0:?} vs {frame1:?}" ); } #[test] fn idle_zero_server_seed_renders_starting_session() { // total == 0 seed → "Starting session…" above the prompt. let text = render_idle_with_mcp(&McpInitProgress { total: 0, connected: 0, started_at: Instant::now(), }); assert!( text.contains("Starting session"), "idle 0-server seed must render 'Starting session…', got: {text:?}" ); } #[test] fn idle_active_mcp_progress_renders_nothing_in_turn_status() { // total > 0 is the top-bar chip — the turn-status row stays empty. let text = render_idle_with_mcp(&McpInitProgress { total: 3, connected: 1, started_at: Instant::now(), }); assert!( text.trim().is_empty(), "active MCP progress must NOT render in the turn-status row, got: {text:?}" ); } #[test] fn expired_seed_renders_nothing() { // An expired total == 0 seed renders nothing — defense-in-depth. let text = render_idle_with_mcp(&McpInitProgress { total: 0, connected: 0, started_at: Instant::now() - McpInitProgress::SEED_EXPIRE - Duration::from_secs(1), }); assert!( text.trim().is_empty(), "expired seed must render nothing, got: {text:?}" ); } #[test] fn format_tokens_under_1k() { assert_eq!(format_tokens_short(0), "0"); assert_eq!(format_tokens_short(1), "1"); assert_eq!(format_tokens_short(10), "10"); assert_eq!(format_tokens_short(100), "100"); assert_eq!(format_tokens_short(999), "999"); } #[test] fn format_tokens_1k_to_10k() { assert_eq!(format_tokens_short(1000), "1.00k"); assert_eq!(format_tokens_short(1230), "1.23k"); assert_eq!(format_tokens_short(1500), "1.50k"); assert_eq!(format_tokens_short(9990), "9.99k"); assert_eq!(format_tokens_short(9999), "10.00k"); // rounds up } #[test] fn format_tokens_10k_to_100k() { assert_eq!(format_tokens_short(10000), "10.0k"); assert_eq!(format_tokens_short(10100), "10.1k"); assert_eq!(format_tokens_short(12345), "12.3k"); assert_eq!(format_tokens_short(99999), "100.0k"); // rounds up } #[test] fn format_tokens_100k_to_1m() { assert_eq!(format_tokens_short(100000), "100k"); assert_eq!(format_tokens_short(128000), "128k"); assert_eq!(format_tokens_short(500000), "500k"); assert_eq!(format_tokens_short(999000), "999k"); } #[test] fn format_tokens_millions() { assert_eq!(format_tokens_short(1_000_000), "1.00m"); assert_eq!(format_tokens_short(1_230_000), "1.23m"); assert_eq!(format_tokens_short(9_999_000), "10.00m"); // rounds assert_eq!(format_tokens_short(10_000_000), "10.0m"); assert_eq!(format_tokens_short(10_100_000), "10.1m"); } #[test] fn user_waiting_pulse_speed_is_stable() { // The drain-blocked, pending-user-input, and plan-approval cues // all read from this single constant via `pending_diamond_color`, // so this assertion guards against an accidental tweak that // would silently change the cadence of every "your turn" cue. assert_eq!(USER_WAITING_PULSE_SPEED, 0.08); } }