grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/views/turn_status.rs
grokkybara[bot] 47348d13ec Synced from monorepo
Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
2026-07-25 18:44:42 +00:00

1688 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use xai_grok_workspace::permission::mcp_pretty_name_if_qualified;
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 still-running 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<Rect>,
/// Hit area for the background-demote button, if rendered.
pub bg_button: Option<Rect>,
/// Hit area for the still-running watcher cue (click opens the tasks
/// pane). `None` on keyboard-only hosts.
pub watching_cue: Option<Rect>,
}
/// Hover state for the turn-status row's mouse affordances (`[stop]`, `[↓]`,
/// the still-running watcher cue). `Some(_)` renders them; `None` marks a
/// keyboard-only host (minimal mode — no mouse capture) and suppresses all.
#[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,
/// Whether the mouse is over the still-running watcher cue.
pub watching_hovered: bool,
}
/// Counts of idle-surviving "watcher" work — background jobs that can wake
/// the agent for a new turn while it sits idle (commands and monitors on
/// completion/events, `/loop` tasks on a timer, background subagents on
/// finish). They share one persistent still-running cue above the prompt.
/// Broader than the tasks-pane `Watchers` group (monitors + loops only).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Watchers {
/// Running background commands (non-monitor `background: true` tasks).
pub commands: usize,
/// Running `monitor` background tasks.
pub monitors: usize,
/// Active scheduled `/loop` tasks.
pub loops: usize,
/// Running background subagents. While the agent is idle, any running
/// subagent is a background one — a foreground subagent would keep the
/// parent in `TurnRunning`.
pub subagents: usize,
pub workflows: usize,
}
impl Watchers {
/// Total watcher count across all kinds.
pub fn total(self) -> usize {
self.commands + self.monitors + self.loops + self.subagents + self.workflows
}
/// Awaitable in-flight work — the kinds a blocking `wait_tasks` /
/// `get_task_output` wait can resolve on (commands, monitors, subagents;
/// scheduled `/loop` tasks and workflows are not task waits).
pub fn awaitable_work(self) -> usize {
self.commands + self.monitors + self.subagents
}
}
/// Format a counts-first `"… still running"` cue from `(count, noun)` pairs,
/// listing only the non-zero kinds (plain-`s` plurals) — e.g.
/// `"1 command · 2 monitors still running"`. `None` when every count is
/// zero. Single owner of the format mechanics so the agent view's idle cue
/// and the dashboard's background-work label cannot drift.
pub(crate) fn format_still_running<'a>(
kinds: impl IntoIterator<Item = (usize, &'a str)>,
) -> Option<String> {
use std::fmt::Write as _;
let mut label = String::with_capacity(48);
for (count, noun) in kinds {
if count == 0 {
continue;
}
if !label.is_empty() {
label.push_str(" \u{00b7} ");
}
let plural = if count == 1 { "" } else { "s" };
let _ = write!(label, "{count} {noun}{plural}");
}
if label.is_empty() {
return None;
}
label.push_str(" still running");
Some(label)
}
/// The idle watcher cue's label — e.g.
/// `"1 command · 2 monitors · 1 loop · 1 subagent still running"`. Leads
/// with the counts (not an ambient "watching") so a glance under a
/// "Worked for X" marker still reads as unfinished work. `None` when no
/// watchers are live.
fn still_running_label(watchers: Watchers) -> Option<String> {
format_still_running([
(watchers.commands, "command"),
(watchers.monitors, "monitor"),
(watchers.loops, "loop"),
(watchers.subagents, "subagent"),
(watchers.workflows, "workflow"),
])
}
/// 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<TurnActivity>) -> bool {
matches!(
activity,
Some(TurnActivity::Waiting(
WaitingReason::TaskOutput { waits: true, .. }
| WaitingReason::TasksComplete
| WaitingReason::Sleep
| WaitingReason::Subagent
))
)
}
/// Inputs to [`render_turn_status`] — one frame's worth of turn state.
#[derive(Debug)]
pub struct TurnStatusArgs<'a> {
pub state: &'a AgentState,
pub activity: &'a Option<TurnActivity>,
pub turn_elapsed: Option<Duration>,
pub activity_started_at: Option<Instant>,
pub tick: u64,
pub drain_blocked: bool,
/// Mouse affordances + hover state; `None` for keyboard-only hosts.
pub buttons: Option<MouseButtons>,
pub has_running_execute: bool,
/// Context-window tokens used, shown as `⇣Nk`.
pub total_tokens: Option<u64>,
pub mcp_init_progress: Option<&'a McpInitProgress>,
pub is_bash_turn: bool,
pub is_pending_user_input: bool,
pub goal_verifying: bool,
pub watchers: Watchers,
/// Parked on a sendable wait (`AgentView::renders_parked`).
pub parked: bool,
/// Transparent right-side background so the row blends with the
/// terminal's own background (minimal mode).
pub flat_background: bool,
pub held_queue: usize,
pub held_queue_top_sendable: bool,
}
/// 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).
pub fn render_turn_status(
buf: &mut Buffer,
area: Rect,
args: TurnStatusArgs<'_>,
) -> TurnStatusOutput {
let TurnStatusArgs {
state,
activity,
turn_elapsed,
activity_started_at,
tick,
drain_blocked,
buttons,
has_running_execute,
total_tokens,
mcp_init_progress,
is_bash_turn,
is_pending_user_input,
goal_verifying,
watchers,
parked,
flat_background,
held_queue,
held_queue_top_sendable,
} = args;
// 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();
}
// Idle or parked: persistent cue (not scrollback — it must never scroll
// away). Lower priority than the starting-session and drain-blocked cues
// above. Parked never falls through to the running-turn chrome
// (spinner/timers/[stop]) — the wait aborts the moment the user types,
// so that chrome would lie.
if state.is_idle() || parked {
// Parked with held queued rows: the queued hint IS the input-semantics
// story (Enter acts on the queue immediately), so it replaces the
// generic interrupt copy.
let parked_suffix = if held_queue > 0 && held_queue_top_sendable {
format!(" \u{00b7} {held_queue} queued — Enter to send now")
} else if held_queue > 0 {
format!(" \u{00b7} {held_queue} queued")
} else {
" \u{00b7} send a message to interrupt".to_string()
};
let cue = match (still_running_label(watchers), parked) {
(Some(label), true) => Some(format!("{label}{parked_suffix}")),
(Some(label), false) => Some(label),
(None, true) => Some(format!("waiting{parked_suffix}")),
(None, false) => None,
};
if let Some(cue) = cue {
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
// the agent is idle, so this 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 icon = format!("{} ", frames[frame_idx]);
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
theme.text_primary
} else {
theme.gray
};
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
let spans = vec![
Span::styled(icon, Style::default().fg(theme.accent_system)),
Span::styled(cue, Style::default().fg(label_fg)),
];
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
// The cue opens the tasks pane on click — only advertise the hit
// area when there are tasks to show (a watcherless parked cue has
// nothing behind it).
return TurnStatusOutput {
watching_cue: (show_buttons && watchers.total() > 0)
.then(|| Rect::new(area.x, area.y, cue_width, 1)),
..TurnStatusOutput::default()
};
}
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 running braille spinner 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::braille_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<Span<'static>> = 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<Span<'static>> = 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,
watching_cue: None,
}
}
/// Compute activity style, label, and whether it's a tool.
fn compute_activity(
theme: &Theme,
state: &AgentState,
activity: &Option<TurnActivity>,
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 <command>` 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 commands and monitors wake the agent on
/// completion/events, scheduled `/loop` tasks fire prompts, and background
/// subagents inject a completion turn, any of which can start a new turn.
///
/// A parked turn always shows the row, watchers or not.
///
/// 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,
parked: bool,
) -> bool {
if parked {
return true;
}
!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(),
false
));
assert!(should_show(
&AgentState::TurnCancelling,
false,
None,
Watchers::default(),
false
));
assert!(!should_show(
&AgentState::Idle,
false,
None,
Watchers::default(),
false
));
}
#[test]
fn should_show_when_drain_blocked() {
assert!(should_show(
&AgentState::Idle,
true,
None,
Watchers::default(),
false
));
}
#[test]
fn should_show_when_watchers_running() {
// Idle but a watcher (command, monitor, loop, or subagent) is still
// running → row stays visible so the persistent "… still running" cue
// can show.
for watchers in [
Watchers {
commands: 1,
..Watchers::default()
},
Watchers {
monitors: 1,
..Watchers::default()
},
Watchers {
loops: 1,
..Watchers::default()
},
Watchers {
subagents: 1,
..Watchers::default()
},
] {
assert!(should_show(&AgentState::Idle, false, None, watchers, false));
}
// Idle with no watchers and nothing else pending → hidden.
assert!(!should_show(
&AgentState::Idle,
false,
None,
Watchers::default(),
false
));
}
#[test]
fn should_show_parked_always() {
assert!(should_show(
&AgentState::TurnRunning,
false,
None,
Watchers {
commands: 1,
..Watchers::default()
},
true
));
assert!(should_show(
&AgentState::TurnRunning,
false,
None,
Watchers::default(),
true
));
}
#[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(),
false
));
// 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(),
false
));
// 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(),
false
));
}
/// 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::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
/// Baseline render args: idle agent on a mouse host with the given watchers.
fn idle_args<'a>(watchers: Watchers) -> TurnStatusArgs<'a> {
TurnStatusArgs {
state: &AgentState::Idle,
activity: &None,
turn_elapsed: None,
activity_started_at: None,
tick: 0,
drain_blocked: false,
buttons: Some(MouseButtons::default()),
has_running_execute: false,
total_tokens: None,
mcp_init_progress: None,
is_bash_turn: false,
is_pending_user_input: false,
goal_verifying: false,
watchers,
parked: false,
flat_background: false,
held_queue: 0,
held_queue_top_sendable: false,
}
}
/// Render `args` into a `width`×1 row.
fn render_row(args: TurnStatusArgs<'_>, width: u16) -> (TurnStatusOutput, Buffer) {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
let output = render_turn_status(&mut buf, area, args);
(output, buf)
}
/// Render `args` into a `width`×1 row, returning the visible text.
fn render_row_text(args: TurnStatusArgs<'_>, width: u16) -> String {
let (_, buf) = render_row(args, width);
buffer_text(&buf, buf.area)
}
/// Invoke `render_turn_status` for an idle agent with the given MCP seed.
fn render_idle_with_mcp(progress: &McpInitProgress) -> String {
let mut args = idle_args(Watchers::default());
args.mcp_init_progress = Some(progress);
render_row_text(args, 60)
}
/// 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 {
render_idle_with_watchers_in_width(watchers, tick, 72)
}
/// [`render_idle_with_watchers_at_tick`] with an explicit row width.
fn render_idle_with_watchers_in_width(watchers: Watchers, tick: u64, width: u16) -> String {
let mut args = idle_args(watchers);
args.tick = tick;
render_row_text(args, width)
}
/// Invoke `render_turn_status` for a PARKED running turn (the stopped
/// look) with the given watcher counts.
fn render_parked_with_watchers(watchers: Watchers) -> String {
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
let mut args = idle_args(watchers);
args.state = &AgentState::TurnRunning;
args.activity = &activity;
args.turn_elapsed = Some(Duration::from_secs(5));
args.parked = true;
render_row_text(args, 72)
}
/// 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_still_running_cue() {
let text = render_idle_with_monitors(2);
assert!(
text.contains("2 monitors still running"),
"idle with monitors must render the still-running cue, got: {text:?}"
);
}
#[test]
fn idle_with_one_monitor_uses_singular() {
let text = render_idle_with_monitors(1);
assert!(
text.contains("1 monitor still running") && !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:?}"
);
}
/// Mouse hosts get a hit rect hugging exactly the rendered cue text, and
/// hover brightens the label; keyboard-only hosts get neither.
#[test]
fn watching_cue_is_clickable_on_mouse_hosts_only() {
let theme = Theme::current();
let watchers = Watchers {
monitors: 1,
..Watchers::default()
};
// First label cell (after the 2-col icon).
let label_fg = |buf: &Buffer| buf.cell((2, 0)).map(|c| c.fg);
let (output, buf) = render_row(idle_args(watchers), 60);
let rect = output.watching_cue.expect("mouse host must get a hit rect");
let rendered_width = buffer_text(&buf, buf.area).trim_end().width() as u16;
assert_eq!(rect, Rect::new(0, 0, rendered_width, 1));
assert_eq!(label_fg(&buf), Some(theme.gray));
let mut args = idle_args(watchers);
args.buttons = Some(MouseButtons {
watching_hovered: true,
..MouseButtons::default()
});
let (_, buf) = render_row(args, 60);
assert_eq!(label_fg(&buf), Some(theme.text_primary));
let mut args = idle_args(watchers);
args.buttons = None;
let (output, _) = render_row(args, 60);
assert!(output.watching_cue.is_none());
}
#[test]
fn idle_with_loops_renders_still_running_cue() {
let text = render_idle_with_watchers(Watchers {
loops: 2,
..Watchers::default()
});
assert!(
text.contains("2 loops still running"),
"idle with loops must render the still-running 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("1 loop still running") && !text.contains("loops"),
"single loop must use the singular noun, got: {text:?}"
);
}
#[test]
fn idle_with_subagents_renders_still_running_cue() {
let text = render_idle_with_watchers(Watchers {
subagents: 2,
..Watchers::default()
});
assert!(
text.contains("2 subagents still running"),
"idle with subagents must render the still-running 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("1 subagent still running") && !text.contains("subagents"),
"single subagent must use the singular noun, got: {text:?}"
);
}
#[test]
fn idle_with_one_workflow_counts_run_once() {
let text = render_idle_with_watchers(Watchers {
workflows: 1,
..Watchers::default()
});
assert!(text.contains("1 workflow still running"), "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("1 monitor \u{00b7} 2 loops still running"),
"both kinds must be listed in one cue, got: {text:?}"
);
}
#[test]
fn idle_with_all_watcher_kinds_lists_all() {
// Commands, monitors, loops, and subagents present → one cue lists
// all four in order, middle-dot separated.
let text = render_idle_with_watchers(Watchers {
commands: 1,
monitors: 2,
loops: 1,
subagents: 3,
workflows: 0,
});
assert!(
text.contains(
"1 command \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents still running"
),
"all kinds must be listed in one cue, got: {text:?}"
);
}
#[test]
fn narrow_area_clips_cue_tail_keeping_counts() {
// 40 cols with three kinds: the row tail-clips with no ellipsis, so
// the leading counts survive and the trailing suffix is what gets
// cut. Pins the narrow-pane tradeoff of leading with the counts; a
// smarter compact fallback would be a behavior change.
let watchers = Watchers {
commands: 1,
monitors: 2,
loops: 1,
..Watchers::default()
};
let text = render_idle_with_watchers_in_width(watchers, 0, 40);
assert!(
text.contains("1 command \u{00b7} 2 monitors \u{00b7} 1 loop"),
"the counts must survive the clip, got: {text:?}"
);
}
#[test]
fn idle_with_commands_renders_still_running_cue() {
// Plain background commands (non-monitor bg tasks) count as watchers:
// they wake the agent with a task-completed turn, so the cue must show.
let text = render_idle_with_watchers(Watchers {
commands: 2,
..Watchers::default()
});
assert!(
text.contains("2 commands still running"),
"idle with bg commands must render the still-running cue, got: {text:?}"
);
let text = render_idle_with_watchers(Watchers {
commands: 1,
..Watchers::default()
});
assert!(
text.contains("1 command still running") && !text.contains("commands"),
"single command must use the singular noun, got: {text:?}"
);
}
#[test]
fn parked_with_watchers_renders_cue_not_running_chrome() {
// The wait aborts as soon as the user types, so busy chrome would lie.
let text = render_parked_with_watchers(Watchers {
commands: 2,
..Watchers::default()
});
assert!(
text.contains("2 commands still running \u{00b7} send a message to interrupt"),
"parked with bg work must render the interruptible still-running cue, got: {text:?}"
);
assert!(
!text.contains("Waiting") && !text.contains("[stop]"),
"parked must not render the running-turn chrome, got: {text:?}"
);
}
#[test]
fn parked_without_watchers_renders_waiting_cue() {
let text = render_parked_with_watchers(Watchers::default());
assert!(
text.contains("waiting \u{00b7} send a message to interrupt"),
"watcherless parked must render the waiting interrupt cue, got: {text:?}"
);
assert!(
!text.contains("[stop]"),
"watcherless parked must not render the running-turn chrome, got: {text:?}"
);
}
#[test]
fn parked_with_held_queue_renders_queued_hint() {
// The queued hint replaces the interrupt copy (Enter = send-now).
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
let mut args = idle_args(Watchers {
commands: 1,
..Watchers::default()
});
args.state = &AgentState::TurnRunning;
args.activity = &activity;
args.parked = true;
args.held_queue = 1;
args.held_queue_top_sendable = true;
let text = render_row_text(args, 80);
assert!(
text.contains("1 queued — Enter to send now"),
"parked with a held row must advertise the queued hint, got: {text:?}"
);
assert!(
!text.contains("send a message to interrupt"),
"queued hint replaces the interrupt copy, 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 activity = Some(TurnActivity::Waiting(WaitingReason::Subagent));
let mut args = idle_args(Watchers::default());
args.state = &AgentState::TurnRunning;
args.activity = &activity;
args.activity_started_at = Some(Instant::now() - Duration::from_secs(359));
args.held_queue = 1;
args.held_queue_top_sendable = true;
let text = render_row_text(args, 80);
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 still_running_label_lists_only_nonzero_kinds() {
assert_eq!(
still_running_label(Watchers {
commands: 2,
..Watchers::default()
}),
Some("2 commands still running".into())
);
assert_eq!(
still_running_label(Watchers {
monitors: 2,
..Watchers::default()
}),
Some("2 monitors still running".into())
);
assert_eq!(
still_running_label(Watchers {
loops: 1,
..Watchers::default()
}),
Some("1 loop still running".into())
);
assert_eq!(
still_running_label(Watchers {
subagents: 1,
..Watchers::default()
}),
Some("1 subagent still running".into())
);
assert_eq!(
still_running_label(Watchers {
monitors: 1,
loops: 2,
..Watchers::default()
}),
Some("1 monitor \u{00b7} 2 loops still running".into())
);
assert_eq!(
still_running_label(Watchers {
commands: 1,
monitors: 1,
loops: 1,
subagents: 2,
workflows: 0,
}),
Some(
"1 command \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents still running"
.into()
)
);
assert_eq!(still_running_label(Watchers::default()), None);
}
#[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);
}
}