diff --git a/DESIGN.md b/DESIGN.md index 5a14af2c..48168d32 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,17 +115,18 @@ collapse full to compact when needed, then hide the strip. The native composer stays visible within the child, including in zoom and splits. ```text -● working df17b585 opus-5-5 xhigh spyc ⎇ main 32m -context 336.6k out 370.2k hit 98% compacted 1 -tools 271 calls ✗ 4 failed ◐ Bash Bash 182 ✗3 · ✓ Read 41 · Edit 23 ✗1 -agents 1 background 5 todo 2/5 (1 active) hooks ✓ mcp ✓ +● working df17b585 opus-5-5 xhigh spyc ⎇ main 32m +context 336.6k out 370.2k hit 98% compacted 1 +tools 271 calls ✗ 4 failed ◐ Bash Bash 182 ✗3 · ✓ Read 41 · Edit 23 ✗1 +agents 1 background 5 todo 2/5 (1 active) hooks ✓ mcp ✓ ``` It is one template for every agent with a metrics source: the activity row, the health cells and the styling are shared, and each agent fills only its usage, tools and mode cells. The rows form a grid, so a column starts at the -same x on every row. A label column leads; value columns are at least twelve -wide, so a count gaining a digit rarely moves its neighbour; a cell with a +same x on every row. A heading column leads, as wide as the widest state the +activity cell can show, so a change of state never moves a column; value +columns are at least twelve wide, so a count gaining a digit rarely moves its neighbour; a cell with a column after it is cut past 32, and the repository and branch past 28, so an unusually long branch name can only push the columns after it so far. As the frame narrows, whole cells drop from the right, except the activity and the @@ -144,8 +145,8 @@ strip. The strip is spyc's text, never the child's input: a press there selects, a drag copies, and the wheel does nothing. The activity cell is the tab's own dot in its own colour, with the state in -bold beside it, so the strip and the tab never disagree. Field names are dim and -values bright; a key value is bold. Calls are counted in bright text, a running +bold beside it, so the strip and the tab never disagree. Row headings are bright +and bold; field names are dim and values bright; a key value is bold. Calls are counted in bright text, a running call is marked `◐` in amber, failures `✗` in red, and a working hook or MCP connection `✓` in green, with a word only when one isn't working. A stale value is dimmed rather than labelled. Monochrome keeps the weights and drops every diff --git a/src/app/agent_hud/tests.rs b/src/app/agent_hud/tests.rs index d9a6392d..48a4ec19 100644 --- a/src/app/agent_hud/tests.rs +++ b/src/app/agent_hud/tests.rs @@ -325,7 +325,7 @@ fn agent_hud_real_frame_resizes_child_and_preserves_chrome_across_modes() { .with_screen(TerminalScreen::size), (child.height, child.width) ); - assert!(rows[0].contains("56789abc") || width < 20); + assert!(rows[0].contains("56789abc") || width < 22); if strip.width >= 40 { assert!(rows[1].starts_with("context")); assert!(rows[1].ends_with("awaiting metrics")); diff --git a/src/app/render/agent_hud.rs b/src/app/render/agent_hud.rs index 3f40e57c..3bc1906b 100644 --- a/src/app/render/agent_hud.rs +++ b/src/app/render/agent_hud.rs @@ -5,7 +5,7 @@ use ratatui::{Frame, layout::Rect}; use crate::agent::tail::Availability; use crate::config::AgentHudMode; use crate::pane::AgentActivity; -use crate::ui::agent_hud::{self, Hooks, HudView, Metrics, Reported}; +use crate::ui::agent_hud::{self, Attention, Hooks, HudView, Metrics, Reported}; use super::super::state::agent_hud::{Feed, Update}; @@ -46,15 +46,15 @@ impl App { .flatten() }); let attention = if info.suspended { - "suspended" + Attention::Suspended } else { match info.activity { - AgentActivity::Working => "working", - AgentActivity::Waiting => "waiting", - AgentActivity::Blocked => "needs you", - AgentActivity::Done => "done", - AgentActivity::Idle => "idle", - AgentActivity::Unknown => "status --", + AgentActivity::Working => Attention::Working, + AgentActivity::Waiting => Attention::Waiting, + AgentActivity::Blocked => Attention::NeedsYou, + AgentActivity::Done => Attention::Done, + AgentActivity::Idle => Attention::Idle, + AgentActivity::Unknown => Attention::Unknown, } }; // What a `waiting` agent waits on, as it or its `Stop` hook said. diff --git a/src/ui/agent_hud.rs b/src/ui/agent_hud.rs index 0cb5f53b..f222be84 100644 --- a/src/ui/agent_hud.rs +++ b/src/ui/agent_hud.rs @@ -33,6 +33,8 @@ const MAX_CELL: usize = 32; const MAX_TOOL_NAME: usize = 12; /// How many of the most-called tools the collapsed row names. const TOP_TOOLS: usize = 3; +/// The activity dot and the space after it. +const DOT: usize = 2; /// Repository and branch are cut tighter than [`MAX_CELL`]: a long branch name /// still widens its column, but not enough to push the row's later fields off /// a typical terminal. @@ -103,11 +105,59 @@ pub enum Hooks { Absent, } +/// What the activity row says the agent is doing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Attention { + Working, + Waiting, + NeedsYou, + Done, + Idle, + Suspended, + Unknown, +} + +impl Attention { + /// Every state, so the first column can be as wide as the widest. + const ALL: [Self; 7] = [ + Self::Working, + Self::Waiting, + Self::NeedsYou, + Self::Done, + Self::Idle, + Self::Suspended, + Self::Unknown, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Working => "working", + Self::Waiting => "waiting", + Self::NeedsYou => "needs you", + Self::Done => "done", + Self::Idle => "idle", + Self::Suspended => "suspended", + Self::Unknown => "status --", + } + } +} + +/// The first column's width: the widest activity, dot included, so a change +/// of state never moves the columns after it. The headings below it are +/// narrower. +fn first_column_width() -> usize { + Attention::ALL + .iter() + .map(|a| DOT + display_width(a.label())) + .max() + .unwrap_or(0) +} + pub struct HudView<'a> { pub session: Option<&'a str>, /// The tab's activity dot, in its own colour. pub dot: Option>, - pub attention: &'a str, + pub attention: Attention, /// What a `waiting` agent waits on. pub note: Option<&'a str>, pub repository: Option<&'a str>, @@ -150,7 +200,7 @@ pub fn lines( rows.push(agent.tools); rows.push(mode_row); } - let mut lines = grid(rows, usize::from(width)); + let mut lines = grid(rows, usize::from(width), first_column_width()); if theme.mono { // Shape and weight carry the meaning; no span keeps a colour. for span in lines.iter_mut().flat_map(|line| line.spans.iter_mut()) { @@ -220,8 +270,8 @@ impl Field { /// Lays `rows` out so column `j` starts at the same x on every row. A cell /// past the right edge is dropped, never wrapped, except in the first two /// columns: the activity and the session have first claim on the width, so -/// they are cut instead. -fn grid(rows: Vec, width: usize) -> Vec> { +/// they are cut instead. The first column is at least `first` wide. +fn grid(rows: Vec, width: usize, first: usize) -> Vec> { let columns = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); // The last column moves nothing, so only the width of the strip cuts it. let rows: Vec = rows @@ -246,7 +296,7 @@ fn grid(rows: Vec, width: usize) -> Vec> { .map(Field::width) .max() .unwrap_or(0); - let floor = if j == 0 { 0 } else { MIN_COLUMN }; + let floor = if j == 0 { first } else { MIN_COLUMN }; x += widest.max(floor) + GAP; } rows.into_iter() @@ -322,6 +372,15 @@ impl Palette { } } + /// A row's heading, in the first column: bright and bold, so it reads + /// apart from the dim names and placeholders beside it. + fn heading(self, text: &str) -> Span<'static> { + Span::styled( + safe_label(text), + Style::default().fg(self.value).add_modifier(Modifier::BOLD), + ) + } + /// A field's name, or a value that is only a placeholder. fn label(self, text: &str) -> Span<'static> { let style = Style::default().fg(self.label); @@ -371,7 +430,7 @@ impl Palette { Some(other) => self.tint(&source_label(other, file), self.warn), }; Row::new(vec![ - Field(vec![self.label("context")]), + Field(vec![self.heading("context")]), Field(vec![reason]), ]) } @@ -396,7 +455,7 @@ fn activity(view: &HudView<'_>, p: Palette) -> Row { first.push(Span::raw(" ")); } first.push(Span::styled( - safe_label(view.attention), + view.attention.label(), Style::default() .fg(colour.unwrap_or(p.value)) .add_modifier(Modifier::BOLD), @@ -514,7 +573,7 @@ fn tools_row(label: &str, tallies: &[Tally], running: Option<&[String]>, p: Pale let calls: u32 = tallies.iter().map(|t| t.calls).sum(); let failed: u32 = tallies.iter().map(|t| t.failed).sum(); let mut cells = vec![ - Field(vec![p.label(label)]), + Field(vec![p.heading(label)]), Field(vec![p.strong(&calls.to_string()), p.label(" calls")]), if failed == 0 { Field(vec![p.label("0 failed")]) @@ -612,7 +671,7 @@ mod tests { HudView { session: Some("01a133b2-dc16-74c8-af3c-111111111111"), dot: None, - attention: "idle", + attention: Attention::Idle, note: None, repository: Some("spyc"), branch: Some("main"), @@ -681,7 +740,7 @@ mod tests { HudView { session: Some("537fb604-0a1b-478f-be17-3c76df17b585"), dot: Some(Span::raw("●")), - attention: "working", + attention: Attention::Working, note: None, repository: Some("spyc"), branch: Some(branch), @@ -783,7 +842,7 @@ mod tests { last[2].ends_with("✓ Read 141 · ✓ spyc:get_sp… 19 · Edit 9 ✗1"), "the last column is cut only by the strip: {last:?}" ); - let tight = text(&lines(&view, AgentHudMode::Compact, 14, &Theme::default())); + let tight = text(&lines(&view, AgentHudMode::Compact, 16, &Theme::default())); assert!(tight[0].starts_with("● working")); assert!( tight[0].ends_with('…'), @@ -796,7 +855,7 @@ mod tests { let (snapshot, branch) = claude_session("main"); let mut view = claude_view(&snapshot, branch); let before = text(&lines(&view, AgentHudMode::Full, 200, &Theme::default())); - view.attention = "waiting"; + view.attention = Attention::Waiting; view.note = Some("CI on #641, the long way round"); let after = text(&lines(&view, AgentHudMode::Full, 200, &Theme::default())); assert!( @@ -819,10 +878,54 @@ mod tests { assert!(spans.iter().all(|s| s.style.fg == Some(Color::Reset))); let style = |text: &str| spans.iter().find(|s| s.content == text).unwrap().style; assert!(style("working").add_modifier.contains(Modifier::BOLD)); - assert!(style("context").add_modifier.contains(Modifier::DIM)); + assert!(style("context").add_modifier.contains(Modifier::BOLD)); + assert!(!style("context").add_modifier.contains(Modifier::DIM)); + assert!(style("compacted").add_modifier.contains(Modifier::DIM)); assert!(style("336.6k").add_modifier.contains(Modifier::BOLD)); } + /// A change of state never moves a column: the first is as wide as the + /// widest activity it can show. + #[test] + fn every_activity_leaves_the_columns_where_they_were() { + let (snapshot, branch) = claude_session("main"); + let mut view = claude_view(&snapshot, branch); + let session_x = |view: &HudView<'_>| { + let rows = text(&lines(view, AgentHudMode::Full, 200, &Theme::default())); + (x_of(&rows[0], "df17b585"), x_of(&rows[1], "336.6k")) + }; + let expected = session_x(&view); + for attention in Attention::ALL { + for dot in [Some(Span::raw("●")), None] { + view.attention = attention; + view.dot = dot; + assert_eq!(session_x(&view), expected, "{attention:?}"); + } + } + } + + /// Row headings read apart from the field names and placeholders beside + /// them: bright and bold, where those are dim and plain. + #[test] + fn headings_stand_apart_from_the_names_beside_them() { + let (snapshot, branch) = claude_session("main"); + let view = claude_view(&snapshot, branch); + let theme = Theme::default(); + let lines = lines(&view, AgentHudMode::Full, 200, &theme); + let spans: Vec<&Span<'_>> = lines.iter().flat_map(|l| &l.spans).collect(); + let style = |text: &str| spans.iter().find(|s| s.content == text).unwrap().style; + for heading in ["context", "tools", "agents"] { + let style = style(heading); + assert_eq!(style.fg, Some(theme.status_path), "{heading}"); + assert!(style.add_modifier.contains(Modifier::BOLD), "{heading}"); + } + for name in ["compacted", "background"] { + let style = style(name); + assert_eq!(style.fg, Some(theme.status_suffix), "{name}"); + assert!(!style.add_modifier.contains(Modifier::BOLD), "{name}"); + } + } + #[test] fn health_is_a_tick_when_it_works_and_a_word_when_it_does_not() { let (snapshot, branch) = claude_session("main"); @@ -923,7 +1026,7 @@ mod tests { ..Snapshot::default() }; let mut view = codex_view(Some(&snapshot)); - view.attention = "needs you"; + view.attention = Attention::NeedsYou; view.session = Some("12345678"); view.now = Some(at); for width in 1..130 { diff --git a/src/ui/agent_hud/agy.rs b/src/ui/agent_hud/agy.rs index 5c086bfe..de67e31c 100644 --- a/src/ui/agent_hud/agy.rs +++ b/src/ui/agent_hud/agy.rs @@ -18,11 +18,11 @@ pub(super) fn rows( return AgentRows { usage, tools: Row::new(vec![ - Field(vec![p.label("tools")]), + Field(vec![p.heading("tools")]), Field(vec![p.label("--")]), ]), mode: Row::new(vec![ - Field(vec![p.label("agents")]), + Field(vec![p.heading("agents")]), Field(vec![p.label("--")]), ]), }; @@ -38,7 +38,7 @@ pub(super) fn rows( fn mode_row(s: &Snapshot, at_least: &str, p: Palette) -> Row { Row::new(vec![ - Field(vec![p.label("agents")]), + Field(vec![p.heading("agents")]), Field(vec![p.strong(&format!("{at_least}{}", s.subagent_calls))]), ]) } @@ -57,7 +57,7 @@ fn usage(s: &Snapshot, at_least: &str, p: Palette) -> Row { .checked_div(sent) .map_or_else(|| p.label("--"), |pct| p.value(&format!("{pct}%"))); let cells = vec![ - Field(vec![p.label("context")]), + Field(vec![p.heading("context")]), Field(context), p.pair( "out", @@ -103,7 +103,7 @@ mod tests { use jiff::Timestamp; use ratatui::text::Span; - use super::super::{Hooks, HudView, Metrics, lines}; + use super::super::{Attention, Hooks, HudView, Metrics, lines}; use crate::agent::agy_metrics::{ Availability, Compaction, Mode, Sample, Snapshot, Tool, ToolCount, ToolStatus, Usage, }; @@ -162,7 +162,7 @@ mod tests { HudView { session: Some("00000000-0000-0000-0000-000000000001"), dot: Some(Span::raw("●")), - attention: "working", + attention: Attention::Working, note: None, repository: Some("spyc"), branch: Some("main"), @@ -198,7 +198,7 @@ mod tests { assert!(rendered[1].contains("compacted 1")); assert!(rendered[2].contains("tools")); assert!(rendered[2].contains("30 calls")); - assert!(rendered[3].contains("agents 3")); + assert!(rendered[3].contains("agents 3")); assert!(rendered[3].contains("hooks ✓")); assert!(rendered[3].contains("mcp ✓")); } @@ -213,7 +213,7 @@ mod tests { }); snapshot.compactions = 2; let compacted = rows(&view(Some(&snapshot), true), 160); - assert!(compacted[1].contains("context --")); + assert!(compacted[1].contains("context --")); assert!(compacted[1].contains("compacted ≥2")); } @@ -224,6 +224,6 @@ mod tests { let rendered = rows(&missing, 120); assert!(rendered[1].contains("transcript missing")); assert!(rendered[2].contains("--")); - assert!(rendered[3].contains("agents --")); + assert!(rendered[3].contains("agents --")); } } diff --git a/src/ui/agent_hud/claude.rs b/src/ui/agent_hud/claude.rs index f9349472..4adb765d 100644 --- a/src/ui/agent_hud/claude.rs +++ b/src/ui/agent_hud/claude.rs @@ -26,11 +26,11 @@ pub(super) fn rows( return AgentRows { usage, tools: Row::new(vec![ - Field(vec![p.label("tools")]), + Field(vec![p.heading("tools")]), Field(vec![p.label("--")]), ]), mode: Row::new(vec![ - Field(vec![p.label("agents")]), + Field(vec![p.heading("agents")]), Field(vec![p.label("--")]), p.pair("background", p.label("--")), ]), @@ -47,7 +47,7 @@ pub(super) fn rows( fn mode_row(s: &Snapshot, at_least: &str, p: Palette) -> Row { let mut cells = vec![ - Field(vec![p.label("agents")]), + Field(vec![p.heading("agents")]), Field(vec![p.strong(&format!("{at_least}{}", s.subagent_calls))]), p.pair( "background", @@ -102,7 +102,7 @@ fn usage(s: &Snapshot, at_least: &str, reported: Reported<'_>, p: Palette) -> Ro .saturating_mul(100) .checked_div(sent) .map_or_else(|| p.label("--"), |pct| p.value(&format!("{pct}%"))); - let mut cells = vec![Field(vec![p.label("context")]), Field(context)]; + let mut cells = vec![Field(vec![p.heading("context")]), Field(context)]; cells.extend(plan(reported, p)); cells.extend([ p.pair( @@ -163,7 +163,7 @@ mod tests { use ratatui::style::Modifier; use ratatui::text::Span; - use super::super::{Hooks, HudView, Metrics, lines}; + use super::super::{Attention, Hooks, HudView, Metrics, lines}; use crate::agent::claude_metrics::{ Availability, Compaction, Mode, Plan, Sample, Snapshot, Tool, ToolCount, ToolStatus, Usage, }; @@ -229,7 +229,7 @@ mod tests { HudView { session: Some("537fb604-0a1b-478f-be17-3c76df17b585"), dot: Some(Span::raw("●")), - attention: "working", + attention: Attention::Working, note: None, repository: Some("spyc"), branch: Some("main"), @@ -259,10 +259,10 @@ mod tests { assert_eq!( rows(&view(Some(&snapshot), false), 120), [ - "● working df17b585 opus-5-5 xhigh spyc ⎇ main 32m", - "context 336.6k out 370.2k hit 98% compacted 1", - "tools 271 calls ✗ 4 failed ◐ Bash Bash 182 ✗3 · ✓ Read 41 · Edit 23 ✗1", - "agents 1 background 5 hooks ✓ mcp ✓", + "● working df17b585 opus-5-5 xhigh spyc ⎇ main 32m", + "context 336.6k out 370.2k hit 98% compacted 1", + "tools 271 calls ✗ 4 failed ◐ Bash Bash 182 ✗3 · ✓ Read 41 · Edit 23 ✗1", + "agents 1 background 5 hooks ✓ mcp ✓", ] ); } @@ -286,7 +286,7 @@ mod tests { snapshot.compactions = 2; let compacted = rows(&view(Some(&snapshot), true), 160); assert!( - compacted[1].starts_with("context -- out ≥370.2k"), + compacted[1].starts_with("context -- out ≥370.2k"), "lifetime totals survive; a tail-only read says they may be more: {compacted:?}" ); assert!(compacted[1].ends_with("compacted ≥2")); @@ -438,7 +438,7 @@ mod tests { }; let line = lines(&v, AgentHudMode::Compact, 200, &theme).swap_remove(1); let text = line.to_string(); - assert!(text.starts_with("context 336.6k 55%"), "{text}"); + assert!(text.starts_with("context 336.6k 55%"), "{text}"); assert!( text.find("5h 23%").unwrap() < text.find("7d 85%").unwrap() && text.find("7d 85%").unwrap() < text.find("out ").unwrap(), diff --git a/src/ui/agent_hud/codex.rs b/src/ui/agent_hud/codex.rs index c0704f40..d17d84fb 100644 --- a/src/ui/agent_hud/codex.rs +++ b/src/ui/agent_hud/codex.rs @@ -14,7 +14,7 @@ pub(super) fn rows(snapshot: Option<&Snapshot>, view: &HudView<'_>, p: Palette) let mode = |approval: Option<&str>, sandbox: Option<&str>| { let field = |v: Option<&str>| Field(vec![v.map_or_else(|| p.label("--"), |v| p.value(v))]); Row::new(vec![ - Field(vec![p.label("mode")]), + Field(vec![p.heading("mode")]), field(approval), field(sandbox), ]) @@ -23,7 +23,7 @@ pub(super) fn rows(snapshot: Option<&Snapshot>, view: &HudView<'_>, p: Palette) return AgentRows { usage: p.unavailable(view.availability, "rollout"), tools: Row::new(vec![ - Field(vec![p.label("recent")]), + Field(vec![p.heading("recent")]), Field(vec![p.label("--")]), ]), mode: mode(None, None), @@ -69,7 +69,7 @@ fn usage(s: &Snapshot, view: &HudView<'_>, p: Palette) -> Row { }, ); Row::new(vec![ - Field(vec![p.label("context")]), + Field(vec![p.heading("context")]), Field(vec![p.label("--")]), tokens("req", s.request.as_ref().or(s.legacy_last.as_ref())), tokens("turn", s.turn.as_ref()), @@ -114,7 +114,7 @@ fn tallies(s: &Snapshot) -> Vec { mod tests { use jiff::Timestamp; - use super::super::{Hooks, HudView, Metrics, lines}; + use super::super::{Attention, Hooks, HudView, Metrics, lines}; use crate::agent::codex_metrics::{ Availability, Mode, Sample, Snapshot, Source, Tokens, Tool, ToolStatus, }; @@ -125,7 +125,7 @@ mod tests { let view = HudView { session: Some("01a133b2-dc16-74c8-af3c-123456789abc"), dot: None, - attention: "working", + attention: Attention::Working, note: None, repository: Some("spyc"), branch: None,