返回 DeepSeek-TUI-2026
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 (720 ms/step) reuses
7 //! the existing tool-status animation.
8 //!
9 //! This module owns:
10 //!
11 //! - [`ToolFamily`] — the seven canonical 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 /// Tool family — the verb the agent is performing. Used to pick a glyph
27 /// and label for the card header.
28 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 pub enum ToolFamily {
30 /// Reads, listings, exploration. `▷ read`.
31 Read,
32 /// Edits, patches, writes. `◆ patch`.
33 Patch,
34 /// Shell, child processes. `▶ run`.
35 Run,
36 /// Grep, fuzzy file search, web search. `⌕ find`.
37 Find,
38 /// Single sub-agent dispatch. `◐ delegate`.
39 Delegate,
40 /// Multi-agent fanout dispatch (rlm). `⋮⋮ fanout`.
41 Fanout,
42 /// Recursive language model work. `⋮⋮ rlm`.
43 Rlm,
44 /// Reasoning / chain-of-thought. `… think`. Reasoning has its own
45 /// render path (`render_thinking` in `history.rs`); the family is
46 /// declared here for completeness so any future code that reaches for
47 /// it has the matching glyph + label vocabulary.
48 #[allow(dead_code)]
49 Think,
50 /// Anything we don't have a family glyph for yet — falls back to a
51 /// neutral bullet so the card still renders cleanly.
52 Generic,
53 }
54
55 /// Map a legacy tool-header title string (the value passed to
56 /// `render_tool_header`) to a family. Anything unrecognised falls back to
57 /// [`ToolFamily::Generic`] so cards still render — they just lose the
58 /// verb-glyph treatment until the family is added here.
59 #[must_use]
60 pub fn tool_family_for_title(title: &str) -> ToolFamily {
61 match title {
62 "Shell" => ToolFamily::Run,
63 "Patch" | "Diff" => ToolFamily::Patch,
64 "Workspace" | "Image" => ToolFamily::Read,
65 "Search" => ToolFamily::Find,
66 "Plan" | "Review" => ToolFamily::Generic,
67 _ => ToolFamily::Generic,
68 }
69 }
70
71 /// Map an arbitrary tool name (as exposed to the model — e.g. `read_file`,
72 /// `apply_patch`, `agent_spawn`) to a family. Used by `GenericToolCell`
73 /// where the `tool_family_for_title` shortcut isn't enough because every
74 /// generic cell shares the title `"Tool"`.
75 #[must_use]
76 pub fn tool_family_for_name(name: &str) -> ToolFamily {
77 match name {
78 "read_file" | "list_dir" | "view_image" => ToolFamily::Read,
79 "edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch,
80 "exec_shell" | "exec_shell_wait" | "exec_shell_interact" => ToolFamily::Run,
81 "grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find,
82 "agent_spawn" => ToolFamily::Delegate,
83 "rlm" => ToolFamily::Rlm,
84 _ => ToolFamily::Generic,
85 }
86 }
87
88 /// Build a compact semantic summary for a tool header from the public tool
89 /// name and the already-sanitized argument summary.
90 #[must_use]
91 pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) -> Option<String> {
92 let summary = input_summary?.trim();
93 if summary.is_empty() {
94 return None;
95 }
96
97 let preferred_keys = match tool_family_for_name(name) {
98 ToolFamily::Read | ToolFamily::Patch => ["path", "file", "target", "content"].as_slice(),
99 ToolFamily::Run => ["command", "cmd", "script"].as_slice(),
100 ToolFamily::Find => ["query", "pattern", "path", "scope"].as_slice(),
101 ToolFamily::Delegate | ToolFamily::Fanout | ToolFamily::Rlm => {
102 ["prompt", "task", "model"].as_slice()
103 }
104 ToolFamily::Think | ToolFamily::Generic => {
105 ["query", "path", "command", "prompt"].as_slice()
106 }
107 };
108
109 for key in preferred_keys {
110 if let Some(value) = summary_value(summary, key) {
111 return Some(value);
112 }
113 }
114
115 Some(summary.to_string())
116 }
117
118 fn summary_value(summary: &str, key: &str) -> Option<String> {
119 for part in summary.split(", ") {
120 let Some((part_key, value)) = part.split_once(':') else {
121 continue;
122 };
123 if part_key.trim() == key {
124 let value = value.trim();
125 if !value.is_empty() {
126 return Some(value.to_string());
127 }
128 }
129 }
130 None
131 }
132
133 /// The verb glyph for a family. Single grapheme so the header layout math
134 /// in `render_tool_header` stays simple (one cell wide).
135 #[must_use]
136 pub fn family_glyph(family: ToolFamily) -> &'static str {
137 match family {
138 ToolFamily::Read => "\u{25B7}", // ▷
139 ToolFamily::Patch => "\u{25C6}", // ◆
140 ToolFamily::Run => "\u{25B6}", // ▶
141 ToolFamily::Find => "\u{2315}", // ⌕
142 ToolFamily::Delegate => "\u{25D0}", // ◐
143 ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
144 ToolFamily::Rlm => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells)
145 ToolFamily::Think => "\u{2026}", // …
146 ToolFamily::Generic => "\u{2022}", // •
147 }
148 }
149
150 /// The short verb label for a family — appears in card headers next to the
151 /// glyph. Lowercased on purpose; the verb-glyph + label is the new card
152 /// title vocabulary.
153 #[must_use]
154 pub fn family_label(family: ToolFamily) -> &'static str {
155 match family {
156 ToolFamily::Read => "read",
157 ToolFamily::Patch => "patch",
158 ToolFamily::Run => "run",
159 ToolFamily::Find => "find",
160 ToolFamily::Delegate => "delegate",
161 ToolFamily::Fanout => "fanout",
162 ToolFamily::Rlm => "rlm",
163 ToolFamily::Think => "think",
164 ToolFamily::Generic => "tool",
165 }
166 }
167
168 /// Position of a line within a multi-line card — drives the left-rail
169 /// glyph so the box reads as a contiguous group from top to bottom.
170 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
171 #[allow(dead_code)] // wired by future card-refactor follow-ups
172 pub enum CardRail {
173 /// First line of the card — the header. `╭`.
174 Top,
175 /// Any middle line — body content. `│`.
176 Middle,
177 /// Last line of the card. `╰`.
178 Bottom,
179 /// Single-line card — no rail at all.
180 Single,
181 }
182
183 /// Map a [`CardRail`] position to its rail glyph. Returned as a `&str`
184 /// because callers paste it into a span.
185 #[must_use]
186 #[allow(dead_code)] // wired by future card-refactor follow-ups
187 pub fn rail_glyph(rail: CardRail) -> &'static str {
188 match rail {
189 CardRail::Top => "\u{256D}", // ╭
190 CardRail::Middle => "\u{2502}", // │
191 CardRail::Bottom => "\u{2570}", // ╰
192 CardRail::Single => "",
193 }
194 }
195
196 #[cfg(test)]
197 mod tests {
198 use super::{
199 CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name,
200 tool_family_for_title, tool_header_summary_for_name,
201 };
202
203 #[test]
204 fn legacy_titles_route_to_expected_families() {
205 assert_eq!(tool_family_for_title("Shell"), ToolFamily::Run);
206 assert_eq!(tool_family_for_title("Patch"), ToolFamily::Patch);
207 assert_eq!(tool_family_for_title("Workspace"), ToolFamily::Read);
208 assert_eq!(tool_family_for_title("Search"), ToolFamily::Find);
209 assert_eq!(tool_family_for_title("Diff"), ToolFamily::Patch);
210 assert_eq!(tool_family_for_title("Plan"), ToolFamily::Generic);
211 assert_eq!(tool_family_for_title("unknown title"), ToolFamily::Generic);
212 }
213
214 #[test]
215 fn tool_names_route_to_families_by_verb() {
216 assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read);
217 assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch);
218 assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run);
219 assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find);
220 assert_eq!(tool_family_for_name("agent_spawn"), ToolFamily::Delegate);
221 assert_eq!(tool_family_for_name("rlm"), ToolFamily::Rlm);
222 assert_eq!(
223 tool_family_for_name("totally_new_tool"),
224 ToolFamily::Generic
225 );
226 }
227
228 #[test]
229 fn tool_header_summary_prefers_family_specific_arguments() {
230 assert_eq!(
231 tool_header_summary_for_name("read_file", Some("path: src/main.rs, limit: 20"))
232 .as_deref(),
233 Some("src/main.rs")
234 );
235 assert_eq!(
236 tool_header_summary_for_name("exec_shell", Some("command: cargo test, cwd: /repo"))
237 .as_deref(),
238 Some("cargo test")
239 );
240 assert_eq!(
241 tool_header_summary_for_name("grep_files", Some("pattern: TODO, path: crates"))
242 .as_deref(),
243 Some("TODO")
244 );
245 assert_eq!(
246 tool_header_summary_for_name("unknown", Some("alpha: beta")).as_deref(),
247 Some("alpha: beta")
248 );
249 }
250
251 #[test]
252 fn each_family_has_a_glyph_and_label() {
253 // Smoke test — surface accidental empties from a future refactor.
254 for family in [
255 ToolFamily::Read,
256 ToolFamily::Patch,
257 ToolFamily::Run,
258 ToolFamily::Find,
259 ToolFamily::Delegate,
260 ToolFamily::Fanout,
261 ToolFamily::Rlm,
262 ToolFamily::Think,
263 ToolFamily::Generic,
264 ] {
265 assert!(
266 !family_glyph(family).is_empty(),
267 "family {family:?} has empty glyph",
268 );
269 assert!(
270 !family_label(family).is_empty(),
271 "family {family:?} has empty label",
272 );
273 }
274 }
275
276 #[test]
277 fn card_rail_glyphs_form_a_box() {
278 assert_eq!(rail_glyph(CardRail::Top), "\u{256D}");
279 assert_eq!(rail_glyph(CardRail::Middle), "\u{2502}");
280 assert_eq!(rail_glyph(CardRail::Bottom), "\u{2570}");
281 assert!(rail_glyph(CardRail::Single).is_empty());
282 }
283 }
284
284 lines RUST