1#[derive(Debug, Clone, PartialEq)]
2pub enum ToolCategory {
3 FileRead,
4 FileWrite,
5 MultiEdit,
6 Directory,
7 Search,
8 Command,
9 Glob,
10 Grep,
11 WebFetch,
12 Patch,
13 Batch,
14 Snapshot,
15 Question,
16 Mcp { server: String },
17 Skill,
18 Subagent,
19 Unknown,
20}
21
22impl ToolCategory {
23 pub fn from_name(name: &str) -> Self {
24 match name {
25 "read_file" => Self::FileRead,
26 "write_file" => Self::FileWrite,
27 "multiedit" => Self::MultiEdit,
28 "list_directory" => Self::Directory,
29 "search_files" => Self::Search,
30 "run_command" => Self::Command,
31 "glob" => Self::Glob,
32 "grep" => Self::Grep,
33 "webfetch" => Self::WebFetch,
34 "apply_patch" => Self::Patch,
35 "batch" => Self::Batch,
36 "snapshot_list" | "snapshot_restore" => Self::Snapshot,
37 "question" => Self::Question,
38 "skill" => Self::Skill,
39 "subagent" | "subagent_result" => Self::Subagent,
40 other => {
41 if let Some(idx) = other.find('_') {
42 let prefix = &other[..idx];
43 if ![
44 "read", "write", "list", "search", "run", "snapshot", "apply",
45 ]
46 .contains(&prefix)
47 {
48 return Self::Mcp {
49 server: prefix.to_string(),
50 };
51 }
52 }
53 Self::Unknown
54 }
55 }
56 }
57
58 pub fn icon(&self) -> &'static str {
59 match self {
60 Self::FileRead => "\u{f15c} ",
61 Self::FileWrite => "\u{270e} ",
62 Self::MultiEdit => "\u{270e} ",
63 Self::Directory => "\u{f07b} ",
64 Self::Search => "\u{f002} ",
65 Self::Command => "\u{f120} ",
66 Self::Glob => "\u{f002} ",
67 Self::Grep => "\u{f002} ",
68 Self::WebFetch => "\u{f0ac} ",
69 Self::Patch => "\u{270e} ",
70 Self::Batch => "\u{f0c2} ",
71 Self::Snapshot => "\u{f0c2} ",
72 Self::Question => "\u{f128} ",
73 Self::Mcp { .. } => "\u{f1e6} ",
74 Self::Skill => "\u{f0eb} ",
75 Self::Subagent => "\u{f0c0} ",
76 Self::Unknown => "\u{f013} ",
77 }
78 }
79
80 pub fn label(&self) -> String {
81 match self {
82 Self::FileRead => "read".to_string(),
83 Self::FileWrite => "write".to_string(),
84 Self::MultiEdit => "edit".to_string(),
85 Self::Directory => "list".to_string(),
86 Self::Search => "search".to_string(),
87 Self::Command => "run".to_string(),
88 Self::Glob => "glob".to_string(),
89 Self::Grep => "grep".to_string(),
90 Self::WebFetch => "fetch".to_string(),
91 Self::Patch => "patch".to_string(),
92 Self::Batch => "batch".to_string(),
93 Self::Snapshot => "snapshot".to_string(),
94 Self::Question => "question".to_string(),
95 Self::Mcp { server } => format!("mcp:{}", server),
96 Self::Skill => "skill".to_string(),
97 Self::Subagent => "agent".to_string(),
98 Self::Unknown => "tool".to_string(),
99 }
100 }
101
102 pub fn intent(&self) -> &'static str {
103 match self {
104 Self::FileRead => "reading",
105 Self::FileWrite => "writing",
106 Self::MultiEdit => "editing",
107 Self::Directory => "listing",
108 Self::Search => "searching",
109 Self::Command => "running",
110 Self::Glob => "finding",
111 Self::Grep => "searching",
112 Self::WebFetch => "fetching",
113 Self::Patch => "patching",
114 Self::Batch => "running",
115 Self::Snapshot => "checking",
116 Self::Question => "asking",
117 Self::Mcp { .. } => "calling",
118 Self::Skill => "loading",
119 Self::Subagent => "delegating",
120 Self::Unknown => "running",
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
126pub struct ToolCallDisplay {
127 pub name: String,
128 pub input: String,
129 pub output: Option<String>,
130 pub is_error: bool,
131 pub category: ToolCategory,
132 pub detail: String,
133}
134
135#[derive(Debug, Clone)]
136pub enum StreamSegment {
137 Text(String),
138 ToolCall(ToolCallDisplay),
139}
140
141pub fn extract_tool_detail(name: &str, input: &str) -> String {
142 let parsed: Result<serde_json::Value, _> = serde_json::from_str(input);
143 let val = match parsed {
144 Ok(v) => v,
145 Err(_) => return String::new(),
146 };
147
148 match name {
149 "read_file" => val
150 .get("path")
151 .and_then(|v| v.as_str())
152 .map(shorten_path)
153 .unwrap_or_default(),
154 "write_file" => val
155 .get("path")
156 .and_then(|v| v.as_str())
157 .map(shorten_path)
158 .unwrap_or_default(),
159 "list_directory" => val
160 .get("path")
161 .and_then(|v| v.as_str())
162 .map(shorten_path)
163 .unwrap_or_default(),
164 "search_files" => {
165 let pattern = val.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
166 let path = val.get("path").and_then(|v| v.as_str()).unwrap_or("");
167 if path.is_empty() {
168 format!("\"{}\"", pattern)
169 } else {
170 format!("\"{}\" in {}", pattern, shorten_path(path))
171 }
172 }
173 "run_command" => val
174 .get("command")
175 .and_then(|v| v.as_str())
176 .map(|c| {
177 if c.len() > 60 {
178 format!("{}...", &c[..57])
179 } else {
180 c.to_string()
181 }
182 })
183 .unwrap_or_default(),
184 "glob" => val
185 .get("pattern")
186 .and_then(|v| v.as_str())
187 .unwrap_or("")
188 .to_string(),
189 "grep" => {
190 let pattern = val.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
191 let path = val.get("path").and_then(|v| v.as_str()).unwrap_or("");
192 if path.is_empty() {
193 format!("\"{}\"", pattern)
194 } else {
195 format!("\"{}\"; in {}", pattern, shorten_path(path))
196 }
197 }
198 "webfetch" => val
199 .get("url")
200 .and_then(|v| v.as_str())
201 .map(|u| {
202 if u.len() > 60 {
203 format!("{}...", &u[..57])
204 } else {
205 u.to_string()
206 }
207 })
208 .unwrap_or_default(),
209 "apply_patch" => {
210 let count = val
211 .get("patches")
212 .and_then(|v| v.as_array())
213 .map(|a| a.len())
214 .unwrap_or(0);
215 format!("{} patches", count)
216 }
217 "multiedit" => {
218 let path = val
219 .get("path")
220 .and_then(|v| v.as_str())
221 .map(shorten_path)
222 .unwrap_or_default();
223 let count = val
224 .get("edits")
225 .and_then(|v| v.as_array())
226 .map(|a| a.len())
227 .unwrap_or(0);
228 format!("{} ({} edits)", path, count)
229 }
230 "batch" => {
231 let count = val
232 .get("invocations")
233 .and_then(|v| v.as_array())
234 .map(|a| a.len())
235 .unwrap_or(0);
236 format!("{} tools", count)
237 }
238 "snapshot_list" => "listing changes".to_string(),
239 "snapshot_restore" => val
240 .get("path")
241 .and_then(|v| v.as_str())
242 .map(shorten_path)
243 .unwrap_or_else(|| "all files".to_string()),
244 "question" => val
245 .get("question")
246 .and_then(|v| v.as_str())
247 .map(|q| {
248 if q.len() > 50 {
249 format!("{}...", &q[..47])
250 } else {
251 q.to_string()
252 }
253 })
254 .unwrap_or_default(),
255 "skill" => val
256 .get("name")
257 .and_then(|v| v.as_str())
258 .unwrap_or("")
259 .to_string(),
260 "subagent" => {
261 let desc = val
262 .get("description")
263 .and_then(|v| v.as_str())
264 .map(|d| {
265 if d.len() > 40 {
266 format!("{}...", &d[..37])
267 } else {
268 d.to_string()
269 }
270 })
271 .unwrap_or_default();
272 let bg = val
273 .get("background")
274 .and_then(|v| v.as_bool())
275 .unwrap_or(false);
276 if bg { format!("{} (bg)", desc) } else { desc }
277 }
278 "subagent_result" => val
279 .get("id")
280 .and_then(|v| v.as_str())
281 .unwrap_or("")
282 .to_string(),
283 _ => {
284 if let Some(first_str) = val
285 .as_object()
286 .and_then(|o| o.values().find_map(|v| v.as_str().map(|s| s.to_string())))
287 {
288 if first_str.len() > 50 {
289 format!("{}...", &first_str[..47])
290 } else {
291 first_str
292 }
293 } else {
294 String::new()
295 }
296 }
297 }
298}
299
300fn shorten_path(path: &str) -> String {
301 if let Ok(home) = std::env::var("HOME")
302 && let Some(rest) = path.strip_prefix(&home)
303 {
304 return format!("~{}", rest);
305 }
306 if let Ok(cwd) = std::env::current_dir() {
307 let cwd_str = cwd.to_string_lossy();
308 if let Some(rest) = path.strip_prefix(cwd_str.as_ref()) {
309 let rest = rest.strip_prefix('/').unwrap_or(rest);
310 return if rest.is_empty() {
311 ".".to_string()
312 } else {
313 format!("./{}", rest)
314 };
315 }
316 }
317 path.to_string()
318}