Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/agent_hud/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
16 changes: 8 additions & 8 deletions src/app/render/agent_hud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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.
Expand Down
131 changes: 117 additions & 14 deletions src/ui/agent_hud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Span<'static>>,
pub attention: &'a str,
pub attention: Attention,
/// What a `waiting` agent waits on.
pub note: Option<&'a str>,
pub repository: Option<&'a str>,
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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<Row>, width: usize) -> Vec<Line<'static>> {
/// they are cut instead. The first column is at least `first` wide.
fn grid(rows: Vec<Row>, width: usize, first: usize) -> Vec<Line<'static>> {
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<Row> = rows
Expand All @@ -246,7 +296,7 @@ fn grid(rows: Vec<Row>, width: usize) -> Vec<Line<'static>> {
.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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]),
])
}
Expand All @@ -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),
Expand Down Expand Up @@ -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")])
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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('…'),
Expand All @@ -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!(
Expand All @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 9 additions & 9 deletions src/ui/agent_hud/agy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("--")]),
]),
};
Expand All @@ -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))]),
])
}
Expand All @@ -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",
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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 ✓"));
}
Expand All @@ -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"));
}

Expand All @@ -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 --"));
}
}
Loading
Loading