返回 CodeWhale
tool_card.rs
根目录 / crates / tui / src / tui / widgets / tool_card.rs
1 //! Tool-card visual vocabulary for the v0.6.6 transcript redesign.
2 //!
3 //! Tool cards are the boxes that appear when the agent runs `read_file`,
4 //! `exec_shell`, `apply_patch`, etc. The visual vocabulary is intentionally
5 //! sparse: a single verb glyph identifies the family, a left rail anchors
6 //! the card to the timeline, and the spinner cadence reuses the existing
7 //! tool-status animation.
8 //!
9 //! This module owns:
10 //!
11 //! - [`ToolFamily`] — the canonical semantic families plus a `Generic`
12 //! fallback for anything we don't have a family for yet.
13 //! - [`tool_family_for_title`] — maps the legacy `render_tool_header` title
14 //! string (`"Shell"`, `"Patch"`, `"Workspace"`, etc.) to a family. Lets
15 //! the existing call sites drop in family glyphs without re-architecting
16 //! each cell.
17 //! - [`family_glyph`] / [`family_label`] — the verb glyph + label per
18 //! family. Glyphs are single graphemes; labels are short verbs.
19 //! - [`CardRail`] / [`rail_glyph`] — the `╭ │ ╰` rail anchored to the
20 //! left margin so the eye can group multi-line cards.
21 //!
22 //! The actual line composition still happens inside `history.rs`; this
23 //! module is the vocabulary, not the layout engine. Keeping it small means
24 //! a future visual refresh only has to touch the constants here.
25
26 use codewhale_localization::Locale;
27
28 /// Tool family — the verb the agent is performing. Used to pick a glyph
29 /// and label for the card header.
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 pub enum ToolFamily {
32 /// Reads, listings, exploration. `▷ read`.
33 Read,
34 /// Edits, patches, writes. `◆ patch`.
35 Patch,
36 /// Shell, child processes. `▶ run`.
37 Run,
38 /// Grep, fuzzy file search, web search. `⌕ find`.
39 Find,
40 /// Single sub-agent dispatch. `◐ agent`.
41 Delegate,
42 /// Multi-agent fanout dispatch (rlm). `⋮⋮ fanout`.
43 Fanout,
44 /// Recursive language model work. `⋮⋮ rlm`.
45 Rlm,
46 /// Verification gates, tests, and validators. `✓ verify`.
47 Verify,
48 /// Reasoning / chain-of-thought. `… think`. Reasoning has its own
49 /// render path (`render_thinking` in `history.rs`); the family is
50 /// declared here for completeness so any future code that reaches for
51 /// it has the matching glyph + label vocabulary.
52 Think,
53 /// Anything we don't have a family glyph for yet — falls back to a
54 /// neutral bullet so the card still renders cleanly.
55 Generic,
56 }
57
58 /// Map a legacy tool-header title string (the value passed to
59 /// `render_tool_header`) to a family. Anything unrecognised falls back to
60 /// [`ToolFamily::Generic`] so cards still render — they just lose the
61 /// verb-glyph treatment until the family is added here.
62 #[must_use]
63 pub fn tool_family_for_title(title: &str) -> ToolFamily {
64 match title {
65 "Shell" => ToolFamily::Run,
66 "Patch" | "Diff" => ToolFamily::Patch,
67 "Workspace" | "Image" => ToolFamily::Read,
68 "Search" => ToolFamily::Find,
69 "Plan" | "Legacy plan" | "Review" => ToolFamily::Generic,
70 _ => ToolFamily::Generic,
71 }
72 }
73
74 /// Map an arbitrary tool name (as exposed to the model — e.g. `read_file`,
75 /// `apply_patch`, `agent`) to a family. Used by `GenericToolCell`
76 /// where the `tool_family_for_title` shortcut isn't enough because every
77 /// generic cell shares the title `"Tool"`.
78 #[must_use]
79 pub fn tool_family_for_name(name: &str) -> ToolFamily {
80 match name {
81 "read_file" | "list_dir" | "view_image" | "git_status" | "git_diff" | "git_log"
82 | "git_show" | "git_blame" | "git_commit_plan" => ToolFamily::Read,
83 "edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch,
84 "exec_shell"
85 | "exec_shell_wait"
86 | "exec_shell_interact"
87 | "exec_shell_cancel"
88 | "task_shell_start"
89 | "task_shell_wait"
90 | "start_registry_mcp_server" => ToolFamily::Run,
91 "grep_files" | "file_search" | "web_search" | "fetch_url" | "registry_sync" => {
92 ToolFamily::Find
93 }
94 "agent" => ToolFamily::Delegate,
95 "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => ToolFamily::Rlm,
96 "run_tests"
97 | "run_verifiers"
98 | "task_gate_run"
99 | "validate_data"
100 | "wait_for_dev_server" => ToolFamily::Verify,
101 // Workflow runs are multi-child activity; reuse fanout glyph so the
102 // compact history card (#4122) shares visual vocabulary with direct
103 // multi-agent cards rather than the neutral generic bullet.
104 "workflow" => ToolFamily::Fanout,
105 _ => ToolFamily::Generic,
106 }
107 }
108
109 /// Resolve an action-parameterized model tool before assigning its visual
110 /// family. Legacy names pass through unchanged.
111 #[cfg(test)]
112 #[must_use]
113 pub fn tool_family_for_call(name: &str, input: &serde_json::Value) -> ToolFamily {
114 tool_family_for_name(crate::tools::canonical_action::canonical_action_alias(
115 name, input,
116 ))
117 }
118
119 /// User-facing label for an arbitrary tool name. Known tools collapse to the
120 /// semantic verb; unknown tools keep their exact name for debugging.
121 #[cfg(test)]
122 #[must_use]
123 fn tool_display_label_for_name(name: &str) -> String {
124 let family = tool_family_for_name(name);
125 if matches!(family, ToolFamily::Generic) {
126 name.to_string()
127 } else {
128 family_label(family).to_string()
129 }
130 }
131
132 fn family_message_id(family: ToolFamily) -> codewhale_localization::MessageId {
133 match family {
134 ToolFamily::Read => codewhale_localization::MessageId::ToolFamilyRead,
135 ToolFamily::Patch => codewhale_localization::MessageId::ToolFamilyPatch,
136 ToolFamily::Run => codewhale_localization::MessageId::ToolFamilyRun,
137 ToolFamily::Find => codewhale_localization::MessageId::ToolFamilyFind,
138 ToolFamily::Delegate => codewhale_localization::MessageId::ToolFamilyDelegate,
139 ToolFamily::Fanout => codewhale_localization::MessageId::ToolFamilyFanout,
140 ToolFamily::Rlm => codewhale_localization::MessageId::ToolFamilyRlm,
141 ToolFamily::Verify => codewhale_localization::MessageId::ToolFamilyVerify,
142 ToolFamily::Think => codewhale_localization::MessageId::ToolFamilyThink,
143 ToolFamily::Generic => codewhale_localization::MessageId::ToolFamilyGeneric,
144 }
145 }
146
147 /// Compact activity/status label for arbitrary tool names. Known built-ins use
148 /// the semantic verb; unknown tools keep the `tool NAME` form.
149 #[must_use]
150 pub fn tool_activity_label_for_name(name: &str, locale: Locale) -> String {
151 let family = tool_family_for_name(name);
152 let mid = family_message_id(family);
153 if matches!(family, ToolFamily::Generic) {
154 format!("{} {name}", codewhale_localization::tr(locale, mid))
155 } else {
156 codewhale_localization::tr(locale, mid).to_string()
157 }
158 }
159
160 /// Build a compact semantic summary for a tool header from the public tool
161 /// name and the already-sanitized argument summary.
162 #[must_use]
163 pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) -> Option<String> {
164 let family = tool_family_for_name(name);
165 let summary = input_summary
166 .map(str::trim)
167 .filter(|summary| !summary.is_empty());
168
169 let preferred_keys = match family {
170 ToolFamily::Read | ToolFamily::Patch => ["path", "file", "target", "content"].as_slice(),
171 ToolFamily::Run => ["command", "cmd", "script"].as_slice(),
172 ToolFamily::Find => ["query", "pattern", "path", "scope"].as_slice(),
173 ToolFamily::Delegate | ToolFamily::Fanout | ToolFamily::Rlm => {
174 ["prompt", "task", "model"].as_slice()
175 }
176 ToolFamily::Verify => ["profile", "level", "command", "args", "path"].as_slice(),
177 ToolFamily::Think | ToolFamily::Generic => {
178 ["query", "path", "command", "prompt"].as_slice()
179 }
180 };
181
182 let selected_summary = summary.and_then(|summary| {
183 for key in preferred_keys {
184 if let Some(value) = summary_value(summary, key) {
185 return Some(value);
186 }
187 }
188
189 if summary_is_noisy_control_only(summary) {
190 None
191 } else {
192 Some(summary.to_string())
193 }
194 });
195
196 if should_show_tool_name_in_header(name, family) {
197 let tool_name = name.trim();
198 if tool_name.is_empty() {
199 return selected_summary;
200 }
201 return Some(match selected_summary {
202 Some(summary) if summary != tool_name => format!("{tool_name} · {summary}"),
203 _ => tool_name.to_string(),
204 });
205 }
206
207 selected_summary
208 }
209
210 fn summary_value(summary: &str, key: &str) -> Option<String> {
211 for part in summary.split(", ") {
212 let Some((part_key, value)) = part.split_once(':') else {
213 continue;
214 };
215 if part_key.trim() == key {
216 let value = value.trim();
217 if !value.is_empty() {
218 return Some(value.to_string());
219 }
220 }
221 }
222 None
223 }
224
225 fn should_show_tool_name_in_header(name: &str, family: ToolFamily) -> bool {
226 (matches!(family, ToolFamily::Generic) && !is_known_metadata_tool_name(name))
227 || matches!(name, "git_log" | "git_show" | "git_blame")
228 }
229
230 fn is_known_metadata_tool_name(name: &str) -> bool {
231 matches!(
232 name,
233 "update_plan"
234 | "work_update"
235 | "todo_write"
236 | "todo_add"
237 | "todo_update"
238 | "checklist_write"
239 | "checklist_add"
240 | "checklist_update"
241 | "checklist_list"
242 )
243 }
244
245 fn summary_is_noisy_control_only(summary: &str) -> bool {
246 let mut saw_control = false;
247 for part in summary.split(", ") {
248 let Some((key, value)) = part.split_once(':') else {
249 return false;
250 };
251 if value.trim().is_empty() {
252 continue;
253 }
254 if !is_noisy_summary_key(key.trim()) {
255 return false;
256 }
257 saw_control = true;
258 }
259 saw_control
260 }
261
262 fn is_noisy_summary_key(key: &str) -> bool {
263 matches!(
264 key,
265 "limit"
266 | "max_count"
267 | "max_output_tokens"
268 | "offset"
269 | "page"
270 | "page_size"
271 | "per_page"
272 | "response_length"
273 | "timeout_ms"
274 | "yield_time_ms"
275 )
276 }
277
278 /// The verb glyph for a family. Single grapheme so the header layout math
279 /// in `render_tool_header` stays simple (one cell wide).
280 #[must_use]
281 pub fn family_glyph(family: ToolFamily) -> &'static str {
282 match family {
283 ToolFamily::Read => "\u{25B7}", // ▷
284 ToolFamily::Patch => "\u{25C6}", // ◆
285 ToolFamily::Run => "\u{25B6}", // ▶
286 ToolFamily::Find => "\u{2315}", // ⌕
287 ToolFamily::Delegate => "\u{25D0}", // ◐
288 ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
289 ToolFamily::Rlm => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
290 ToolFamily::Verify => "\u{2713}",
291 ToolFamily::Think => "\u{2026}", // …
292 ToolFamily::Generic => "\u{2022}", // •
293 }
294 }
295
296 /// The short verb label for a family — appears in card headers next to the
297 /// glyph. Lowercased on purpose; the verb-glyph + label is the new card
298 /// title vocabulary.
299 #[must_use]
300 pub fn family_label(family: ToolFamily) -> &'static str {
301 match family {
302 ToolFamily::Read => "read",
303 ToolFamily::Patch => "patch",
304 ToolFamily::Run => "run",
305 ToolFamily::Find => "find",
306 ToolFamily::Delegate => "agent",
307 ToolFamily::Fanout => "fanout",
308 ToolFamily::Rlm => "rlm",
309 ToolFamily::Verify => "verify",
310 ToolFamily::Think => "think",
311 ToolFamily::Generic => "tool",
312 }
313 }
314
315 /// Position of a line within a multi-line card — drives the left-rail
316 /// glyph so the box reads as a contiguous group from top to bottom.
317 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
318 #[allow(dead_code)] // wired by future card-refactor follow-ups
319 pub enum CardRail {
320 /// First line of the card — the header. `╭`.
321 Top,
322 /// Any middle line — body content. `│`.
323 Middle,
324 /// Last line of the card. `╰`.
325 Bottom,
326 /// Single-line card — no rail at all.
327 Single,
328 }
329
330 /// Map a [`CardRail`] position to its rail glyph. Returned as a `&str`
331 /// because callers paste it into a span.
332 #[must_use]
333 #[allow(dead_code)] // wired by future card-refactor follow-ups
334 pub fn rail_glyph(rail: CardRail) -> &'static str {
335 match rail {
336 CardRail::Top => "\u{256D}", // ╭
337 CardRail::Middle => "\u{2502}", // │
338 CardRail::Bottom => "\u{2570}", // ╰
339 CardRail::Single => "",
340 }
341 }
342
343 #[cfg(test)]
344 mod tests {
345 use super::{
346 CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_activity_label_for_name,
347 tool_display_label_for_name, tool_family_for_call, tool_family_for_name,
348 tool_family_for_title, tool_header_summary_for_name,
349 };
350 use codewhale_localization::{Locale, MessageId, tr};
351 use serde_json::json;
352
353 #[test]
354 fn legacy_titles_route_to_expected_families() {
355 assert_eq!(tool_family_for_title("Shell"), ToolFamily::Run);
356 assert_eq!(tool_family_for_title("Patch"), ToolFamily::Patch);
357 assert_eq!(tool_family_for_title("Workspace"), ToolFamily::Read);
358 assert_eq!(tool_family_for_title("Search"), ToolFamily::Find);
359 assert_eq!(tool_family_for_title("Diff"), ToolFamily::Patch);
360 assert_eq!(tool_family_for_title("Plan"), ToolFamily::Generic);
361 assert_eq!(tool_family_for_title("Legacy plan"), ToolFamily::Generic);
362 assert_eq!(tool_family_for_title("unknown title"), ToolFamily::Generic);
363 }
364
365 #[test]
366 fn tool_names_route_to_families_by_verb() {
367 assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read);
368 assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch);
369 assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run);
370 assert_eq!(tool_family_for_name("task_shell_start"), ToolFamily::Run);
371 assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find);
372 assert_eq!(tool_family_for_name("git_log"), ToolFamily::Read);
373 assert_eq!(tool_family_for_name("agent"), ToolFamily::Delegate);
374 assert_eq!(tool_family_for_name("rlm_eval"), ToolFamily::Rlm);
375 assert_eq!(tool_family_for_name("run_verifiers"), ToolFamily::Verify);
376 assert_eq!(
377 tool_family_for_name("wait_for_dev_server"),
378 ToolFamily::Verify
379 );
380 assert_eq!(
381 tool_family_for_name("totally_new_tool"),
382 ToolFamily::Generic
383 );
384 }
385
386 #[test]
387 fn canonical_actions_route_to_the_same_families_as_legacy_aliases() {
388 let cases = [
389 ("Bash", "run", ToolFamily::Run),
390 ("Bash", "wait", ToolFamily::Run),
391 ("Bash", "interact", ToolFamily::Run),
392 ("Bash", "cancel", ToolFamily::Run),
393 ("File", "read", ToolFamily::Read),
394 ("File", "list", ToolFamily::Read),
395 ("File", "search_name", ToolFamily::Find),
396 ("File", "search_content", ToolFamily::Find),
397 ("File", "write", ToolFamily::Patch),
398 ("File", "edit", ToolFamily::Patch),
399 ("File", "patch", ToolFamily::Patch),
400 ("Git", "status", ToolFamily::Read),
401 ("Git", "diff", ToolFamily::Read),
402 ("Git", "log", ToolFamily::Read),
403 ("Git", "show", ToolFamily::Read),
404 ("Git", "blame", ToolFamily::Read),
405 ("Git", "commit_plan", ToolFamily::Read),
406 ("Run", "tests", ToolFamily::Verify),
407 ("Run", "verifiers", ToolFamily::Verify),
408 ("Web", "search", ToolFamily::Find),
409 ("Web", "fetch", ToolFamily::Find),
410 ("Web", "wait", ToolFamily::Verify),
411 ];
412
413 for (family, action, expected) in cases {
414 assert_eq!(
415 tool_family_for_call(family, &json!({"action": action})),
416 expected,
417 "{family}.{action}"
418 );
419 }
420 }
421
422 #[test]
423 fn tool_display_label_collapses_known_tools_to_user_verbs() {
424 assert_eq!(tool_display_label_for_name("exec_shell"), "run");
425 assert_eq!(tool_display_label_for_name("run_verifiers"), "verify");
426 assert_eq!(tool_display_label_for_name("file_search"), "find");
427 assert_eq!(
428 tool_display_label_for_name("future_private_tool"),
429 "future_private_tool"
430 );
431
432 assert_eq!(
433 tool_activity_label_for_name("exec_shell", Locale::En),
434 "run"
435 );
436 assert_eq!(
437 tool_activity_label_for_name("run_verifiers", Locale::En),
438 "verify"
439 );
440 assert_eq!(
441 tool_activity_label_for_name("future_private_tool", Locale::En),
442 "tool future_private_tool"
443 );
444 }
445
446 #[test]
447 fn tool_header_summary_prefers_family_specific_arguments() {
448 assert_eq!(
449 tool_header_summary_for_name("read_file", Some("path: src/main.rs, limit: 20"))
450 .as_deref(),
451 Some("src/main.rs")
452 );
453 assert_eq!(
454 tool_header_summary_for_name("exec_shell", Some("command: cargo test, cwd: /repo"))
455 .as_deref(),
456 Some("cargo test")
457 );
458 assert_eq!(
459 tool_header_summary_for_name("grep_files", Some("pattern: TODO, path: crates"))
460 .as_deref(),
461 Some("TODO")
462 );
463 assert_eq!(
464 tool_header_summary_for_name("run_verifiers", Some("profile: auto, level: quick"))
465 .as_deref(),
466 Some("auto")
467 );
468 assert_eq!(
469 tool_header_summary_for_name("unknown", Some("alpha: beta")).as_deref(),
470 Some("unknown · alpha: beta")
471 );
472 assert_eq!(
473 tool_header_summary_for_name("git_log", Some("max_count: 15")).as_deref(),
474 Some("git_log")
475 );
476 assert_eq!(
477 tool_header_summary_for_name("future_private_tool", Some("max_count: 15")).as_deref(),
478 Some("future_private_tool")
479 );
480 assert_eq!(
481 tool_header_summary_for_name("future_private_tool", None).as_deref(),
482 Some("future_private_tool")
483 );
484 assert_eq!(
485 tool_header_summary_for_name("todo_write", Some("items: <2 items>")).as_deref(),
486 Some("items: <2 items>")
487 );
488 }
489
490 #[test]
491 fn each_family_has_a_glyph_and_label() {
492 // Smoke test — surface accidental empties from a future refactor.
493 for family in [
494 ToolFamily::Read,
495 ToolFamily::Patch,
496 ToolFamily::Run,
497 ToolFamily::Find,
498 ToolFamily::Delegate,
499 ToolFamily::Fanout,
500 ToolFamily::Rlm,
501 ToolFamily::Verify,
502 ToolFamily::Think,
503 ToolFamily::Generic,
504 ] {
505 assert!(
506 !family_glyph(family).is_empty(),
507 "family {family:?} has empty glyph",
508 );
509 assert!(
510 !family_label(family).is_empty(),
511 "family {family:?} has empty label",
512 );
513 }
514 }
515
516 #[test]
517 fn card_rail_glyphs_form_a_box() {
518 assert_eq!(rail_glyph(CardRail::Top), "\u{256D}");
519 assert_eq!(rail_glyph(CardRail::Middle), "\u{2502}");
520 assert_eq!(rail_glyph(CardRail::Bottom), "\u{2570}");
521 assert!(rail_glyph(CardRail::Single).is_empty());
522 }
523
524 #[test]
525 fn tool_family_labels_localized_no_english_leak() {
526 let checks: &[(MessageId, &str, &str)] = &[
527 (MessageId::ToolFamilyRead, "read", "đọc,读,読,读取,ler,leer"),
528 (
529 MessageId::ToolFamilyPatch,
530 "patch",
531 "vá,補,パ,修补,corrigir,parchear",
532 ),
533 (
534 MessageId::ToolFamilyRun,
535 "run",
536 "chạy,執,実,运行,executar,ejecutar",
537 ),
538 (
539 MessageId::ToolFamilyFind,
540 "find",
541 "tìm,搜,検,搜索,buscar,buscar",
542 ),
543 (
544 MessageId::ToolFamilyDelegate,
545 "agent",
546 "ủy,委,委,委,agente,agente",
547 ),
548 (
549 MessageId::ToolFamilyVerify,
550 "verify",
551 "xác minh,驗,検,验,verificar,verificar",
552 ),
553 (
554 MessageId::ToolFamilyThink,
555 "think",
556 "suy nghĩ,思,思,思,pensar,pensar",
557 ),
558 (
559 MessageId::ToolFamilyGeneric,
560 "tool",
561 "công cụ,工具,ツール,工具,ferramenta,herramienta",
562 ),
563 ];
564 for locale in [
565 Locale::Ja,
566 Locale::ZhHans,
567 Locale::ZhHant,
568 Locale::PtBr,
569 Locale::Es419,
570 Locale::Vi,
571 Locale::Ca,
572 Locale::De,
573 Locale::Fr,
574 Locale::Id,
575 Locale::Hi,
576 Locale::Ru,
577 Locale::Uk,
578 ] {
579 for (id, eng, _) in checks {
580 if *id == MessageId::ToolFamilyDelegate
581 && matches!(locale, Locale::Ca | Locale::De | Locale::Fr)
582 {
583 continue;
584 }
585 let msg = tr(locale, *id);
586 assert!(
587 !msg.eq_ignore_ascii_case(eng),
588 "{} leaked exact English '{}' for '{:?}': {msg}",
589 locale.tag(),
590 eng,
591 id
592 );
593 }
594 }
595 }
596
597 #[test]
598 fn tool_family_activity_label_localized_no_english_leak() {
599 let known = [
600 "exec_shell",
601 "read_file",
602 "apply_patch",
603 "grep_files",
604 "run_verifiers",
605 ];
606 let english_labels = ["run", "read", "patch", "find", "verify"];
607 for locale in [
608 Locale::Ja,
609 Locale::ZhHans,
610 Locale::ZhHant,
611 Locale::PtBr,
612 Locale::Es419,
613 Locale::Vi,
614 Locale::Ca,
615 Locale::De,
616 Locale::Fr,
617 Locale::Id,
618 Locale::Hi,
619 Locale::Ru,
620 Locale::Uk,
621 ] {
622 for (tool, eng) in known.iter().zip(english_labels.iter()) {
623 let label = tool_activity_label_for_name(tool, locale);
624 assert!(
625 !label.eq_ignore_ascii_case(eng),
626 "{} leaked English '{}' for tool '{tool}': {label}",
627 locale.tag(),
628 eng,
629 );
630 }
631 }
632 }
633 }
634
634 lines RUST