Skip to main content

ai_agent/utils/
collapse_read_search.rs

1// Source: ~/claudecode/openclaudecode/src/utils/collapseReadSearch.ts
2//! Collapse consecutive Read/Search operations into summary groups.
3//!
4//! Rules:
5//! - Groups consecutive search/read tool uses (Grep, Glob, Read, and Bash search/read commands)
6//! - Includes their corresponding tool results in the group
7//! - Breaks groups when assistant text appears
8
9#![allow(dead_code)]
10
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13
14use crate::tools::mcp::classify_for_collapse::classify_mcp_tool_for_collapse;
15
16/// Tool name constants
17const BASH_TOOL_NAME: &str = "Bash";
18const READ_TOOL_NAME: &str = "Read";
19const GREP_TOOL_NAME: &str = "Grep";
20const GLOB_TOOL_NAME: &str = "Glob";
21const REPL_TOOL_NAME: &str = "REPL";
22const FILE_EDIT_TOOL_NAME: &str = "Edit";
23const FILE_WRITE_TOOL_NAME: &str = "Write";
24const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearch";
25const SNIP_TOOL_NAME: &str = "Snip";
26
27/// Result of checking if a tool use is a search or read operation.
28#[derive(Debug, Clone)]
29pub struct SearchOrReadResult {
30    pub is_collapsible: bool,
31    pub is_search: bool,
32    pub is_read: bool,
33    pub is_list: bool,
34    pub is_repl: bool,
35    /// True if this is a Write/Edit targeting a memory file
36    pub is_memory_write: bool,
37    /// True for meta-operations that should be absorbed into a collapse group
38    /// without incrementing any count (Snip, ToolSearch).
39    pub is_absorbed_silently: bool,
40    /// MCP server name when this is an MCP tool
41    pub mcp_server_name: Option<String>,
42    /// Bash command that is NOT a search/read (under fullscreen mode)
43    pub is_bash: Option<bool>,
44}
45
46/// Information about a collapsible tool use.
47#[derive(Debug, Clone)]
48pub struct CollapsibleToolInfo {
49    pub name: String,
50    pub input: serde_json::Value,
51    pub is_search: bool,
52    pub is_read: bool,
53    pub is_list: bool,
54    pub is_repl: bool,
55    pub is_memory_write: bool,
56    pub is_absorbed_silently: bool,
57    pub mcp_server_name: Option<String>,
58    pub is_bash: Option<bool>,
59}
60
61/// Extract the primary file/directory path from a tool_use input.
62/// Handles both `file_path` (Read/Write/Edit) and `path` (Grep/Glob).
63fn get_file_path_from_tool_input(input: &serde_json::Value) -> Option<String> {
64    input
65        .get("file_path")
66        .or_else(|| input.get("path"))
67        .and_then(|v| v.as_str())
68        .map(String::from)
69}
70
71/// Check if a search tool use targets memory files by examining its path, pattern, and glob.
72fn is_memory_search(tool_input: &serde_json::Value) -> bool {
73    if let Some(path) = tool_input.get("path").and_then(|v| v.as_str()) {
74        if is_auto_managed_memory_file(path) || is_memory_directory(path) {
75            return true;
76        }
77    }
78    if let Some(glob) = tool_input.get("glob").and_then(|v| v.as_str()) {
79        if is_auto_managed_memory_pattern(glob) {
80            return true;
81        }
82    }
83    if let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) {
84        if is_shell_command_targeting_memory(command) {
85            return true;
86        }
87    }
88    false
89}
90
91/// Check if a Write or Edit tool use targets a memory file and should be collapsed.
92fn is_memory_write_or_edit(tool_name: &str, tool_input: &serde_json::Value) -> bool {
93    if tool_name != FILE_WRITE_TOOL_NAME && tool_name != FILE_EDIT_TOOL_NAME {
94        return false;
95    }
96    get_file_path_from_tool_input(tool_input).is_some()
97        && get_file_path_from_tool_input(tool_input)
98            .map(|p| is_auto_managed_memory_file(&p))
99            .unwrap_or(false)
100}
101
102/// ~5 lines x ~60 cols. Generous static cap -- the renderer lets Ink wrap.
103const MAX_HINT_CHARS: usize = 300;
104
105/// Format a bash command for the hint. Drops blank lines, collapses runs of
106/// inline whitespace, then caps total length.
107fn command_as_hint(command: &str) -> String {
108    let cleaned: String = command
109        .lines()
110        .map(|l| {
111            let trimmed = l.split_whitespace().collect::<Vec<_>>().join(" ");
112            trimmed
113        })
114        .filter(|l| !l.is_empty())
115        .collect::<Vec<_>>()
116        .join("\n");
117    let prefixed = format!("$ {}", cleaned);
118
119    if prefixed.len() > MAX_HINT_CHARS {
120        format!("{}...", &prefixed[..MAX_HINT_CHARS - 3])
121    } else {
122        prefixed
123    }
124}
125
126/// Check if an environment variable is truthy.
127fn is_env_truthy(key: &str) -> bool {
128    std::env::var(key)
129        .map(|v| v == "1" || v.to_lowercase() == "true")
130        .unwrap_or(false)
131}
132
133/// Check if fullscreen mode is enabled.
134fn is_fullscreen_env_enabled() -> bool {
135    std::env::var("AI_FULLSCREEN")
136        .map(|v| v == "1")
137        .unwrap_or(false)
138}
139
140/// Check if a file path is an auto-managed memory file.
141fn is_auto_managed_memory_file(path: &str) -> bool {
142    path.contains(".ai/memory") || path.contains(".ai/AI.md")
143}
144
145/// Check if a path is a memory directory.
146fn is_memory_directory(path: &str) -> bool {
147    path.contains(".ai/memory") || path.ends_with(".ai/memory")
148}
149
150/// Check if a shell command targets memory paths.
151fn is_shell_command_targeting_memory(command: &str) -> bool {
152    command.contains(".ai/memory") || command.contains("AI.md")
153}
154
155/// Check if a pattern is an auto-managed memory pattern.
156fn is_auto_managed_memory_pattern(pattern: &str) -> bool {
157    pattern.contains(".ai/memory") || pattern.contains("AI.md")
158}
159
160/// Check if feature flag is enabled.
161fn is_feature_enabled(feature: &str) -> bool {
162    match feature {
163        "TEAMMEM" => is_env_truthy("AI_CODE_ENABLE_TEAM_MEMORY"),
164        "HISTORY_SNIP" => is_env_truthy("AI_CODE_ENABLE_HISTORY_SNIP"),
165        "BASH_CLASSIFIER" => is_env_truthy("AI_CODE_ENABLE_BASH_CLASSIFIER"),
166        "TRANSCRIPT_CLASSIFIER" => is_env_truthy("AI_CODE_ENABLE_TRANSCRIPT_CLASSIFIER"),
167        _ => false,
168    }
169}
170
171/// Extract bash comment label from a command.
172fn extract_bash_comment_label(command: &str) -> Option<String> {
173    command
174        .lines()
175        .next()
176        .and_then(|line| line.strip_prefix("# "))
177        .map(String::from)
178}
179
180/// Get the display path (simplified version).
181fn get_display_path(path: &str) -> String {
182    // In a full implementation, this would shorten the path relative to cwd.
183    path.to_string()
184}
185
186/// Checks if a tool is a search/read operation using the tool's isSearchOrReadCommand method.
187/// Also treats Write/Edit of memory files as collapsible.
188pub fn get_tool_search_or_read_info(
189    tool_name: &str,
190    tool_input: &serde_json::Value,
191) -> SearchOrReadResult {
192    // REPL is absorbed silently
193    if tool_name == REPL_TOOL_NAME {
194        return SearchOrReadResult {
195            is_collapsible: true,
196            is_search: false,
197            is_read: false,
198            is_list: false,
199            is_repl: true,
200            is_memory_write: false,
201            is_absorbed_silently: true,
202            mcp_server_name: None,
203            is_bash: None,
204        };
205    }
206
207    // Memory file writes/edits are collapsible
208    if is_memory_write_or_edit(tool_name, tool_input) {
209        return SearchOrReadResult {
210            is_collapsible: true,
211            is_search: false,
212            is_read: false,
213            is_list: false,
214            is_repl: false,
215            is_memory_write: true,
216            is_absorbed_silently: false,
217            mcp_server_name: None,
218            is_bash: None,
219        };
220    }
221
222    // Meta-operations absorbed silently: Snip and ToolSearch
223    if (is_feature_enabled("HISTORY_SNIP") && tool_name == SNIP_TOOL_NAME)
224        || (is_fullscreen_env_enabled() && tool_name == TOOL_SEARCH_TOOL_NAME)
225    {
226        return SearchOrReadResult {
227            is_collapsible: true,
228            is_search: false,
229            is_read: false,
230            is_list: false,
231            is_repl: false,
232            is_memory_write: false,
233            is_absorbed_silently: true,
234            mcp_server_name: None,
235            is_bash: None,
236        };
237    }
238
239    // For the tool's isSearchOrReadCommand check, we use a simplified approach.
240    // In the TS version, this calls tool.isSearchOrReadCommand() on the actual tool.
241    // Here we use pattern matching on known tool names.
242    let (is_search, is_read, is_list) = match tool_name {
243        GREP_TOOL_NAME | "grep" => (true, false, false),
244        GLOB_TOOL_NAME | "glob" => (true, false, false),
245        READ_TOOL_NAME | "Read" => (false, true, false),
246        BASH_TOOL_NAME => {
247            // Determine if bash command is a search, read, or list operation
248            let command = tool_input
249                .get("command")
250                .and_then(|v| v.as_str())
251                .unwrap_or("");
252            if is_bash_search_command(command) {
253                (true, false, false)
254            } else if is_bash_read_command(command) {
255                (false, true, false)
256            } else if is_bash_list_command(command) {
257                (false, false, true)
258            } else {
259                (false, false, false)
260            }
261        }
262        _ => (false, false, false),
263    };
264
265    // Handle MCP tools: names starting with "mcp__" format "mcp__{server}__{tool_name}"
266    let mcp_info = if tool_name.starts_with("mcp__") {
267        parse_and_classify_mcp_tool(tool_name)
268    } else {
269        None
270    };
271
272    let is_collapsible = is_search || is_read || is_list;
273
274    // Extract MCP classification values
275    let (mcp_is_search, mcp_is_read, mcp_server_name) = match mcp_info {
276        Some(m) => (m.is_search, m.is_read, Some(m.server_name)),
277        None => (false, false, None),
278    };
279
280    // Under fullscreen mode, non-search/read Bash commands are also collapsible
281    SearchOrReadResult {
282        is_collapsible: is_collapsible
283            || (is_fullscreen_env_enabled() && tool_name == BASH_TOOL_NAME)
284            || mcp_server_name.is_some(),
285        is_search: is_search || mcp_is_search,
286        is_read: is_read || mcp_is_read,
287        is_list,
288        is_repl: false,
289        is_memory_write: false,
290        is_absorbed_silently: false,
291        mcp_server_name,
292        is_bash: if is_fullscreen_env_enabled() {
293            Some(!is_collapsible && tool_name == BASH_TOOL_NAME)
294        } else {
295            None
296        },
297    }
298}
299
300/// Check if a bash command is a search command.
301/// Parse an MCP tool name and classify it for collapse.
302/// Returns Some when the tool is classified as search or read.
303/// MCP tool names are in format "mcp__{server}__{tool_name}".
304fn parse_and_classify_mcp_tool(tool_name: &str) -> Option<McpCollapseInfo> {
305    // Format: mcp__{server}__{tool_name}
306    let without_prefix = tool_name.strip_prefix("mcp__")?;
307    let mut parts = without_prefix.splitn(2, "__");
308    let server_name = parts.next()?.to_string();
309    let raw_tool_name = parts.next()?;
310
311    let classification = classify_mcp_tool_for_collapse(&server_name, raw_tool_name);
312    if classification.is_search || classification.is_read {
313        Some(McpCollapseInfo {
314            server_name,
315            is_search: classification.is_search,
316            is_read: classification.is_read,
317        })
318    } else {
319        None
320    }
321}
322
323#[derive(Debug, Clone)]
324struct McpCollapseInfo {
325    server_name: String,
326    is_search: bool,
327    is_read: bool,
328}
329
330fn is_bash_search_command(command: &str) -> bool {
331    let cmd = command.trim_start();
332    cmd.starts_with("grep ")
333        || cmd.starts_with("rg ")
334        || cmd.starts_with("ag ")
335        || cmd.starts_with("ack ")
336        || cmd.starts_with("find ")
337        || cmd.starts_with("ugrep ")
338}
339
340/// Check if a bash command is a read command.
341fn is_bash_read_command(command: &str) -> bool {
342    let cmd = command.trim_start();
343    cmd.starts_with("cat ")
344        || cmd.starts_with("head ")
345        || cmd.starts_with("tail ")
346        || cmd.starts_with("less ")
347        || cmd.starts_with("more ")
348        || cmd.starts_with("wc ")
349}
350
351/// Check if a bash command is a list/directory command.
352fn is_bash_list_command(command: &str) -> bool {
353    let cmd = command.trim_start();
354    cmd.starts_with("ls ") || cmd.starts_with("tree ") || cmd.starts_with("du ")
355}
356
357/// Check if a tool_use content block is a search/read operation.
358pub fn get_search_or_read_from_content(
359    content: Option<&serde_json::Value>,
360) -> Option<CollapsibleToolInfo> {
361    let content = content?;
362    if content.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
363        return None;
364    }
365    let name = content.get("name").and_then(|v| v.as_str())?;
366    let input = content.get("input").cloned().unwrap_or_default();
367    let info = get_tool_search_or_read_info(name, &input);
368    if info.is_collapsible || info.is_repl {
369        Some(CollapsibleToolInfo {
370            name: name.to_string(),
371            input,
372            is_search: info.is_search,
373            is_read: info.is_read,
374            is_list: info.is_list,
375            is_repl: info.is_repl,
376            is_memory_write: info.is_memory_write,
377            is_absorbed_silently: info.is_absorbed_silently,
378            mcp_server_name: info.mcp_server_name,
379            is_bash: info.is_bash,
380        })
381    } else {
382        None
383    }
384}
385
386/// Checks if a tool is a search/read operation (for backwards compatibility).
387fn is_tool_search_or_read(tool_name: &str, tool_input: &serde_json::Value) -> bool {
388    get_tool_search_or_read_info(tool_name, tool_input).is_collapsible
389}
390
391/// Get all tool use IDs from a message.
392fn get_tool_use_ids_from_message(msg: &serde_json::Value) -> Vec<String> {
393    // In a full implementation, this would extract IDs from the message structure.
394    // For now, return a placeholder.
395    msg.get("tool_use_id")
396        .and_then(|v| v.as_str())
397        .map(|id| vec![id.to_string()])
398        .unwrap_or_default()
399}
400
401/// Get file paths from a read message.
402fn get_file_paths_from_read_message(msg: &serde_json::Value) -> Vec<String> {
403    let mut paths = Vec::new();
404
405    if let Some(input) = msg.get("input") {
406        if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) {
407            paths.push(file_path.to_string());
408        }
409    }
410
411    paths
412}
413
414/// Accumulator for building a collapsed group.
415struct GroupAccumulator {
416    messages: Vec<serde_json::Value>,
417    search_count: usize,
418    read_file_paths: HashSet<String>,
419    read_operation_count: usize,
420    list_count: usize,
421    tool_use_ids: HashSet<String>,
422    memory_search_count: usize,
423    memory_read_file_paths: HashSet<String>,
424    memory_write_count: usize,
425    non_mem_search_args: Vec<String>,
426    latest_display_hint: Option<String>,
427    hook_total_ms: u64,
428    hook_count: usize,
429    hook_infos: Vec<serde_json::Value>,
430    // Fullscreen-specific fields
431    bash_count: usize,
432    bash_commands: HashMap<String, String>,
433    commits: Vec<serde_json::Value>,
434    pushes: Vec<serde_json::Value>,
435    branches: Vec<serde_json::Value>,
436    prs: Vec<serde_json::Value>,
437    git_op_bash_count: usize,
438    // MCP-specific fields
439    mcp_call_count: usize,
440    mcp_server_names: HashSet<String>,
441    // Memory-specific fields
442    team_memory_search_count: usize,
443    team_memory_read_file_paths: HashSet<String>,
444    team_memory_write_count: usize,
445}
446
447fn create_empty_group() -> GroupAccumulator {
448    GroupAccumulator {
449        messages: Vec::new(),
450        search_count: 0,
451        read_file_paths: HashSet::new(),
452        read_operation_count: 0,
453        list_count: 0,
454        tool_use_ids: HashSet::new(),
455        memory_search_count: 0,
456        memory_read_file_paths: HashSet::new(),
457        memory_write_count: 0,
458        non_mem_search_args: Vec::new(),
459        latest_display_hint: None,
460        hook_total_ms: 0,
461        hook_count: 0,
462        hook_infos: Vec::new(),
463        bash_count: 0,
464        bash_commands: HashMap::new(),
465        commits: Vec::new(),
466        pushes: Vec::new(),
467        branches: Vec::new(),
468        prs: Vec::new(),
469        git_op_bash_count: 0,
470        mcp_call_count: 0,
471        mcp_server_names: HashSet::new(),
472        team_memory_search_count: 0,
473        team_memory_read_file_paths: HashSet::new(),
474        team_memory_write_count: 0,
475    }
476}
477
478/// Collapse consecutive Read/Search operations into summary groups.
479pub fn collapse_read_search_groups(messages: &[serde_json::Value]) -> Vec<serde_json::Value> {
480    let mut result = Vec::new();
481    let mut current_group = create_empty_group();
482    let mut deferred_skippable: Vec<serde_json::Value> = Vec::new();
483
484    fn flush_group(
485        result: &mut Vec<serde_json::Value>,
486        current_group: &mut GroupAccumulator,
487        deferred_skippable: &mut Vec<serde_json::Value>,
488    ) {
489        if current_group.messages.is_empty() {
490            return;
491        }
492        result.push(create_collapsed_group(current_group));
493        for deferred in deferred_skippable.drain(..) {
494            result.push(deferred);
495        }
496        *current_group = create_empty_group();
497    }
498
499    for msg in messages {
500        let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("");
501
502        if msg_type == "assistant" {
503            // Check if this is a collapsible tool use
504            let content = msg.get("message").and_then(|m| m.get("content"));
505            if let Some(content_arr) = content.and_then(|c| c.as_array()) {
506                if let Some(first_content) = content_arr.first() {
507                    if first_content.get("type").and_then(|v| v.as_str()) == Some("tool_use") {
508                        if let Some(tool_name) = first_content.get("name").and_then(|v| v.as_str())
509                        {
510                            let input =
511                                first_content.get("input").cloned().unwrap_or_default();
512                            let info = get_tool_search_or_read_info(tool_name, &input);
513
514                            if info.is_collapsible {
515                                process_collapsible_tool_use(
516                                    &info,
517                                    tool_name,
518                                    &input,
519                                    msg,
520                                    &mut current_group,
521                                    &mut deferred_skippable,
522                                    &mut result,
523                                    &mut flush_group,
524                                );
525                                continue;
526                            }
527                        }
528                    }
529                }
530            }
531        }
532
533        // If we get here, this message breaks the group
534        flush_group(
535            &mut result,
536            &mut current_group,
537            &mut deferred_skippable,
538        );
539        result.push(msg.clone());
540    }
541
542    flush_group(
543        &mut result,
544        &mut current_group,
545        &mut deferred_skippable,
546    );
547    result
548}
549
550/// Process a collapsible tool use message.
551fn process_collapsible_tool_use(
552    info: &SearchOrReadResult,
553    tool_name: &str,
554    tool_input: &serde_json::Value,
555    msg: &serde_json::Value,
556    current_group: &mut GroupAccumulator,
557    deferred_skippable: &mut Vec<serde_json::Value>,
558    result: &mut Vec<serde_json::Value>,
559    flush_fn: &mut impl FnMut(
560        &mut Vec<serde_json::Value>,
561        &mut GroupAccumulator,
562        &mut Vec<serde_json::Value>,
563    ),
564) {
565    if info.is_memory_write {
566        // Memory file write/edit
567        if is_feature_enabled("TEAMMEM") && is_team_memory_write_or_edit(tool_name, tool_input) {
568            current_group.team_memory_write_count += 1;
569        } else {
570            current_group.memory_write_count += 1;
571        }
572    } else if info.is_absorbed_silently {
573        // Snip/ToolSearch absorbed silently
574    } else if let Some(ref mcp_server) = info.mcp_server_name {
575        // MCP search/read
576        current_group.mcp_call_count += 1;
577        current_group.mcp_server_names.insert(mcp_server.clone());
578        if let Some(query) = tool_input.get("query").and_then(|v| v.as_str()) {
579            current_group.latest_display_hint = Some(format!("\"{query}\""));
580        }
581    } else if is_fullscreen_env_enabled() && info.is_bash == Some(true) {
582        // Non-search/read Bash command
583        current_group.bash_count += 1;
584        if let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) {
585            current_group.latest_display_hint =
586                Some(extract_bash_comment_label(command).unwrap_or_else(|| command_as_hint(command)));
587            for id in get_tool_use_ids_from_message(msg) {
588                current_group
589                    .bash_commands
590                    .insert(id, command.to_string());
591            }
592        }
593    } else if info.is_list {
594        current_group.list_count += 1;
595        if let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) {
596            current_group.latest_display_hint = Some(command_as_hint(command));
597        }
598    } else if info.is_search {
599        current_group.search_count += 1;
600        if is_feature_enabled("TEAMMEM") && is_team_memory_search(tool_input) {
601            current_group.team_memory_search_count += 1;
602        } else if is_memory_search(tool_input) {
603            current_group.memory_search_count += 1;
604        } else {
605            if let Some(pattern) = tool_input.get("pattern").and_then(|v| v.as_str()) {
606                current_group.non_mem_search_args.push(pattern.to_string());
607                current_group.latest_display_hint = Some(format!("\"{pattern}\""));
608            }
609        }
610    } else {
611        // For reads, track unique file paths
612        let file_paths = get_file_paths_from_read_message(msg);
613        for file_path in &file_paths {
614            current_group.read_file_paths.insert(file_path.clone());
615            if is_feature_enabled("TEAMMEM") && is_team_mem_file(file_path) {
616                current_group
617                    .team_memory_read_file_paths
618                    .insert(file_path.clone());
619            } else if is_auto_managed_memory_file(file_path) {
620                current_group
621                    .memory_read_file_paths
622                    .insert(file_path.clone());
623            } else {
624                current_group.latest_display_hint = Some(get_display_path(file_path));
625            }
626        }
627        if file_paths.is_empty() {
628            current_group.read_operation_count += 1;
629            if let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) {
630                current_group.latest_display_hint = Some(command_as_hint(command));
631            }
632        }
633    }
634
635    // Track tool use IDs
636    for id in get_tool_use_ids_from_message(msg) {
637        current_group.tool_use_ids.insert(id);
638    }
639
640    current_group.messages.push(msg.clone());
641}
642
643/// Team memory stubs
644fn is_team_memory_write_or_edit(_tool_name: &str, _tool_input: &serde_json::Value) -> bool {
645    false
646}
647
648fn is_team_memory_search(_tool_input: &serde_json::Value) -> bool {
649    false
650}
651
652fn is_team_mem_file(_path: &str) -> bool {
653    false
654}
655
656/// Create a collapsed group JSON value.
657fn create_collapsed_group(group: &GroupAccumulator) -> serde_json::Value {
658    let total_read_count = if !group.read_file_paths.is_empty() {
659        group.read_file_paths.len()
660    } else {
661        group.read_operation_count
662    };
663
664    serde_json::json!({
665        "type": "collapsed_read_search",
666        "searchCount": group.search_count.saturating_sub(group.memory_search_count).saturating_sub(group.team_memory_search_count),
667        "readCount": total_read_count.saturating_sub(group.memory_read_file_paths.len()),
668        "listCount": group.list_count,
669        "replCount": 0,
670        "memorySearchCount": group.memory_search_count,
671        "memoryReadCount": group.memory_read_file_paths.len(),
672        "memoryWriteCount": group.memory_write_count,
673        "readFilePaths": group.read_file_paths.iter().cloned().collect::<Vec<_>>(),
674        "searchArgs": group.non_mem_search_args,
675        "latestDisplayHint": group.latest_display_hint,
676        "messages": group.messages,
677    })
678}
679
680/// Generate a summary text for search/read counts.
681pub fn get_search_read_summary_text(
682    search_count: usize,
683    read_count: usize,
684    is_active: bool,
685    repl_count: usize,
686    memory_counts: Option<MemoryCounts>,
687    list_count: usize,
688) -> String {
689    let mut parts: Vec<String> = Vec::new();
690
691    // Memory operations first
692    if let Some(mc) = &memory_counts {
693        if mc.memory_read_count > 0 {
694            let verb = if is_active {
695                if parts.is_empty() {
696                    "Recalling"
697                } else {
698                    "recalling"
699                }
700            } else if parts.is_empty() {
701                "Recalled"
702            } else {
703                "recalled"
704            };
705            let noun = if mc.memory_read_count == 1 {
706                "memory"
707            } else {
708                "memories"
709            };
710            parts.push(format!("{verb} {} {noun}", mc.memory_read_count));
711        }
712        if mc.memory_search_count > 0 {
713            let verb = if is_active {
714                if parts.is_empty() { "Searching" } else { "searching" }
715            } else if parts.is_empty() {
716                "Searched"
717            } else {
718                "searched"
719            };
720            parts.push(format!("{verb} memories"));
721        }
722        if mc.memory_write_count > 0 {
723            let verb = if is_active {
724                if parts.is_empty() { "Writing" } else { "writing" }
725            } else if parts.is_empty() {
726                "Wrote"
727            } else {
728                "wrote"
729            };
730            let noun = if mc.memory_write_count == 1 {
731                "memory"
732            } else {
733                "memories"
734            };
735            parts.push(format!("{verb} {} {noun}", mc.memory_write_count));
736        }
737    }
738
739    if search_count > 0 {
740        let search_verb = if is_active {
741            if parts.is_empty() {
742                "Searching for"
743            } else {
744                "searching for"
745            }
746        } else if parts.is_empty() {
747            "Searched for"
748        } else {
749            "searched for"
750        };
751        let pattern = if search_count == 1 {
752            "pattern"
753        } else {
754            "patterns"
755        };
756        parts.push(format!("{search_verb} {search_count} {pattern}"));
757    }
758
759    if read_count > 0 {
760        let read_verb = if is_active {
761            if parts.is_empty() { "Reading" } else { "reading" }
762        } else if parts.is_empty() {
763            "Read"
764        } else {
765            "read"
766        };
767        let file = if read_count == 1 { "file" } else { "files" };
768        parts.push(format!("{read_verb} {read_count} {file}"));
769    }
770
771    if list_count > 0 {
772        let list_verb = if is_active {
773            if parts.is_empty() { "Listing" } else { "listing" }
774        } else if parts.is_empty() {
775            "Listed"
776        } else {
777            "listed"
778        };
779        let dir = if list_count == 1 {
780            "directory"
781        } else {
782            "directories"
783        };
784        parts.push(format!("{list_verb} {list_count} {dir}"));
785    }
786
787    if repl_count > 0 {
788        let repl_verb = if is_active { "REPL'ing" } else { "REPL'd" };
789        let time = if repl_count == 1 { "time" } else { "times" };
790        parts.push(format!("{repl_verb} {repl_count} {time}"));
791    }
792
793    let text = parts.join(", ");
794    if is_active {
795        format!("{text}...")
796    } else {
797        text
798    }
799}
800
801/// Memory counts for summary.
802#[derive(Debug, Clone)]
803pub struct MemoryCounts {
804    pub memory_search_count: usize,
805    pub memory_read_count: usize,
806    pub memory_write_count: usize,
807    pub team_memory_search_count: usize,
808    pub team_memory_read_count: usize,
809    pub team_memory_write_count: usize,
810}
811
812/// Summarize recent activities into a compact description.
813pub fn summarize_recent_activities(
814    activities: &[ActivityDescription],
815) -> Option<String> {
816    if activities.is_empty() {
817        return None;
818    }
819
820    // Count trailing search/read activities from the end
821    let mut search_count = 0;
822    let mut read_count = 0;
823    for activity in activities.iter().rev() {
824        if activity.is_search == Some(true) {
825            search_count += 1;
826        } else if activity.is_read == Some(true) {
827            read_count += 1;
828        } else {
829            break;
830        }
831    }
832
833    let collapsible_count = search_count + read_count;
834    if collapsible_count >= 2 {
835        return Some(get_search_read_summary_text(
836            search_count,
837            read_count,
838            true,
839            0,
840            None,
841            0,
842        ));
843    }
844
845    // Fall back to most recent activity with a description
846    for activity in activities.iter().rev() {
847        if let Some(ref desc) = activity.activity_description {
848            return Some(desc.clone());
849        }
850    }
851    None
852}
853
854/// Description of an activity.
855#[derive(Debug, Clone, Serialize, Deserialize)]
856pub struct ActivityDescription {
857    #[serde(rename = "activityDescription", skip_serializing_if = "Option::is_none")]
858    pub activity_description: Option<String>,
859    #[serde(rename = "isSearch", skip_serializing_if = "Option::is_none")]
860    pub is_search: Option<bool>,
861    #[serde(rename = "isRead", skip_serializing_if = "Option::is_none")]
862    pub is_read: Option<bool>,
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    #[test]
870    fn test_get_tool_search_or_read_info_repl() {
871        let info = get_tool_search_or_read_info(REPL_TOOL_NAME, &serde_json::json!({}));
872        assert!(info.is_collapsible);
873        assert!(info.is_repl);
874        assert!(info.is_absorbed_silently);
875    }
876
877    #[test]
878    fn test_get_tool_search_or_read_info_grep() {
879        let info = get_tool_search_or_read_info(
880            GREP_TOOL_NAME,
881            &serde_json::json!({"pattern": "foo", "path": "."}),
882        );
883        assert!(info.is_collapsible);
884        assert!(info.is_search);
885    }
886
887    #[test]
888    fn test_get_tool_search_or_read_info_read() {
889        let info = get_tool_search_or_read_info(
890            READ_TOOL_NAME,
891            &serde_json::json!({"file_path": "test.txt"}),
892        );
893        assert!(info.is_collapsible);
894        assert!(info.is_read);
895    }
896
897    #[test]
898    fn test_command_as_hint() {
899        let hint = command_as_hint("ls -la /some/path");
900        assert!(hint.starts_with("$ "));
901    }
902
903    #[test]
904    fn test_command_as_hint_truncation() {
905        let long_cmd = "x".repeat(400);
906        let hint = command_as_hint(&long_cmd);
907        assert!(hint.len() <= MAX_HINT_CHARS);
908        assert!(hint.ends_with("..."));
909    }
910
911    #[test]
912    fn test_get_search_read_summary_text() {
913        let summary = get_search_read_summary_text(3, 2, false, 0, None, 0);
914        assert!(summary.contains("Searched for 3 patterns"));
915        assert!(summary.contains("read 2 files"));
916    }
917
918    #[test]
919    fn test_get_search_read_summary_text_active() {
920        let summary = get_search_read_summary_text(1, 1, true, 0, None, 0);
921        assert!(summary.ends_with("..."));
922    }
923
924    #[test]
925    fn test_summarize_recent_activities_multiple_searches() {
926        let activities = vec![
927            ActivityDescription {
928                activity_description: Some("Doing something".to_string()),
929                is_search: Some(false),
930                is_read: Some(false),
931            },
932            ActivityDescription {
933                activity_description: None,
934                is_search: Some(true),
935                is_read: Some(false),
936            },
937            ActivityDescription {
938                activity_description: None,
939                is_search: Some(true),
940                is_read: Some(false),
941            },
942        ];
943        let summary = summarize_recent_activities(&activities);
944        assert!(summary.is_some());
945        let s = summary.unwrap();
946        assert!(s.contains("Searching for 2 patterns"));
947    }
948
949    #[test]
950    fn test_summarize_recent_activities_empty() {
951        assert!(summarize_recent_activities(&[]).is_none());
952    }
953}