feat(tui): tool-card verb glyphs + family vocabulary module

Sub-area #3 of the v0.6.6 UI redesign (issue #121).

Introduces `crates/tui/src/tui/widgets/tool_card.rs` — a small,
self-contained vocabulary module that owns:

- `ToolFamily` enum (Read / Patch / Run / Find / Delegate / Fanout /
  Think / Generic) and the verb-glyph + label per family
  (▷ read, ◆ patch, ▶ run, ⌕ find, ◐ delegate, ⋮⋮ fanout, … think)
- `tool_family_for_title` — maps the legacy header titles
  (`"Shell"`, `"Patch"`, `"Workspace"`, `"Search"`, `"Diff"`, `"Image"`)
  to a family, so existing call sites pick up the new glyph without
  re-architecture
- `tool_family_for_name` — maps actual tool names
  (`agent_spawn`, `apply_patch`, etc.) for `GenericToolCell`, which
  shares the catch-all `"Tool"` title across every model-facing tool
- `CardRail` + `rail_glyph` — the `╭ │ ╰` rail vocabulary, declared
  here so any future per-card refactor has the matching glyphs

Wires the verb glyph + label into `render_tool_header` and adds a
`render_tool_header_with_family` overload so `GenericToolCell` can
route by tool name rather than the generic title. The header now reads
`<spinner> <verb-glyph> <verb> <state>` instead of
`<spinner> <Title-Case-Word> <state>`.

Existing parity tests for ExecCell / PlanUpdate are updated to assert
against the new header structure (verb + glyph) — the colour wiring is
unchanged. New tests pin the verb-glyph format end-to-end:
`agent_spawn` → `◐ delegate`, exec → `▶ run`.

Spinner cadence (TOOL_STATUS_SYMBOL_MS = 720 ms) is unchanged — the
spec already matched.

