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