返回 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 ToolCell::DiffPreview(_) => "diff",
179 }
180 }
181
182 fn classify_tool_run_activity(tool: &ToolCell) -> ToolRunActivity {
183 let name = tool_display_name(tool);
184 classify_tool_name_activity(name)
185 }
186
187 fn classify_tool_name_activity(name: &str) -> ToolRunActivity {
188 let normalized = name.trim().to_ascii_lowercase();
189 match normalized.as_str() {
190 "read_file" | "list_dir" | "view_image" | "explore" | "git_status" | "git_diff"
191 | "git_log" | "git_show" | "git_blame" => ToolRunActivity::File,
192 "grep_files" | "file_search" | "web_search" | "fetch_url" | "registry_sync" => {
193 ToolRunActivity::Search
194 }
195 "shell"
196 | "exec_shell"
197 | "exec_shell_wait"
198 | "exec_shell_interact"
199 | "exec_shell_cancel"
200 | "task_shell_start"
201 | "task_shell_wait"
202 | "start_registry_mcp_server"
203 | "run_tests"
204 | "run_verifiers"
205 | "wait_for_dev_server"
206 | "task_gate_run"
207 | "validate_data" => ToolRunActivity::Command,
208 "edit_file" | "apply_patch" | "write_file" | "diff" => ToolRunActivity::Edit,
209 "agent" | "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => {
210 ToolRunActivity::Delegate
211 }
212 _ if is_metadata_tool_name(&normalized) => ToolRunActivity::Metadata,
213 _ if normalized.contains("search")
214 || normalized.contains("grep")
215 || normalized.contains("find") =>
216 {
217 ToolRunActivity::Search
218 }
219 _ if normalized.contains("read")
220 || normalized.contains("list")
221 || normalized.contains("view")
222 || normalized.contains("open") =>
223 {
224 ToolRunActivity::File
225 }
226 _ if normalized.contains("patch")
227 || normalized.contains("write")
228 || normalized.contains("edit")
229 || normalized.contains("diff") =>
230 {
231 ToolRunActivity::Edit
232 }
233 _ if normalized.contains("run")
234 || normalized.contains("exec")
235 || normalized.contains("shell")
236 || normalized.contains("test")
237 || normalized.contains("check") =>
238 {
239 ToolRunActivity::Command
240 }
241 _ if normalized.contains("agent")
242 || normalized.contains("delegate")
243 || normalized.contains("fanout")
244 || normalized.contains("rlm") =>
245 {
246 ToolRunActivity::Delegate
247 }
248 _ if normalized.contains("metadata")
249 || normalized.contains("session")
250 || normalized.contains("context")
251 || normalized.contains("plan")
252 || normalized.contains("todo") =>
253 {
254 ToolRunActivity::Metadata
255 }
256 _ => ToolRunActivity::Other,
257 }
258 }
259
260 #[must_use]
261 pub fn tool_run_summary(run: &ToolRun) -> String {
262 let activity = &run.activity;
263 let mut parts = Vec::new();
264 if activity.files > 0 {
265 parts.push(counted(activity.files, "file", "files"));
266 }
267 if activity.searches > 0 {
268 parts.push(counted(activity.searches, "search", "searches"));
269 }
270
271 let mut clauses = Vec::new();
272 if !parts.is_empty() {
273 let mut explore_clause = format!("Explored {}", parts.join(", "));
274 if let Some(families) =
275 activity_family_summary(run, &[ToolRunActivity::File, ToolRunActivity::Search])
276 {
277 explore_clause.push_str(": ");
278 explore_clause.push_str(&families);
279 }
280 clauses.push(explore_clause);
281 }
282 if activity.commands > 0 {
283 let mut command_clause =
284 format!("ran {}", counted(activity.commands, "command", "commands"));
285 if let Some(families) = activity_family_summary(run, &[ToolRunActivity::Command]) {
286 command_clause.push_str(": ");
287 command_clause.push_str(&families);
288 }
289 clauses.push(command_clause);
290 }
291 if activity.edits > 0 {
292 clauses.push(format!(
293 "edited {}",
294 counted(activity.edits, "file", "files")
295 ));
296 }
297 if activity.delegates > 0 {
298 clauses.push(format!(
299 "delegated {}",
300 counted(activity.delegates, "task", "tasks")
301 ));
302 }
303 if activity.metadata > 0 || activity.other > 0 {
304 clauses.push("updated metadata".to_string());
305 }
306
307 if clauses.is_empty() {
308 return "Updated metadata".to_string();
309 }
310
311 let summary = clauses.join(", ");
312 sentence_case_activity(summary)
313 }
314
315 fn activity_family_summary(run: &ToolRun, activities: &[ToolRunActivity]) -> Option<String> {
316 let mut families = Vec::new();
317 for family in &run.tool_families {
318 if activities.contains(&classify_tool_name_activity(family))
319 && !families.iter().any(|existing| existing == family)
320 {
321 families.push(family.as_str());
322 }
323 }
324
325 (!families.is_empty()).then(|| families.join(", "))
326 }
327
328 fn counted(count: usize, singular: &str, plural: &str) -> String {
329 let noun = if count == 1 { singular } else { plural };
330 format!("{count} {noun}")
331 }
332
333 fn sentence_case_activity(text: String) -> String {
334 let mut chars = text.chars();
335 let Some(first) = chars.next() else {
336 return text;
337 };
338 let mut out = String::new();
339 out.extend(first.to_uppercase());
340 out.push_str(chars.as_str());
341 out
342 }
343
344 #[cfg(test)]
345 mod tests {
346 use super::*;
347 use crate::tools::canonical_action::canonical_action_alias;
348 use serde_json::json;
349
350 #[test]
351 fn canonical_file_mutations_never_collapse_behind_summary_rows() {
352 for action in ["write", "edit", "patch"] {
353 let input = json!({"action": action});
354 let semantic_name = canonical_action_alias("File", &input);
355 assert!(
356 generic_tool_name_is_collapse_guard(semantic_name),
357 "File.{action}"
358 );
359 }
360
361 for action in ["read", "list", "search_name", "search_content"] {
362 let input = json!({"action": action});
363 let semantic_name = canonical_action_alias("File", &input);
364 assert!(
365 !generic_tool_name_is_collapse_guard(semantic_name),
366 "File.{action}"
367 );
368 }
369 }
370
371 #[test]
372 fn normalized_git_and_run_actions_keep_truthful_activity_buckets() {
373 for action in ["status", "diff", "log", "show", "blame"] {
374 let input = json!({"action": action});
375 assert_eq!(
376 classify_tool_name_activity(canonical_action_alias("Git", &input)),
377 ToolRunActivity::File,
378 "Git.{action}"
379 );
380 }
381 for action in ["tests", "verifiers"] {
382 let input = json!({"action": action});
383 assert_eq!(
384 classify_tool_name_activity(canonical_action_alias("Run", &input)),
385 ToolRunActivity::Command,
386 "Run.{action}"
387 );
388 }
389 }
390 }
391
391 lines RUST