Deferred to a follow-up: full per-card rail (`╭ │ ╰`) refactor that
threads `CardRail` through every cell render path. The vocabulary is
in place; the layout pass is the next bite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 21:04:23 -05:00
parent 7c3a01c7b8
commit aeba004c7b
3 changed files with 321 additions and 12 deletions
+112 -12
View File
@@ -958,8 +958,13 @@ impl GenericToolCell {
mode: RenderMode,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(render_tool_header(
"Tool",
// Map the actual tool name (e.g. `agent_spawn`, `apply_patch`) to a
// family rather than the catch-all `"Tool"` title — this is what
// gives a `GenericToolCell` the right verb glyph (◐ delegate, ⋮⋮
// fanout, etc.) instead of falling back to the neutral bullet.
let family = crate::tui::widgets::tool_card::tool_family_for_name(&self.name);
lines.push(render_tool_header_with_family(
family,
tool_status_label(self.status),
self.status,
None,
@@ -1757,6 +1762,20 @@ fn render_tool_header(
status: ToolStatus,
started_at: Option<Instant>,
low_motion: bool,
) -> Line<'static> {
let family = crate::tui::widgets::tool_card::tool_family_for_title(title);
render_tool_header_with_family(family, state, status, started_at, low_motion)
}
/// Render a tool-card header with an explicit verb family. Lets callers
/// (e.g. `GenericToolCell`) bypass the legacy title→family mapping when
/// they already know the actual tool name.
fn render_tool_header_with_family(
family: crate::tui::widgets::tool_card::ToolFamily,
state: &str,
status: ToolStatus,
started_at: Option<Instant>,
low_motion: bool,
) -> Line<'static> {
// For long-running tools, append elapsed seconds so the user can see the
// call isn't stuck. Threshold matches the eye's "did this hang?" reflex
@@ -1770,12 +1789,19 @@ fn render_tool_header(
state.to_string()
};
let glyph = crate::tui::widgets::tool_card::family_glyph(family);
let verb = crate::tui::widgets::tool_card::family_label(family);
Line::from(vec![
Span::styled(
format!("{} ", status_symbol(started_at, status, low_motion)),
Style::default().fg(tool_state_color(status)),
),
Span::styled(title.to_string(), tool_title_style()),
Span::styled(
format!("{glyph} "),
Style::default().fg(tool_state_color(status)),
),
Span::styled(verb.to_string(), tool_title_style()),
Span::styled(" ", Style::default()),
Span::styled(state_owned, tool_status_style(status)),
])
@@ -2085,6 +2111,63 @@ mod tests {
);
}
// === Tool-card verb-glyph tests (v0.6.6 UI redesign) ===
#[test]
fn exec_cell_header_uses_run_verb_glyph_and_label() {
let cell = ExecCell {
command: "ls".to_string(),
status: ToolStatus::Success,
output: Some("a\nb\n".to_string()),
started_at: None,
duration_ms: Some(10),
source: ExecSource::Assistant,
interaction: None,
};
let header = &cell.lines_with_motion(80, true)[0];
let visible: String = header
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(
visible.contains('\u{25B6}'),
"Run glyph `▶` present: {visible:?}"
);
assert!(visible.contains(" run "), "verb label `run`: {visible:?}");
// Old literal title must be gone.
assert!(
!visible.contains("Shell"),
"old `Shell` literal is gone: {visible:?}"
);
}
#[test]
fn generic_tool_cell_picks_family_from_tool_name() {
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Running,
input_summary: Some("foo".to_string()),
output: None,
prompts: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
let header_visible: String = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
// agent_spawn → Delegate family (◐ delegate).
assert!(
header_visible.contains('\u{25D0}'),
"Delegate glyph `◐`: {header_visible:?}"
);
assert!(
header_visible.contains(" delegate "),
"verb label `delegate`: {header_visible:?}"
);
}
// === Reasoning treatment tests (v0.6.6 UI redesign) ===
#[test]
@@ -2199,21 +2282,31 @@ mod tests {
let lines = cell.lines_with_motion(80, true);
// Header: "<symbol> Plan <state>"
// Header: "<spinner> <family-glyph> <verb> <state>" (v0.6.6 layout).
// PlanUpdate has no canonical family yet, so it falls into the
// Generic bullet glyph + "tool" verb. The shape and colour wiring
// is what matters for the theme parity; the verb text moves with
// the redesign.
let header = &lines[0];
let symbol_span = &header.spans[0];
let title_span = &header.spans[1];
let state_span = &header.spans[3];
let glyph_span = &header.spans[1];
let title_span = &header.spans[2];
let state_span = &header.spans[4];
assert_eq!(
symbol_span.style.fg,
Some(theme.tool_running_accent),
"running header symbol should use the dark theme running accent"
);
assert_eq!(
glyph_span.style.fg,
Some(theme.tool_running_accent),
"family glyph rides the same status colour as the spinner"
);
assert_eq!(
title_span.content.as_ref(),
"Plan",
"tool title text is locked"
"tool",
"PlanUpdate routes to Generic family → 'tool' verb",
);
assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
assert!(
@@ -2274,18 +2367,25 @@ mod tests {
let header = &lines[0];
let symbol_span = &header.spans[0];
let title_span = &header.spans[1];
let state_span = &header.spans[3];
let glyph_span = &header.spans[1];
let title_span = &header.spans[2];
let state_span = &header.spans[4];
assert_eq!(
symbol_span.style.fg,
Some(theme.tool_failed_accent),
"failed exec header symbol should use the dark theme failed accent"
);
// ExecCell is family Run → glyph `▶ ` and verb `run`.
assert!(
glyph_span.content.starts_with('\u{25B6}'),
"Run family glyph: {:?}",
glyph_span.content
);
assert_eq!(
title_span.content.as_ref(),
"Shell",
"exec title text is locked"
"run",
"ExecCell routes to Run family → 'run' verb",
);
assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
assert!(title_span.style.add_modifier.contains(Modifier::BOLD));
+1
View File
@@ -11,6 +11,7 @@ pub mod key_hint;
// keeps the unused-imports lint quiet until then.
pub mod pending_input_preview;
mod renderable;
pub mod tool_card;
pub use footer::{
FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_working_label,
+208
View File
@@ -0,0 +1,208 @@
//! Tool-card visual vocabulary for the v0.6.6 transcript redesign.
//!
//! Tool cards are the boxes that appear when the agent runs `read_file`,
//! `exec_shell`, `apply_patch`, etc. The visual vocabulary is intentionally
//! sparse: a single verb glyph identifies the family, a left rail anchors
//! the card to the timeline, and the spinner cadence (720 ms/step) reuses
//! the existing tool-status animation.
//!
//! This module owns:
//!
//! - [`ToolFamily`] — the seven canonical families plus a `Generic`
//! fallback for anything we don't have a family for yet.
//! - [`tool_family_for_title`] — maps the legacy `render_tool_header` title
//! string (`"Shell"`, `"Patch"`, `"Workspace"`, etc.) to a family. Lets
//! the existing call sites drop in family glyphs without re-architecting
//! each cell.
//! - [`family_glyph`] / [`family_label`] — the verb glyph + label per
//! family. Glyphs are single graphemes; labels are short verbs.
//! - [`CardRail`] / [`rail_glyph`] — the `╭ │ ╰` rail anchored to the
//! left margin so the eye can group multi-line cards.
//!
//! The actual line composition still happens inside `history.rs`; this
//! module is the vocabulary, not the layout engine. Keeping it small means
//! a future visual refresh only has to touch the constants here.
/// Tool family — the verb the agent is performing. Used to pick a glyph
/// and label for the card header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolFamily {
/// Reads, listings, exploration. `▷ read`.
Read,
/// Edits, patches, writes. `◆ patch`.
Patch,
/// Shell, child processes. `▶ run`.
Run,
/// Grep, fuzzy file search, web search. `⌕ find`.
Find,
/// Single sub-agent dispatch. `◐ delegate`.
Delegate,
/// Multi-agent fanout (swarm, csv). `⋮⋮ fanout`.
Fanout,
/// Reasoning / chain-of-thought. `… think`. Reasoning has its own
/// render path (`render_thinking` in `history.rs`); the family is
/// declared here for completeness so any future code that reaches for
/// it has the matching glyph + label vocabulary.
#[allow(dead_code)]
Think,
/// Anything we don't have a family glyph for yet — falls back to a
/// neutral bullet so the card still renders cleanly.
Generic,
}
/// Map a legacy tool-header title string (the value passed to
/// `render_tool_header`) to a family. Anything unrecognised falls back to
/// [`ToolFamily::Generic`] so cards still render — they just lose the
/// verb-glyph treatment until the family is added here.
#[must_use]
pub fn tool_family_for_title(title: &str) -> ToolFamily {
match title {
"Shell" => ToolFamily::Run,
"Patch" | "Diff" => ToolFamily::Patch,
"Workspace" | "Image" => ToolFamily::Read,
"Search" => ToolFamily::Find,
"Plan" | "Review" => ToolFamily::Generic,
_ => ToolFamily::Generic,
}
}
/// Map an arbitrary tool name (as exposed to the model — e.g. `read_file`,
/// `apply_patch`, `agent_spawn`) to a family. Used by `GenericToolCell`
/// where the `tool_family_for_title` shortcut isn't enough because every
/// generic cell shares the title `"Tool"`.
#[must_use]
pub fn tool_family_for_name(name: &str) -> ToolFamily {
match name {
"read_file" | "list_dir" | "view_image" => ToolFamily::Read,
"edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch,
"exec_shell" | "exec_shell_wait" | "exec_shell_interact" => ToolFamily::Run,
"grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find,
"agent_spawn" => ToolFamily::Delegate,
"agent_swarm" | "spawn_agents_on_csv" | "rlm" => ToolFamily::Fanout,
_ => ToolFamily::Generic,
}
}
/// The verb glyph for a family. Single grapheme so the header layout math
/// in `render_tool_header` stays simple (one cell wide).
#[must_use]
pub fn family_glyph(family: ToolFamily) -> &'static str {
match family {
ToolFamily::Read => "\u{25B7}", // ▷
ToolFamily::Patch => "\u{25C6}", // ◆
ToolFamily::Run => "\u{25B6}", // ▶
ToolFamily::Find => "\u{2315}", // ⌕
ToolFamily::Delegate => "\u{25D0}", // ◐
ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
ToolFamily::Think => "\u{2026}", // …
ToolFamily::Generic => "\u{2022}", // •
}
}
/// The short verb label for a family — appears in card headers next to the
/// glyph. Lowercased on purpose; the verb-glyph + label is the new card
/// title vocabulary.
#[must_use]
pub fn family_label(family: ToolFamily) -> &'static str {
match family {
ToolFamily::Read => "read",
ToolFamily::Patch => "patch",
ToolFamily::Run => "run",
ToolFamily::Find => "find",
ToolFamily::Delegate => "delegate",
ToolFamily::Fanout => "fanout",
ToolFamily::Think => "think",
ToolFamily::Generic => "tool",
}
}
/// Position of a line within a multi-line card — drives the left-rail
/// glyph so the box reads as a contiguous group from top to bottom.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] // wired by future card-refactor follow-ups
pub enum CardRail {
/// First line of the card — the header. `╭`.
Top,
/// Any middle line — body content. `│`.
Middle,
/// Last line of the card. `╰`.
Bottom,
/// Single-line card — no rail at all.
Single,
}
/// Map a [`CardRail`] position to its rail glyph. Returned as a `&str`
/// because callers paste it into a span.
#[must_use]
#[allow(dead_code)] // wired by future card-refactor follow-ups
pub fn rail_glyph(rail: CardRail) -> &'static str {
match rail {
CardRail::Top => "\u{256D}", // ╭
CardRail::Middle => "\u{2502}", // │
CardRail::Bottom => "\u{2570}", // ╰
CardRail::Single => "",
}
}
#[cfg(test)]
mod tests {
use super::{
CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name,
tool_family_for_title,
};
#[test]
fn legacy_titles_route_to_expected_families() {
assert_eq!(tool_family_for_title("Shell"), ToolFamily::Run);
assert_eq!(tool_family_for_title("Patch"), ToolFamily::Patch);
assert_eq!(tool_family_for_title("Workspace"), ToolFamily::Read);
assert_eq!(tool_family_for_title("Search"), ToolFamily::Find);
assert_eq!(tool_family_for_title("Diff"), ToolFamily::Patch);
assert_eq!(tool_family_for_title("Plan"), ToolFamily::Generic);
assert_eq!(tool_family_for_title("unknown title"), ToolFamily::Generic);
}
#[test]
fn tool_names_route_to_families_by_verb() {
assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read);
assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch);
assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run);
assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find);
assert_eq!(tool_family_for_name("agent_spawn"), ToolFamily::Delegate);
assert_eq!(tool_family_for_name("agent_swarm"), ToolFamily::Fanout);
assert_eq!(tool_family_for_name("rlm"), ToolFamily::Fanout);
assert_eq!(tool_family_for_name("totally_new_tool"), ToolFamily::Generic);
}
#[test]
fn each_family_has_a_glyph_and_label() {
// Smoke test — surface accidental empties from a future refactor.
for family in [
ToolFamily::Read,
ToolFamily::Patch,
ToolFamily::Run,
ToolFamily::Find,
ToolFamily::Delegate,
ToolFamily::Fanout,
ToolFamily::Think,
ToolFamily::Generic,
] {
assert!(
!family_glyph(family).is_empty(),
"family {family:?} has empty glyph",
);
assert!(
!family_label(family).is_empty(),
"family {family:?} has empty label",
);
}
}
#[test]
fn card_rail_glyphs_form_a_box() {
assert_eq!(rail_glyph(CardRail::Top), "\u{256D}");
assert_eq!(rail_glyph(CardRail::Middle), "\u{2502}");
assert_eq!(rail_glyph(CardRail::Bottom), "\u{2570}");
assert!(rail_glyph(CardRail::Single).is_empty());
}
}