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:
+112
-12
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user