diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 6545fb81..ba62559a 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -958,8 +958,13 @@ impl GenericToolCell { mode: RenderMode, ) -> Vec> { 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, 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, + 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::(); + 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::(); + // 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: " Plan " + // Header: " " (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)); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 4c3a2dbb..6dd5510e 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -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, diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs new file mode 100644 index 00000000..aa93d422 --- /dev/null +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -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()); + } +}