返回 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 crate::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. `◐ delegate`.
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 #[allow(dead_code)]
53 Think,
54 /// Anything we don't have a family glyph for yet — falls back to a
55 /// neutral bullet so the card still renders cleanly.
56 Generic,
57 }
58
59 /// Map a legacy tool-header title string (the value passed to
60 /// `render_tool_header`) to a family. Anything unrecognised falls back to
61 /// [`ToolFamily::Generic`] so cards still render — they just lose the
62 /// verb-glyph treatment until the family is added here.
63 #[must_use]
64 pub fn tool_family_for_title(title: &str) -> ToolFamily {
65 match title {
66 "Shell" => ToolFamily::Run,
67 "Patch" | "Diff" => ToolFamily::Patch,
68 "Workspace" | "Image" => ToolFamily::Read,
69 "Search" => ToolFamily::Find,
70 "Plan" | "Legacy plan" | "Review" => ToolFamily::Generic,
71 _ => ToolFamily::Generic,
72 }
73 }
74
75 /// Map an arbitrary tool name (as exposed to the model — e.g. `read_file`,
76 /// `apply_patch`, `agent`) to a family. Used by `GenericToolCell`
77 /// where the `tool_family_for_title` shortcut isn't enough because every
78 /// generic cell shares the title `"Tool"`.
79 #[must_use]
80 pub fn tool_family_for_name(name: &str) -> ToolFamily {
81 match name {
82 "read_file" | "list_dir" | "view_image" | "git_status" | "git_diff" | "git_log"
83 | "git_show" | "git_blame" => ToolFamily::Read,
84 "edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch,
85 "exec_shell"
86 | "exec_shell_wait"
87 | "exec_shell_interact"
88 | "exec_shell_cancel"
89 | "task_shell_start"
90 | "task_shell_wait"
91 | "start_registry_mcp_server" => ToolFamily::Run,
92 "grep_files" | "file_search" | "web_search" | "fetch_url" | "registry_sync" => {
93 ToolFamily::Find
94 }
95 "agent" => ToolFamily::Delegate,
96 "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => ToolFamily::Rlm,
97 "run_tests"
98 | "run_verifiers"
99 | "task_gate_run"
100 | "validate_data"
101 | "wait_for_dev_server" => ToolFamily::Verify,
102 // Workflow runs are multi-child activity; reuse fanout glyph so the
103 // compact history card (#4122) shares visual vocabulary with direct
104 // multi-agent cards rather than the neutral generic bullet.
105 "workflow" => ToolFamily::Fanout,
106 _ => ToolFamily::Generic,
107 }
108 }
109
110 /// Resolve an action-parameterized model tool before assigning its visual
111 /// family. Legacy names pass through unchanged.
112 #[cfg(test)]
113 #[must_use]
114 pub fn tool_family_for_call(name: &str, input: &serde_json::Value) -> ToolFamily {
115 tool_family_for_name(crate::tools::canonical_action::canonical_action_alias(
116 name, input,
117 ))
118 }
119
120 /// User-facing label for an arbitrary tool name. Known tools collapse to the
121 /// semantic verb; unknown tools keep their exact name for debugging.
122 #[cfg(test)]
123 #[must_use]
124 fn tool_display_label_for_name(name: &str) -> String {
125 let family = tool_family_for_name(name);
126 if matches!(family, ToolFamily::Generic) {
127 name.to_string()
128 } else {
129 family_label(family).to_string()
130 }
131 }
132
133 fn family_message_id(family: ToolFamily) -> crate::localization::MessageId {
134 match family {
135 ToolFamily::Read => crate::localization::MessageId::ToolFamilyRead,
136 ToolFamily::Patch => crate::localization::MessageId::ToolFamilyPatch,
137 ToolFamily::Run => crate::localization::MessageId::ToolFamilyRun,
138 ToolFamily::Find => crate::localization::MessageId::ToolFamilyFind,
139 ToolFamily::Delegate => crate::localization::MessageId::ToolFamilyDelegate,
140 ToolFamily::Fanout => crate::localization::MessageId::ToolFamilyFanout,
141 ToolFamily::Rlm => crate::localization::MessageId::ToolFamilyRlm,
142 ToolFamily::Verify => crate::localization::MessageId::ToolFamilyVerify,
143 ToolFamily::Think => crate::localization::MessageId::ToolFamilyThink,
144 ToolFamily::Generic => crate::localization::MessageId::ToolFamilyGeneric,
145 }
146 }
147
148 /// Compact activity/status label for arbitrary tool names. Known built-ins use
149 /// the semantic verb; unknown tools keep the `tool NAME` form.
150 #[must_use]
151 pub fn tool_activity_label_for_name(name: &str, locale: Locale) -> String {
152 let family = tool_family_for_name(name);
153 let mid = family_message_id(family);
154 if matches!(family, ToolFamily::Generic) {
155 format!("{} {name}", crate::localization::tr(locale, mid))
156 } else {
157 crate::localization::tr(locale, mid).to_string()
158 }
159 }
160
161 /// Build a compact semantic summary for a tool header from the public tool
162 /// name and the already-sanitized argument summary.
163 #[must_use]
164 pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) -> Option<String> {
165 let family = tool_family_for_name(name);
166 let summary = input_summary
167 .map(str::trim)
168 .filter(|summary| !summary.is_empty());
169
170 let preferred_keys = match family {
171 ToolFamily::Read | ToolFamily::Patch => ["path", "file", "target", "content"].as_slice(),
172 ToolFamily::Run => ["command", "cmd", "script"].as_slice(),
173 ToolFamily::Find => ["query", "pattern", "path", "scope"].as_slice(),
174 ToolFamily::Delegate | ToolFamily::Fanout | ToolFamily::Rlm => {
175 ["prompt", "task", "model"].as_slice()
176 }
177 ToolFamily::Verify => ["profile", "level", "command", "args", "path"].as_slice(),
178 ToolFamily::Think | ToolFamily::Generic => {
179 ["query", "path", "command", "prompt"].as_slice()
180 }
181 };
182
183 let selected_summary = summary.and_then(|summary| {
184 for key in preferred_keys {
185 if let Some(value) = summary_value(summary, key) {
186 return Some(value);
187 }
188 }
189
190 if summary_is_noisy_control_only(summary) {
191 None
192 } else {
193 Some(summary.to_string())
194 }
195 });
196
197 if should_show_tool_name_in_header(name, family) {
198 let tool_name = name.trim();
199 if tool_name.is_empty() {
200 return selected_summary;
201 }
202 return Some(match selected_summary {
203 Some(summary) if summary != tool_name => format!("{tool_name} · {summary}"),
204 _ => tool_name.to_string(),
205 });
206 }
207
208 selected_summary
209 }
210
211 fn summary_value(summary: &str, key: &str) -> Option<String> {
212 for part in summary.split(", ") {
213 let Some((part_key, value)) = part.split_once(':') else {
214 continue;
215 };
216 if part_key.trim() == key {
217 let value = value.trim();
218 if !value.is_empty() {
219 return Some(value.to_string());
220 }
221 }
222 }
223 None
224 }
225
226 fn should_show_tool_name_in_header(name: &str, family: ToolFamily) -> bool {
227 (matches!(family, ToolFamily::Generic) && !is_known_metadata_tool_name(name))
228 || matches!(name, "git_log" | "git_show" | "git_blame")
229 }
230
231 fn is_known_metadata_tool_name(name: &str) -> bool {
232 matches!(
233 name,
234 "update_plan"
235 | "work_update"
236 | "todo_write"
237 | "todo_add"
238 | "todo_update"
239 | "checklist_write"
240 | "checklist_add"
241 | "checklist_update"
242 | "checklist_list"
243 )
244 }
245
246 fn summary_is_noisy_control_only(summary: &str) -> bool {
247 let mut saw_control = false;
248 for part in summary.split(", ") {
249 let Some((key, value)) = part.split_once(':') else {
250 return false;
251 };
252 if value.trim().is_empty() {
253 continue;
254 }
255 if !is_noisy_summary_key(key.trim()) {
256 return false;
257 }
258 saw_control = true;
259 }
260 saw_control
261 }
262
263 fn is_noisy_summary_key(key: &str) -> bool {
264 matches!(
265 key,
266 "limit"
267 | "max_count"
268 | "max_output_tokens"
269 | "offset"
270 | "page"
271 | "page_size"
272 | "per_page"
273 | "response_length"
274 | "timeout_ms"
275 | "yield_time_ms"
276 )
277 }
278
279 /// The verb glyph for a family. Single grapheme so the header layout math
280 /// in `render_tool_header` stays simple (one cell wide).
281 #[must_use]
282 pub fn family_glyph(family: ToolFamily) -> &'static str {
283 match family {
284 ToolFamily::Read => "\u{25B7}", // ▷
285 ToolFamily::Patch => "\u{25C6}", // ◆
286 ToolFamily::Run => "\u{25B6}", // ▶
287 ToolFamily::Find => "\u{2315}", // ⌕
288 ToolFamily::Delegate => "\u{25D0}", // ◐
289 ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
290 ToolFamily::Rlm => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
291 ToolFamily::Verify => "\u{2713}",
292 ToolFamily::Think => "\u{2026}", // …
293 ToolFamily::Generic => "\u{2022}", // •
294 }
295 }
296
297 /// The short verb label for a family — appears in card headers next to the
298 /// glyph. Lowercased on purpose; the verb-glyph + label is the new card
299 /// title vocabulary.
300 #[must_use]
301 pub fn family_label(family: ToolFamily) -> &'static str {
302 match family {
303 ToolFamily::Read => "read",
304 ToolFamily::Patch => "patch",
305 ToolFamily::Run => "run",
306 ToolFamily::Find => "find",
307 ToolFamily::Delegate => "delegate",
308 ToolFamily::Fanout => "fanout",
309 ToolFamily::Rlm => "rlm",
310 ToolFamily::Verify => "verify",
311 ToolFamily::Think => "think",
312 ToolFamily::Generic => "tool",
313 }
314 }
315
316 /// Position of a line within a multi-line card — drives the left-rail
317 /// glyph so the box reads as a contiguous group from top to bottom.
318 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
319 #[allow(dead_code)] // wired by future card-refactor follow-ups
320 pub enum CardRail {
321 /// First line of the card — the header. `╭`.
322 Top,
323 /// Any middle line — body content. `│`.
324 Middle,
325 /// Last line of the card. `╰`.
326 Bottom,
327 /// Single-line card — no rail at all.
328 Single,
329 }
330
331 /// Map a [`CardRail`] position to its rail glyph. Returned as a `&str`
332 /// because callers paste it into a span.
333 #[must_use]
334 #[allow(dead_code)] // wired by future card-refactor follow-ups
335 pub fn rail_glyph(rail: CardRail) -> &'static str {
336 match rail {
337 CardRail::Top => "\u{256D}", // ╭
338 CardRail::Middle => "\u{2502}", // │
339 CardRail::Bottom => "\u{2570}", // ╰
340 CardRail::Single => "",
341 }
342 }
343
344 #[cfg(test)]
345 mod tests {
346 use super::{
347 CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_activity_label_for_name,
348 tool_display_label_for_name, tool_family_for_call, tool_family_for_name,
349 tool_family_for_title, tool_header_summary_for_name,
350 };
351 use crate::localization::{Locale, MessageId, tr};
352 use serde_json::json;
353
354 #[test]
355 fn legacy_titles_route_to_expected_families() {
356 assert_eq!(tool_family_for_title("Shell"), ToolFamily::Run);
357 assert_eq!(tool_family_for_title("Patch"), ToolFamily::Patch);
358 assert_eq!(tool_family_for_title("Workspace"), ToolFamily::Read);
359 assert_eq!(tool_family_for_title("Search"), ToolFamily::Find);
360 assert_eq!(tool_family_for_title("Diff"), ToolFamily::Patch);
361 assert_eq!(tool_family_for_title("Plan"), ToolFamily::Generic);
362 assert_eq!(tool_family_for_title("Legacy plan"), ToolFamily::Generic);
363 assert_eq!(tool_family_for_title("unknown title"), ToolFamily::Generic);
364 }
365
366 #[test]
367 fn tool_names_route_to_families_by_verb() {
368 assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read);
369 assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch);
370 assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run);
371 assert_eq!(tool_family_for_name("task_shell_start"), ToolFamily::Run);
372 assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find);
373 assert_eq!(tool_family_for_name("git_log"), ToolFamily::Read);
374 assert_eq!(tool_family_for_name("agent"), ToolFamily::Delegate);
375 assert_eq!(tool_family_for_name("rlm_eval"), ToolFamily::Rlm);
376 assert_eq!(tool_family_for_name("run_verifiers"), ToolFamily::Verify);
377 assert_eq!(
378 tool_family_for_name("wait_for_dev_server"),
379 ToolFamily::Verify
380 );
381 assert_eq!(
382 tool_family_for_name("totally_new_tool"),
383 ToolFamily::Generic
384 );
385 }
386
387 #[test]
388 fn canonical_actions_route_to_the_same_families_as_legacy_aliases() {
389 let cases = [
390 ("Bash", "run", ToolFamily::Run),
391 ("Bash", "wait", ToolFamily::Run),
392 ("Bash", "interact", ToolFamily::Run),
393 ("Bash", "cancel", ToolFamily::Run),
394 ("File", "read", ToolFamily::Read),
395 ("File", "list", ToolFamily::Read),
396 ("File", "search_name", ToolFamily::Find),
397 ("File", "search_content", ToolFamily::Find),
398 ("File", "write", ToolFamily::Patch),
399 ("File", "edit", ToolFamily::Patch),
400 ("File", "patch", ToolFamily::Patch),
401 ("Git", "status", ToolFamily::Read),
402 ("Git", "diff", ToolFamily::Read),
403 ("Git", "log", ToolFamily::Read),
404 ("Git", "show", ToolFamily::Read),
405 ("Git", "blame", 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 "delegate",
546 "ủy,委,委,委,delegar,delegar",
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 let msg = tr(locale, *id);
581 assert!(
582 !msg.eq_ignore_ascii_case(eng),
583 "{} leaked exact English '{}' for '{:?}': {msg}",
584 locale.tag(),
585 eng,
586 id
587 );
588 }
589 }
590 }
591
592 #[test]
593 fn tool_family_activity_label_localized_no_english_leak() {
594 let known = [
595 "exec_shell",
596 "read_file",
597 "apply_patch",
598 "grep_files",
599 "run_verifiers",
600 ];
601 let english_labels = ["run", "read", "patch", "find", "verify"];
602 for locale in [
603 Locale::Ja,
604 Locale::ZhHans,
605 Locale::ZhHant,
606 Locale::PtBr,
607 Locale::Es419,
608 Locale::Vi,
609 Locale::Ca,
610 Locale::De,
611 Locale::Fr,
612 Locale::Id,
613 Locale::Hi,
614 Locale::Ru,
615 Locale::Uk,
616 ] {
617 for (tool, eng) in known.iter().zip(english_labels.iter()) {
618 let label = tool_activity_label_for_name(tool, locale);
619 assert!(
620 !label.eq_ignore_ascii_case(eng),
621 "{} leaked English '{}' for tool '{tool}': {label}",
622 locale.tag(),
623 eng,
624 );
625 }
626 }
627 }
628 }
629
629 lines RUST