1#![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
16const 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#[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 pub is_memory_write: bool,
37 pub is_absorbed_silently: bool,
40 pub mcp_server_name: Option<String>,
42 pub is_bash: Option<bool>,
44}
45
46#[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
61fn 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
71fn 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
91fn 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
102const MAX_HINT_CHARS: usize = 300;
104
105fn 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
126fn 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
133fn is_fullscreen_env_enabled() -> bool {
135 std::env::var("AI_FULLSCREEN")
136 .map(|v| v == "1")
137 .unwrap_or(false)
138}
139
140fn is_auto_managed_memory_file(path: &str) -> bool {
142 path.contains(".ai/memory") || path.contains(".ai/AI.md")
143}
144
145fn is_memory_directory(path: &str) -> bool {
147 path.contains(".ai/memory") || path.ends_with(".ai/memory")
148}
149
150fn is_shell_command_targeting_memory(command: &str) -> bool {
152 command.contains(".ai/memory") || command.contains("AI.md")
153}
154
155fn is_auto_managed_memory_pattern(pattern: &str) -> bool {
157 pattern.contains(".ai/memory") || pattern.contains("AI.md")
158}
159
160fn 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
171fn 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
180fn get_display_path(path: &str) -> String {
182 path.to_string()
184}
185
186pub fn get_tool_search_or_read_info(
189 tool_name: &str,
190 tool_input: &serde_json::Value,
191) -> SearchOrReadResult {
192 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 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 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 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 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 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 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 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
300fn parse_and_classify_mcp_tool(tool_name: &str) -> Option<McpCollapseInfo> {
305 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
340fn 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
351fn 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
357pub 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
386fn 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
391fn get_tool_use_ids_from_message(msg: &serde_json::Value) -> Vec<String> {
393 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
401fn 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
414struct 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 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_call_count: usize,
440 mcp_server_names: HashSet<String>,
441 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
478pub 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 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 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
550fn 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 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 } else if let Some(ref mcp_server) = info.mcp_server_name {
575 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 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 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 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
643fn 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
656fn 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
680pub 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 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#[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
812pub fn summarize_recent_activities(
814 activities: &[ActivityDescription],
815) -> Option<String> {
816 if activities.is_empty() {
817 return None;
818 }
819
820 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 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#[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}