返回 CodeWhale
tool_run.rs
根目录 / crates / tui / src / tui / history / tool_run.rs
1 //! Tool-run grouping for transcript collapse.
2
3 use super::{HistoryCell, ToolCell};
4
5 #[derive(Debug, Clone, PartialEq, Eq)]
6 pub struct ToolRun {
7 /// Original index of the first tool cell in `App::history`.
8 pub start: usize,
9 /// Number of collapsed cells in the run.
10 pub count: usize,
11 /// Dominant tool names, deduplicated and capped for summary rendering.
12 pub tool_families: Vec<String>,
13 /// Human-facing activity buckets for Cursor-style metadata rows.
14 pub activity: ToolRunActivitySummary,
15 }
16
17 #[derive(Debug, Default, Clone, PartialEq, Eq)]
18 pub struct ToolRunActivitySummary {
19 pub files: usize,
20 pub searches: usize,
21 pub commands: usize,
22 pub edits: usize,
23 pub delegates: usize,
24 pub metadata: usize,
25 pub other: usize,
26 }
27
28 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 enum ToolRunActivity {
30 File,
31 Search,
32 Command,
33 Edit,
34 Delegate,
35 Metadata,
36 Other,
37 }
38
39 impl ToolRunActivitySummary {
40 fn record(&mut self, tool: &ToolCell) {
41 match classify_tool_run_activity(tool) {
42 ToolRunActivity::File => self.files += 1,
43 ToolRunActivity::Search => self.searches += 1,
44 ToolRunActivity::Command => self.commands += 1,
45 ToolRunActivity::Edit => self.edits += 1,
46 ToolRunActivity::Delegate => self.delegates += 1,
47 ToolRunActivity::Metadata => self.metadata += 1,
48 ToolRunActivity::Other => self.other += 1,
49 }
50 }
51 }
52
53 /// Detect contiguous runs of successful, low-risk tool cells.
54 ///
55 /// Failed, running, patch, review, diff, and plan-update cells split runs so
56 /// important state never disappears into a summary row. Successful command
57 /// cells can join dense runs; `v` / expansion keeps their raw details
58 /// available without making routine verifier/shell work dominate the default
59 /// transcript.
60 #[cfg(test)]
61 pub fn detect_tool_runs(history: &[HistoryCell], min_size: usize) -> Vec<ToolRun> {
62 detect_tool_runs_from_slices(history, &[], min_size)
63 }
64
65 /// Detect contiguous runs across committed history plus the active in-flight
66 /// tail. `ToolRun::start` is always the virtual transcript index:
67 /// `history.len() + active_offset` for active entries.
68 pub fn detect_tool_runs_from_slices(
69 history: &[HistoryCell],
70 active_entries: &[HistoryCell],
71 min_size: usize,
72 ) -> Vec<ToolRun> {
73 if min_size == 0 {
74 return Vec::new();
75 }
76
77 let mut runs = Vec::new();
78 let mut index = 0;
79 let total_len = history.len().saturating_add(active_entries.len());
80 while index < total_len {
81 if !cell_at_virtual_index(history, active_entries, index)
82 .is_some_and(is_collapsible_tool_cell)
83 {
84 index += 1;
85 continue;
86 }
87
88 let start = index;
89 let mut names: Vec<String> = Vec::new();
90 let mut activity = ToolRunActivitySummary::default();
91 while index < total_len
92 && cell_at_virtual_index(history, active_entries, index)
93 .is_some_and(is_collapsible_tool_cell)
94 {
95 if let Some(HistoryCell::Tool(tool)) =
96 cell_at_virtual_index(history, active_entries, index)
97 {
98 let name = tool_display_name(tool);
99 if !names.iter().any(|existing| existing == name) {
100 names.push(name.to_string());
101 }
102 activity.record(tool);
103 }
104 index += 1;
105 }
106
107 let count = index - start;
108 if count >= min_size {
109 names.truncate(3);
110 runs.push(ToolRun {
111 start,
112 count,
113 tool_families: names,
114 activity,
115 });
116 }
117 }
118
119 runs
120 }
121
122 fn cell_at_virtual_index<'a>(
123 history: &'a [HistoryCell],
124 active_entries: &'a [HistoryCell],
125 index: usize,
126 ) -> Option<&'a HistoryCell> {
127 history
128 .get(index)
129 .or_else(|| active_entries.get(index.checked_sub(history.len())?))
130 }
131
132 fn is_collapsible_tool_cell(cell: &HistoryCell) -> bool {
133 matches!(cell, HistoryCell::Tool(tool) if tool.is_success() && !tool.is_collapsible_guard())
134 }
135
136 pub(super) fn generic_tool_name_is_collapse_guard(name: &str) -> bool {
137 let normalized = name.trim().to_ascii_lowercase();
138 if is_metadata_tool_name(&normalized) {
139 return false;
140 }
141
142 normalized.contains("patch")
143 || normalized.contains("write")
144 || normalized.contains("edit")
145 || normalized.contains("delete")
146 || normalized.contains("remove")
147 || normalized.contains("commit")
148 || normalized.contains("push")
149 || normalized.contains("review")
150 }
151
152 fn is_metadata_tool_name(name: &str) -> bool {
153 matches!(
154 name,
155 "update_plan"
156 | "work_update"
157 | "todo_write"
158 | "todo_add"
159 | "todo_update"
160 | "checklist_write"
161 | "checklist_add"
162 | "checklist_update"
163 | "checklist_list"
164 )
165 }
166
167 fn tool_display_name(tool: &ToolCell) -> &str {
168 match tool {
169 ToolCell::Generic(cell) => cell.name.as_str(),
170 ToolCell::Mcp(cell) => cell.tool.as_str(),
171 ToolCell::WebSearch(_) => "web_search",
172 ToolCell::ViewImage(_) => "view_image",
173 ToolCell::Exploring(_) => "explore",
174 ToolCell::Exec(_) => "shell",
175 ToolCell::PlanUpdate(_) => "update_plan",
176 ToolCell::PatchSummary(_) => "apply_patch",
177 ToolCell::Review(_) => "review",
178 }
179 }
180
181 fn classify_tool_run_activity(tool: &ToolCell) -> ToolRunActivity {
182 let name = tool_display_name(tool);
183 classify_tool_name_activity(name)
184 }
185
186 fn classify_tool_name_activity(name: &str) -> ToolRunActivity {
187 let normalized = name.trim().to_ascii_lowercase();
188 match normalized.as_str() {
189 "read_file" | "list_dir" | "view_image" | "explore" | "git_status" | "git_diff"
190 | "git_log" | "git_show" | "git_blame" | "git_commit_plan" => ToolRunActivity::File,
191 "grep_files" | "file_search" | "web_search" | "fetch_url" | "registry_sync" => {
192 ToolRunActivity::Search
193 }
194 "shell"
195 | "exec_shell"
196 | "exec_shell_wait"
197 | "exec_shell_interact"
198 | "exec_shell_cancel"
199 | "task_shell_start"
200 | "task_shell_wait"
201 | "start_registry_mcp_server"
202 | "run_tests"
203 | "run_verifiers"
204 | "wait_for_dev_server"
205 | "task_gate_run"
206 | "validate_data" => ToolRunActivity::Command,
207 "edit_file" | "apply_patch" | "write_file" | "diff" => ToolRunActivity::Edit,
208 "agent" | "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => {
209 ToolRunActivity::Delegate
210 }
211 _ if is_metadata_tool_name(&normalized) => ToolRunActivity::Metadata,
212 _ if normalized.contains("search")
213 || normalized.contains("grep")
214 || normalized.contains("find") =>
215 {
216 ToolRunActivity::Search
217 }
218 _ if normalized.contains("read")
219 || normalized.contains("list")
220 || normalized.contains("view")
221 || normalized.contains("open") =>
222 {
223 ToolRunActivity::File
224 }
225 _ if normalized.contains("patch")
226 || normalized.contains("write")
227 || normalized.contains("edit")
228 || normalized.contains("diff") =>
229 {
230 ToolRunActivity::Edit
231 }
232 _ if normalized.contains("run")
233 || normalized.contains("exec")
234 || normalized.contains("shell")
235 || normalized.contains("test")
236 || normalized.contains("check") =>
237 {
238 ToolRunActivity::Command
239 }
240 _ if normalized.contains("agent")
241 || normalized.contains("delegate")
242 || normalized.contains("fanout")
243 || normalized.contains("rlm") =>
244 {
245 ToolRunActivity::Delegate
246 }
247 _ if normalized.contains("metadata")
248 || normalized.contains("session")
249 || normalized.contains("context")
250 || normalized.contains("plan")
251 || normalized.contains("todo") =>
252 {
253 ToolRunActivity::Metadata
254 }
255 _ => ToolRunActivity::Other,
256 }
257 }
258
259 #[must_use]
260 pub fn tool_run_summary(run: &ToolRun) -> String {
261 let activity = &run.activity;
262 let mut parts = Vec::new();
263 if activity.files > 0 {
264 parts.push(counted(activity.files, "file", "files"));
265 }
266 if activity.searches > 0 {
267 parts.push(counted(activity.searches, "search", "searches"));
268 }
269
270 let mut clauses = Vec::new();
271 if !parts.is_empty() {
272 let mut explore_clause = format!("Explored {}", parts.join(", "));
273 if let Some(families) =
274 activity_family_summary(run, &[ToolRunActivity::File, ToolRunActivity::Search])
275 {
276 explore_clause.push_str(": ");
277 explore_clause.push_str(&families);
278 }
279 clauses.push(explore_clause);
280 }
281 if activity.commands > 0 {
282 let mut command_clause =
283 format!("ran {}", counted(activity.commands, "command", "commands"));
284 if let Some(families) = activity_family_summary(run, &[ToolRunActivity::Command]) {
285 command_clause.push_str(": ");
286 command_clause.push_str(&families);
287 }
288 clauses.push(command_clause);
289 }
290 if activity.edits > 0 {
291 clauses.push(format!(
292 "edited {}",
293 counted(activity.edits, "file", "files")
294 ));
295 }
296 if activity.delegates > 0 {
297 clauses.push(format!(
298 "delegated {}",
299 counted(activity.delegates, "task", "tasks")
300 ));
301 }
302 if activity.metadata > 0 || activity.other > 0 {
303 clauses.push("updated metadata".to_string());
304 }
305
306 if clauses.is_empty() {
307 return "Updated metadata".to_string();
308 }
309
310 let summary = clauses.join(", ");
311 sentence_case_activity(summary)
312 }
313
314 fn activity_family_summary(run: &ToolRun, activities: &[ToolRunActivity]) -> Option<String> {
315 let mut families = Vec::new();
316 for family in &run.tool_families {
317 if activities.contains(&classify_tool_name_activity(family))
318 && !families.iter().any(|existing| existing == family)
319 {
320 families.push(family.as_str());
321 }
322 }
323
324 (!families.is_empty()).then(|| families.join(", "))
325 }
326
327 fn counted(count: usize, singular: &str, plural: &str) -> String {
328 let noun = if count == 1 { singular } else { plural };
329 format!("{count} {noun}")
330 }
331
332 fn sentence_case_activity(text: String) -> String {
333 let mut chars = text.chars();
334 let Some(first) = chars.next() else {
335 return text;
336 };
337 let mut out = String::new();
338 out.extend(first.to_uppercase());
339 out.push_str(chars.as_str());
340 out
341 }
342
343 #[cfg(test)]
344 mod tests {
345 use super::*;
346 use crate::tools::canonical_action::canonical_action_alias;
347 use serde_json::json;
348
349 #[test]
350 fn canonical_file_mutations_never_collapse_behind_summary_rows() {
351 for action in ["write", "edit", "patch"] {
352 let input = json!({"action": action});
353 let semantic_name = canonical_action_alias("File", &input);
354 assert!(
355 generic_tool_name_is_collapse_guard(semantic_name),
356 "File.{action}"
357 );
358 }
359
360 for action in ["read", "list", "search_name", "search_content"] {
361 let input = json!({"action": action});
362 let semantic_name = canonical_action_alias("File", &input);
363 assert!(
364 !generic_tool_name_is_collapse_guard(semantic_name),
365 "File.{action}"
366 );
367 }
368 }
369
370 #[test]
371 fn normalized_git_and_run_actions_keep_truthful_activity_buckets() {
372 for action in ["status", "diff", "log", "show", "blame", "commit_plan"] {
373 let input = json!({"action": action});
374 assert_eq!(
375 classify_tool_name_activity(canonical_action_alias("Git", &input)),
376 ToolRunActivity::File,
377 "Git.{action}"
378 );
379 }
380 for action in ["tests", "verifiers"] {
381 let input = json!({"action": action});
382 assert_eq!(
383 classify_tool_name_activity(canonical_action_alias("Run", &input)),
384 ToolRunActivity::Command,
385 "Run.{action}"
386 );
387 }
388 }
389 }
390
390 lines RUST