source dump of claude code
at main 1109 lines 38 kB view raw
1import { feature } from 'bun:bundle' 2import type { UUID } from 'crypto' 3import { findToolByName, type Tools } from '../Tool.js' 4import { extractBashCommentLabel } from '../tools/BashTool/commentLabel.js' 5import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' 6import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js' 7import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js' 8import { REPL_TOOL_NAME } from '../tools/REPLTool/constants.js' 9import { getReplPrimitiveTools } from '../tools/REPLTool/primitiveTools.js' 10import { 11 type BranchAction, 12 type CommitKind, 13 detectGitOperation, 14 type PrAction, 15} from '../tools/shared/gitOperationTracking.js' 16import { TOOL_SEARCH_TOOL_NAME } from '../tools/ToolSearchTool/prompt.js' 17import type { 18 CollapsedReadSearchGroup, 19 CollapsibleMessage, 20 RenderableMessage, 21 StopHookInfo, 22 SystemStopHookSummaryMessage, 23} from '../types/message.js' 24import { getDisplayPath } from './file.js' 25import { isFullscreenEnvEnabled } from './fullscreen.js' 26import { 27 isAutoManagedMemoryFile, 28 isAutoManagedMemoryPattern, 29 isMemoryDirectory, 30 isShellCommandTargetingMemory, 31} from './memoryFileDetection.js' 32 33/* eslint-disable @typescript-eslint/no-require-imports */ 34const teamMemOps = feature('TEAMMEM') 35 ? (require('./teamMemoryOps.js') as typeof import('./teamMemoryOps.js')) 36 : null 37const SNIP_TOOL_NAME = feature('HISTORY_SNIP') 38 ? ( 39 require('../tools/SnipTool/prompt.js') as typeof import('../tools/SnipTool/prompt.js') 40 ).SNIP_TOOL_NAME 41 : null 42/* eslint-enable @typescript-eslint/no-require-imports */ 43 44/** 45 * Result of checking if a tool use is a search or read operation. 46 */ 47export type SearchOrReadResult = { 48 isCollapsible: boolean 49 isSearch: boolean 50 isRead: boolean 51 isList: boolean 52 isREPL: boolean 53 /** True if this is a Write/Edit targeting a memory file */ 54 isMemoryWrite: boolean 55 /** 56 * True for meta-operations that should be absorbed into a collapse group 57 * without incrementing any count (Snip, ToolSearch). They remain visible 58 * in verbose mode via the groupMessages iteration. 59 */ 60 isAbsorbedSilently: boolean 61 /** MCP server name when this is an MCP tool */ 62 mcpServerName?: string 63 /** Bash command that is NOT a search/read (under fullscreen mode) */ 64 isBash?: boolean 65} 66 67/** 68 * Extract the primary file/directory path from a tool_use input. 69 * Handles both `file_path` (Read/Write/Edit) and `path` (Grep/Glob). 70 */ 71function getFilePathFromToolInput(toolInput: unknown): string | undefined { 72 const input = toolInput as 73 | { file_path?: string; path?: string; pattern?: string; glob?: string } 74 | undefined 75 return input?.file_path ?? input?.path 76} 77 78/** 79 * Check if a search tool use targets memory files by examining its path, pattern, and glob. 80 */ 81function isMemorySearch(toolInput: unknown): boolean { 82 const input = toolInput as 83 | { path?: string; pattern?: string; glob?: string; command?: string } 84 | undefined 85 if (!input) { 86 return false 87 } 88 // Check if the search path targets a memory file or directory (Grep/Glob tools) 89 if (input.path) { 90 if (isAutoManagedMemoryFile(input.path) || isMemoryDirectory(input.path)) { 91 return true 92 } 93 } 94 // Check glob patterns that indicate memory file access 95 if (input.glob && isAutoManagedMemoryPattern(input.glob)) { 96 return true 97 } 98 // For shell commands (bash grep/rg, PowerShell Select-String, etc.), 99 // check if the command targets memory paths 100 if (input.command && isShellCommandTargetingMemory(input.command)) { 101 return true 102 } 103 return false 104} 105 106/** 107 * Check if a Write or Edit tool use targets a memory file and should be collapsed. 108 */ 109function isMemoryWriteOrEdit(toolName: string, toolInput: unknown): boolean { 110 if (toolName !== FILE_WRITE_TOOL_NAME && toolName !== FILE_EDIT_TOOL_NAME) { 111 return false 112 } 113 const filePath = getFilePathFromToolInput(toolInput) 114 return filePath !== undefined && isAutoManagedMemoryFile(filePath) 115} 116 117// ~5 lines × ~60 cols. Generous static cap — the renderer lets Ink wrap. 118const MAX_HINT_CHARS = 300 119 120/** 121 * Format a bash command for the ⎿ hint. Drops blank lines, collapses runs of 122 * inline whitespace, then caps total length. Newlines are preserved so the 123 * renderer can indent continuation lines under ⎿. 124 */ 125function commandAsHint(command: string): string { 126 const cleaned = 127 '$ ' + 128 command 129 .split('\n') 130 .map(l => l.replace(/\s+/g, ' ').trim()) 131 .filter(l => l !== '') 132 .join('\n') 133 return cleaned.length > MAX_HINT_CHARS 134 ? cleaned.slice(0, MAX_HINT_CHARS - 1) + '…' 135 : cleaned 136} 137 138/** 139 * Checks if a tool is a search/read operation using the tool's isSearchOrReadCommand method. 140 * Also treats Write/Edit of memory files as collapsible. 141 * Returns detailed information about whether it's a search or read operation. 142 */ 143export function getToolSearchOrReadInfo( 144 toolName: string, 145 toolInput: unknown, 146 tools: Tools, 147): SearchOrReadResult { 148 // REPL is absorbed silently — its inner tool calls are emitted as virtual 149 // messages (isVirtual: true) via newMessages and flow through this function 150 // as regular Read/Grep/Bash messages. The REPL wrapper itself contributes 151 // no counts and doesn't break the group, so consecutive REPL calls merge. 152 if (toolName === REPL_TOOL_NAME) { 153 return { 154 isCollapsible: true, 155 isSearch: false, 156 isRead: false, 157 isList: false, 158 isREPL: true, 159 isMemoryWrite: false, 160 isAbsorbedSilently: true, 161 } 162 } 163 164 // Memory file writes/edits are collapsible 165 if (isMemoryWriteOrEdit(toolName, toolInput)) { 166 return { 167 isCollapsible: true, 168 isSearch: false, 169 isRead: false, 170 isList: false, 171 isREPL: false, 172 isMemoryWrite: true, 173 isAbsorbedSilently: false, 174 } 175 } 176 177 // Meta-operations absorbed silently: Snip (context cleanup) and ToolSearch 178 // (lazy tool schema loading). Neither should break a collapse group or 179 // contribute to its count, but both stay visible in verbose mode. 180 if ( 181 (feature('HISTORY_SNIP') && toolName === SNIP_TOOL_NAME) || 182 (isFullscreenEnvEnabled() && toolName === TOOL_SEARCH_TOOL_NAME) 183 ) { 184 return { 185 isCollapsible: true, 186 isSearch: false, 187 isRead: false, 188 isList: false, 189 isREPL: false, 190 isMemoryWrite: false, 191 isAbsorbedSilently: true, 192 } 193 } 194 195 // Fallback to REPL primitives: in REPL mode, Bash/Read/Grep/etc. are 196 // stripped from the execution tools list, but REPL emits them as virtual 197 // messages. Without the fallback they'd return isCollapsible: false and 198 // vanish from the summary line. 199 const tool = 200 findToolByName(tools, toolName) ?? 201 findToolByName(getReplPrimitiveTools(), toolName) 202 if (!tool?.isSearchOrReadCommand) { 203 return { 204 isCollapsible: false, 205 isSearch: false, 206 isRead: false, 207 isList: false, 208 isREPL: false, 209 isMemoryWrite: false, 210 isAbsorbedSilently: false, 211 } 212 } 213 // The tool's isSearchOrReadCommand method handles its own input validation via safeParse, 214 // so passing the raw input is safe. The type assertion is necessary because Tool[] uses 215 // the default generic which expects { [x: string]: any }, but we receive unknown at runtime. 216 const result = tool.isSearchOrReadCommand( 217 toolInput as { [x: string]: unknown }, 218 ) 219 const isList = result.isList ?? false 220 const isCollapsible = result.isSearch || result.isRead || isList 221 // Under fullscreen mode, non-search/read Bash commands are also collapsible 222 // as their own category — "Ran N bash commands" instead of breaking the group. 223 return { 224 isCollapsible: 225 isCollapsible || 226 (isFullscreenEnvEnabled() ? toolName === BASH_TOOL_NAME : false), 227 isSearch: result.isSearch, 228 isRead: result.isRead, 229 isList, 230 isREPL: false, 231 isMemoryWrite: false, 232 isAbsorbedSilently: false, 233 ...(tool.isMcp && { mcpServerName: tool.mcpInfo?.serverName }), 234 isBash: isFullscreenEnvEnabled() 235 ? !isCollapsible && toolName === BASH_TOOL_NAME 236 : undefined, 237 } 238} 239 240/** 241 * Check if a tool_use content block is a search/read operation. 242 * Returns { isSearch, isRead, isREPL } if it's a collapsible search/read, null otherwise. 243 */ 244export function getSearchOrReadFromContent( 245 content: { type: string; name?: string; input?: unknown } | undefined, 246 tools: Tools, 247): { 248 isSearch: boolean 249 isRead: boolean 250 isList: boolean 251 isREPL: boolean 252 isMemoryWrite: boolean 253 isAbsorbedSilently: boolean 254 mcpServerName?: string 255 isBash?: boolean 256} | null { 257 if (content?.type === 'tool_use' && content.name) { 258 const info = getToolSearchOrReadInfo(content.name, content.input, tools) 259 if (info.isCollapsible || info.isREPL) { 260 return { 261 isSearch: info.isSearch, 262 isRead: info.isRead, 263 isList: info.isList, 264 isREPL: info.isREPL, 265 isMemoryWrite: info.isMemoryWrite, 266 isAbsorbedSilently: info.isAbsorbedSilently, 267 mcpServerName: info.mcpServerName, 268 isBash: info.isBash, 269 } 270 } 271 } 272 return null 273} 274 275/** 276 * Checks if a tool is a search/read operation (for backwards compatibility). 277 */ 278function isToolSearchOrRead( 279 toolName: string, 280 toolInput: unknown, 281 tools: Tools, 282): boolean { 283 return getToolSearchOrReadInfo(toolName, toolInput, tools).isCollapsible 284} 285 286/** 287 * Get the tool name, input, and search/read info from a message if it's a collapsible tool use. 288 * Returns null if the message is not a collapsible tool use. 289 */ 290function getCollapsibleToolInfo( 291 msg: RenderableMessage, 292 tools: Tools, 293): { 294 name: string 295 input: unknown 296 isSearch: boolean 297 isRead: boolean 298 isList: boolean 299 isREPL: boolean 300 isMemoryWrite: boolean 301 isAbsorbedSilently: boolean 302 mcpServerName?: string 303 isBash?: boolean 304} | null { 305 if (msg.type === 'assistant') { 306 const content = msg.message.content[0] 307 const info = getSearchOrReadFromContent(content, tools) 308 if (info && content?.type === 'tool_use') { 309 return { name: content.name, input: content.input, ...info } 310 } 311 } 312 if (msg.type === 'grouped_tool_use') { 313 // For grouped tool uses, check the first message's input 314 const firstContent = msg.messages[0]?.message.content[0] 315 const info = getSearchOrReadFromContent( 316 firstContent 317 ? { type: 'tool_use', name: msg.toolName, input: firstContent.input } 318 : undefined, 319 tools, 320 ) 321 if (info && firstContent?.type === 'tool_use') { 322 return { name: msg.toolName, input: firstContent.input, ...info } 323 } 324 } 325 return null 326} 327 328/** 329 * Check if a message is assistant text that should break a group. 330 */ 331function isTextBreaker(msg: RenderableMessage): boolean { 332 if (msg.type === 'assistant') { 333 const content = msg.message.content[0] 334 if (content?.type === 'text' && content.text.trim().length > 0) { 335 return true 336 } 337 } 338 return false 339} 340 341/** 342 * Check if a message is a non-collapsible tool use that should break a group. 343 * This includes tool uses like Edit, Write, etc. 344 */ 345function isNonCollapsibleToolUse( 346 msg: RenderableMessage, 347 tools: Tools, 348): boolean { 349 if (msg.type === 'assistant') { 350 const content = msg.message.content[0] 351 if ( 352 content?.type === 'tool_use' && 353 !isToolSearchOrRead(content.name, content.input, tools) 354 ) { 355 return true 356 } 357 } 358 if (msg.type === 'grouped_tool_use') { 359 const firstContent = msg.messages[0]?.message.content[0] 360 if ( 361 firstContent?.type === 'tool_use' && 362 !isToolSearchOrRead(msg.toolName, firstContent.input, tools) 363 ) { 364 return true 365 } 366 } 367 return false 368} 369 370function isPreToolHookSummary( 371 msg: RenderableMessage, 372): msg is SystemStopHookSummaryMessage { 373 return ( 374 msg.type === 'system' && 375 msg.subtype === 'stop_hook_summary' && 376 msg.hookLabel === 'PreToolUse' 377 ) 378} 379 380/** 381 * Check if a message should be skipped (not break the group, just passed through). 382 * This includes thinking blocks, redacted thinking, attachments, etc. 383 */ 384function shouldSkipMessage(msg: RenderableMessage): boolean { 385 if (msg.type === 'assistant') { 386 const content = msg.message.content[0] 387 // Skip thinking blocks and other non-text, non-tool content 388 if (content?.type === 'thinking' || content?.type === 'redacted_thinking') { 389 return true 390 } 391 } 392 // Skip attachment messages 393 if (msg.type === 'attachment') { 394 return true 395 } 396 // Skip system messages 397 if (msg.type === 'system') { 398 return true 399 } 400 return false 401} 402 403/** 404 * Type predicate: Check if a message is a collapsible tool use. 405 */ 406function isCollapsibleToolUse( 407 msg: RenderableMessage, 408 tools: Tools, 409): msg is CollapsibleMessage { 410 if (msg.type === 'assistant') { 411 const content = msg.message.content[0] 412 return ( 413 content?.type === 'tool_use' && 414 isToolSearchOrRead(content.name, content.input, tools) 415 ) 416 } 417 if (msg.type === 'grouped_tool_use') { 418 const firstContent = msg.messages[0]?.message.content[0] 419 return ( 420 firstContent?.type === 'tool_use' && 421 isToolSearchOrRead(msg.toolName, firstContent.input, tools) 422 ) 423 } 424 return false 425} 426 427/** 428 * Type predicate: Check if a message is a tool result for collapsible tools. 429 * Returns true if ALL tool results in the message are for tracked collapsible tools. 430 */ 431function isCollapsibleToolResult( 432 msg: RenderableMessage, 433 collapsibleToolUseIds: Set<string>, 434): msg is CollapsibleMessage { 435 if (msg.type === 'user') { 436 const toolResults = msg.message.content.filter( 437 (c): c is { type: 'tool_result'; tool_use_id: string } => 438 c.type === 'tool_result', 439 ) 440 // Only return true if there are tool results AND all of them are for collapsible tools 441 return ( 442 toolResults.length > 0 && 443 toolResults.every(r => collapsibleToolUseIds.has(r.tool_use_id)) 444 ) 445 } 446 return false 447} 448 449/** 450 * Get all tool use IDs from a single message (handles grouped tool uses). 451 */ 452function getToolUseIdsFromMessage(msg: RenderableMessage): string[] { 453 if (msg.type === 'assistant') { 454 const content = msg.message.content[0] 455 if (content?.type === 'tool_use') { 456 return [content.id] 457 } 458 } 459 if (msg.type === 'grouped_tool_use') { 460 return msg.messages 461 .map(m => { 462 const content = m.message.content[0] 463 return content.type === 'tool_use' ? content.id : '' 464 }) 465 .filter(Boolean) 466 } 467 return [] 468} 469 470/** 471 * Get all tool use IDs from a collapsed read/search group. 472 */ 473export function getToolUseIdsFromCollapsedGroup( 474 message: CollapsedReadSearchGroup, 475): string[] { 476 const ids: string[] = [] 477 for (const msg of message.messages) { 478 ids.push(...getToolUseIdsFromMessage(msg)) 479 } 480 return ids 481} 482 483/** 484 * Check if any tool in a collapsed group is in progress. 485 */ 486export function hasAnyToolInProgress( 487 message: CollapsedReadSearchGroup, 488 inProgressToolUseIDs: Set<string>, 489): boolean { 490 return getToolUseIdsFromCollapsedGroup(message).some(id => 491 inProgressToolUseIDs.has(id), 492 ) 493} 494 495/** 496 * Get the underlying NormalizedMessage for display (timestamp/model). 497 * Handles nested GroupedToolUseMessage within collapsed groups. 498 * Returns a NormalizedAssistantMessage or NormalizedUserMessage (never GroupedToolUseMessage). 499 */ 500export function getDisplayMessageFromCollapsed( 501 message: CollapsedReadSearchGroup, 502): Exclude<CollapsibleMessage, { type: 'grouped_tool_use' }> { 503 const firstMsg = message.displayMessage 504 if (firstMsg.type === 'grouped_tool_use') { 505 return firstMsg.displayMessage 506 } 507 return firstMsg 508} 509 510/** 511 * Count the number of tool uses in a message (handles grouped tool uses). 512 */ 513function countToolUses(msg: RenderableMessage): number { 514 if (msg.type === 'grouped_tool_use') { 515 return msg.messages.length 516 } 517 return 1 518} 519 520/** 521 * Extract file paths from read tool inputs in a message. 522 * Returns an array of file paths (may have duplicates if same file is read multiple times in one grouped message). 523 */ 524function getFilePathsFromReadMessage(msg: RenderableMessage): string[] { 525 const paths: string[] = [] 526 527 if (msg.type === 'assistant') { 528 const content = msg.message.content[0] 529 if (content?.type === 'tool_use') { 530 const input = content.input as { file_path?: string } | undefined 531 if (input?.file_path) { 532 paths.push(input.file_path) 533 } 534 } 535 } else if (msg.type === 'grouped_tool_use') { 536 for (const m of msg.messages) { 537 const content = m.message.content[0] 538 if (content?.type === 'tool_use') { 539 const input = content.input as { file_path?: string } | undefined 540 if (input?.file_path) { 541 paths.push(input.file_path) 542 } 543 } 544 } 545 } 546 547 return paths 548} 549 550/** 551 * Scan a bash tool result for commit SHAs and PR URLs and push them into the 552 * group accumulator. Called only for results whose tool_use_id was recorded 553 * in bashCommands (non-search/read bash). 554 */ 555function scanBashResultForGitOps( 556 msg: CollapsibleMessage, 557 group: GroupAccumulator, 558): void { 559 if (msg.type !== 'user') return 560 const out = msg.toolUseResult as 561 | { stdout?: string; stderr?: string } 562 | undefined 563 if (!out?.stdout && !out?.stderr) return 564 // git push writes the ref update to stderr — scan both streams. 565 const combined = (out.stdout ?? '') + '\n' + (out.stderr ?? '') 566 for (const c of msg.message.content) { 567 if (c.type !== 'tool_result') continue 568 const command = group.bashCommands?.get(c.tool_use_id) 569 if (!command) continue 570 const { commit, push, branch, pr } = detectGitOperation(command, combined) 571 if (commit) group.commits?.push(commit) 572 if (push) group.pushes?.push(push) 573 if (branch) group.branches?.push(branch) 574 if (pr) group.prs?.push(pr) 575 if (commit || push || branch || pr) { 576 group.gitOpBashCount = (group.gitOpBashCount ?? 0) + 1 577 } 578 } 579} 580 581type GroupAccumulator = { 582 messages: CollapsibleMessage[] 583 searchCount: number 584 readFilePaths: Set<string> 585 // Count of read operations that don't have file paths (e.g., Bash cat commands) 586 readOperationCount: number 587 // Count of directory-listing operations (ls, tree, du) 588 listCount: number 589 toolUseIds: Set<string> 590 // Memory file operation counts (tracked separately from regular counts) 591 memorySearchCount: number 592 memoryReadFilePaths: Set<string> 593 memoryWriteCount: number 594 // Team memory file operation counts (tracked separately) 595 teamMemorySearchCount?: number 596 teamMemoryReadFilePaths?: Set<string> 597 teamMemoryWriteCount?: number 598 // Non-memory search patterns for display beneath the collapsed summary 599 nonMemSearchArgs: string[] 600 /** Most recently added non-memory operation, pre-formatted for display */ 601 latestDisplayHint: string | undefined 602 // MCP tool calls (tracked separately so display says "Queried slack" not "Read N files") 603 mcpCallCount?: number 604 mcpServerNames?: Set<string> 605 // Bash commands that aren't search/read (tracked separately for "Ran N bash commands") 606 bashCount?: number 607 // Bash tool_use_id → command string, so tool results can be scanned for 608 // commit SHAs / PR URLs (surfaced as "committed abc123, created PR #42") 609 bashCommands?: Map<string, string> 610 commits?: { sha: string; kind: CommitKind }[] 611 pushes?: { branch: string }[] 612 branches?: { ref: string; action: BranchAction }[] 613 prs?: { number: number; url?: string; action: PrAction }[] 614 gitOpBashCount?: number 615 // PreToolUse hook timing absorbed from hook summary messages 616 hookTotalMs: number 617 hookCount: number 618 hookInfos: StopHookInfo[] 619 // relevant_memories attachments absorbed into this group (auto-injected 620 // memories, not explicit Read calls). Paths mirrored into readFilePaths + 621 // memoryReadFilePaths so the inline "recalled N memories" text is accurate. 622 relevantMemories?: { path: string; content: string; mtimeMs: number }[] 623} 624 625function createEmptyGroup(): GroupAccumulator { 626 const group: GroupAccumulator = { 627 messages: [], 628 searchCount: 0, 629 readFilePaths: new Set(), 630 readOperationCount: 0, 631 listCount: 0, 632 toolUseIds: new Set(), 633 memorySearchCount: 0, 634 memoryReadFilePaths: new Set(), 635 memoryWriteCount: 0, 636 nonMemSearchArgs: [], 637 latestDisplayHint: undefined, 638 hookTotalMs: 0, 639 hookCount: 0, 640 hookInfos: [], 641 } 642 if (feature('TEAMMEM')) { 643 group.teamMemorySearchCount = 0 644 group.teamMemoryReadFilePaths = new Set() 645 group.teamMemoryWriteCount = 0 646 } 647 group.mcpCallCount = 0 648 group.mcpServerNames = new Set() 649 if (isFullscreenEnvEnabled()) { 650 group.bashCount = 0 651 group.bashCommands = new Map() 652 group.commits = [] 653 group.pushes = [] 654 group.branches = [] 655 group.prs = [] 656 group.gitOpBashCount = 0 657 } 658 return group 659} 660 661function createCollapsedGroup( 662 group: GroupAccumulator, 663): CollapsedReadSearchGroup { 664 const firstMsg = group.messages[0]! 665 // When file-path-based reads exist, use unique file count (Set.size) only. 666 // Adding bash operation count on top would double-count — e.g. Read(README.md) 667 // followed by Bash(wc -l README.md) should still show as 1 file, not 2. 668 // Fall back to operation count only when there are no file-path reads (bash-only). 669 const totalReadCount = 670 group.readFilePaths.size > 0 671 ? group.readFilePaths.size 672 : group.readOperationCount 673 // memoryReadFilePaths ⊆ readFilePaths (both populated from Read tool calls), 674 // so this count is safe to subtract from totalReadCount at readCount below. 675 // Absorbed relevant_memories attachments are NOT in readFilePaths — added 676 // separately after the subtraction so readCount stays correct. 677 const toolMemoryReadCount = group.memoryReadFilePaths.size 678 const memoryReadCount = 679 toolMemoryReadCount + (group.relevantMemories?.length ?? 0) 680 // Non-memory read file paths: exclude memory and team memory paths 681 const teamMemReadPaths = feature('TEAMMEM') 682 ? group.teamMemoryReadFilePaths 683 : undefined 684 const nonMemReadFilePaths = [...group.readFilePaths].filter( 685 p => 686 !group.memoryReadFilePaths.has(p) && !(teamMemReadPaths?.has(p) ?? false), 687 ) 688 const teamMemSearchCount = feature('TEAMMEM') 689 ? (group.teamMemorySearchCount ?? 0) 690 : 0 691 const teamMemReadCount = feature('TEAMMEM') 692 ? (group.teamMemoryReadFilePaths?.size ?? 0) 693 : 0 694 const teamMemWriteCount = feature('TEAMMEM') 695 ? (group.teamMemoryWriteCount ?? 0) 696 : 0 697 const result: CollapsedReadSearchGroup = { 698 type: 'collapsed_read_search', 699 // Subtract memory + team memory counts so regular counts only reflect non-memory operations 700 searchCount: Math.max( 701 0, 702 group.searchCount - group.memorySearchCount - teamMemSearchCount, 703 ), 704 readCount: Math.max( 705 0, 706 totalReadCount - toolMemoryReadCount - teamMemReadCount, 707 ), 708 listCount: group.listCount, 709 // REPL operations are intentionally not collapsed (see isCollapsible: false at line 32), 710 // so replCount in collapsed groups is always 0. The replCount field is kept for 711 // sub-agent progress display in AgentTool/UI.tsx which has a separate code path. 712 replCount: 0, 713 memorySearchCount: group.memorySearchCount, 714 memoryReadCount, 715 memoryWriteCount: group.memoryWriteCount, 716 readFilePaths: nonMemReadFilePaths, 717 searchArgs: group.nonMemSearchArgs, 718 latestDisplayHint: group.latestDisplayHint, 719 messages: group.messages, 720 displayMessage: firstMsg, 721 uuid: `collapsed-${firstMsg.uuid}` as UUID, 722 timestamp: firstMsg.timestamp, 723 } 724 if (feature('TEAMMEM')) { 725 result.teamMemorySearchCount = teamMemSearchCount 726 result.teamMemoryReadCount = teamMemReadCount 727 result.teamMemoryWriteCount = teamMemWriteCount 728 } 729 if ((group.mcpCallCount ?? 0) > 0) { 730 result.mcpCallCount = group.mcpCallCount 731 result.mcpServerNames = [...(group.mcpServerNames ?? [])] 732 } 733 if (isFullscreenEnvEnabled()) { 734 if ((group.bashCount ?? 0) > 0) { 735 result.bashCount = group.bashCount 736 result.gitOpBashCount = group.gitOpBashCount 737 } 738 if ((group.commits?.length ?? 0) > 0) result.commits = group.commits 739 if ((group.pushes?.length ?? 0) > 0) result.pushes = group.pushes 740 if ((group.branches?.length ?? 0) > 0) result.branches = group.branches 741 if ((group.prs?.length ?? 0) > 0) result.prs = group.prs 742 } 743 if (group.hookCount > 0) { 744 result.hookTotalMs = group.hookTotalMs 745 result.hookCount = group.hookCount 746 result.hookInfos = group.hookInfos 747 } 748 if (group.relevantMemories && group.relevantMemories.length > 0) { 749 result.relevantMemories = group.relevantMemories 750 } 751 return result 752} 753 754/** 755 * Collapse consecutive Read/Search operations into summary groups. 756 * 757 * Rules: 758 * - Groups consecutive search/read tool uses (Grep, Glob, Read, and Bash search/read commands) 759 * - Includes their corresponding tool results in the group 760 * - Breaks groups when assistant text appears 761 */ 762export function collapseReadSearchGroups( 763 messages: RenderableMessage[], 764 tools: Tools, 765): RenderableMessage[] { 766 const result: RenderableMessage[] = [] 767 let currentGroup = createEmptyGroup() 768 let deferredSkippable: RenderableMessage[] = [] 769 770 function flushGroup(): void { 771 if (currentGroup.messages.length === 0) { 772 return 773 } 774 result.push(createCollapsedGroup(currentGroup)) 775 for (const deferred of deferredSkippable) { 776 result.push(deferred) 777 } 778 deferredSkippable = [] 779 currentGroup = createEmptyGroup() 780 } 781 782 for (const msg of messages) { 783 if (isCollapsibleToolUse(msg, tools)) { 784 // This is a collapsible tool use - type predicate narrows to CollapsibleMessage 785 const toolInfo = getCollapsibleToolInfo(msg, tools)! 786 787 if (toolInfo.isMemoryWrite) { 788 // Memory file write/edit — check if it's team memory 789 const count = countToolUses(msg) 790 if ( 791 feature('TEAMMEM') && 792 teamMemOps?.isTeamMemoryWriteOrEdit(toolInfo.name, toolInfo.input) 793 ) { 794 currentGroup.teamMemoryWriteCount = 795 (currentGroup.teamMemoryWriteCount ?? 0) + count 796 } else { 797 currentGroup.memoryWriteCount += count 798 } 799 } else if (toolInfo.isAbsorbedSilently) { 800 // Snip/ToolSearch absorbed silently — no count, no summary text. 801 // Hidden from the default view but still shown in verbose mode 802 // (Ctrl+O) via the groupMessages iteration in CollapsedReadSearchContent. 803 } else if (toolInfo.mcpServerName) { 804 // MCP search/read — counted separately so the summary says 805 // "Queried slack N times" instead of "Read N files". 806 const count = countToolUses(msg) 807 currentGroup.mcpCallCount = (currentGroup.mcpCallCount ?? 0) + count 808 currentGroup.mcpServerNames?.add(toolInfo.mcpServerName) 809 const input = toolInfo.input as { query?: string } | undefined 810 if (input?.query) { 811 currentGroup.latestDisplayHint = `"${input.query}"` 812 } 813 } else if (isFullscreenEnvEnabled() && toolInfo.isBash) { 814 // Non-search/read Bash command — counted separately so the summary 815 // says "Ran N bash commands" instead of breaking the group. 816 const count = countToolUses(msg) 817 currentGroup.bashCount = (currentGroup.bashCount ?? 0) + count 818 const input = toolInfo.input as { command?: string } | undefined 819 if (input?.command) { 820 // Prefer the stripped `# comment` if present (it's what Claude wrote 821 // for the human — same trigger as the comment-as-label tool-use render). 822 currentGroup.latestDisplayHint = 823 extractBashCommentLabel(input.command) ?? 824 commandAsHint(input.command) 825 // Remember tool_use_id → command so the result (arriving next) can 826 // be scanned for commit SHA / PR URL. 827 for (const id of getToolUseIdsFromMessage(msg)) { 828 currentGroup.bashCommands?.set(id, input.command) 829 } 830 } 831 } else if (toolInfo.isList) { 832 // Directory-listing bash commands (ls, tree, du) — counted separately 833 // so the summary says "Listed N directories" instead of "Read N files". 834 currentGroup.listCount += countToolUses(msg) 835 const input = toolInfo.input as { command?: string } | undefined 836 if (input?.command) { 837 currentGroup.latestDisplayHint = commandAsHint(input.command) 838 } 839 } else if (toolInfo.isSearch) { 840 // Use the isSearch flag from the tool to properly categorize bash search commands 841 const count = countToolUses(msg) 842 currentGroup.searchCount += count 843 // Check if the search targets memory files (via path or glob pattern) 844 if ( 845 feature('TEAMMEM') && 846 teamMemOps?.isTeamMemorySearch(toolInfo.input) 847 ) { 848 currentGroup.teamMemorySearchCount = 849 (currentGroup.teamMemorySearchCount ?? 0) + count 850 } else if (isMemorySearch(toolInfo.input)) { 851 currentGroup.memorySearchCount += count 852 } else { 853 // Regular (non-memory) search — collect pattern for display 854 const input = toolInfo.input as { pattern?: string } | undefined 855 if (input?.pattern) { 856 currentGroup.nonMemSearchArgs.push(input.pattern) 857 currentGroup.latestDisplayHint = `"${input.pattern}"` 858 } 859 } 860 } else { 861 // For reads, track unique file paths instead of counting operations 862 const filePaths = getFilePathsFromReadMessage(msg) 863 for (const filePath of filePaths) { 864 currentGroup.readFilePaths.add(filePath) 865 if (feature('TEAMMEM') && teamMemOps?.isTeamMemFile(filePath)) { 866 currentGroup.teamMemoryReadFilePaths?.add(filePath) 867 } else if (isAutoManagedMemoryFile(filePath)) { 868 currentGroup.memoryReadFilePaths.add(filePath) 869 } else { 870 // Non-memory file read — update display hint 871 currentGroup.latestDisplayHint = getDisplayPath(filePath) 872 } 873 } 874 // If no file paths found (e.g., Bash read commands like ls, cat), count the operations 875 if (filePaths.length === 0) { 876 currentGroup.readOperationCount += countToolUses(msg) 877 // Use the Bash command as the display hint (truncated for readability) 878 const input = toolInfo.input as { command?: string } | undefined 879 if (input?.command) { 880 currentGroup.latestDisplayHint = commandAsHint(input.command) 881 } 882 } 883 } 884 885 // Track tool use IDs for matching results 886 for (const id of getToolUseIdsFromMessage(msg)) { 887 currentGroup.toolUseIds.add(id) 888 } 889 890 currentGroup.messages.push(msg) 891 } else if (isCollapsibleToolResult(msg, currentGroup.toolUseIds)) { 892 currentGroup.messages.push(msg) 893 // Scan bash results for commit SHAs / PR URLs to surface in the summary 894 if (isFullscreenEnvEnabled() && currentGroup.bashCommands?.size) { 895 scanBashResultForGitOps(msg, currentGroup) 896 } 897 } else if (currentGroup.messages.length > 0 && isPreToolHookSummary(msg)) { 898 // Absorb PreToolUse hook summaries into the group instead of deferring 899 currentGroup.hookCount += msg.hookCount 900 currentGroup.hookTotalMs += 901 msg.totalDurationMs ?? 902 msg.hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0) 903 currentGroup.hookInfos.push(...msg.hookInfos) 904 } else if ( 905 currentGroup.messages.length > 0 && 906 msg.type === 'attachment' && 907 msg.attachment.type === 'relevant_memories' 908 ) { 909 // Absorb auto-injected memory attachments so "recalled N memories" 910 // renders inline with "ran N bash commands" instead of as a separate 911 // ⏺ block. Do NOT add paths to readFilePaths/memoryReadFilePaths — 912 // that would poison the readOperationCount fallback (bash-only reads 913 // have no paths; adding memory paths makes readFilePaths.size > 0 and 914 // suppresses the fallback). createCollapsedGroup adds .length to 915 // memoryReadCount after the readCount subtraction instead. 916 currentGroup.relevantMemories ??= [] 917 currentGroup.relevantMemories.push(...msg.attachment.memories) 918 } else if (shouldSkipMessage(msg)) { 919 // Don't flush the group for skippable messages (thinking, attachments, system) 920 // If a group is in progress, defer these messages to output after the collapsed group 921 // This preserves the visual ordering where the collapsed badge appears at the position 922 // of the first tool use, not displaced by intervening skippable messages. 923 // Exception: nested_memory attachments are pushed through even during a group so 924 // ⎿ Loaded lines cluster tightly instead of being split by the badge's marginTop. 925 if ( 926 currentGroup.messages.length > 0 && 927 !(msg.type === 'attachment' && msg.attachment.type === 'nested_memory') 928 ) { 929 deferredSkippable.push(msg) 930 } else { 931 result.push(msg) 932 } 933 } else if (isTextBreaker(msg)) { 934 // Assistant text breaks the group 935 flushGroup() 936 result.push(msg) 937 } else if (isNonCollapsibleToolUse(msg, tools)) { 938 // Non-collapsible tool use breaks the group 939 flushGroup() 940 result.push(msg) 941 } else { 942 // User messages with non-collapsible tool results break the group 943 flushGroup() 944 result.push(msg) 945 } 946 } 947 948 flushGroup() 949 return result 950} 951 952/** 953 * Generate a summary text for search/read/REPL counts. 954 * @param searchCount Number of search operations 955 * @param readCount Number of read operations 956 * @param isActive Whether the group is still in progress (use present tense) or completed (use past tense) 957 * @param replCount Number of REPL executions (optional) 958 * @param memoryCounts Optional memory file operation counts 959 * @returns Summary text like "Searching for 3 patterns, reading 2 files, REPL'd 5 times…" 960 */ 961export function getSearchReadSummaryText( 962 searchCount: number, 963 readCount: number, 964 isActive: boolean, 965 replCount: number = 0, 966 memoryCounts?: { 967 memorySearchCount: number 968 memoryReadCount: number 969 memoryWriteCount: number 970 teamMemorySearchCount?: number 971 teamMemoryReadCount?: number 972 teamMemoryWriteCount?: number 973 }, 974 listCount: number = 0, 975): string { 976 const parts: string[] = [] 977 978 // Memory operations first 979 if (memoryCounts) { 980 const { memorySearchCount, memoryReadCount, memoryWriteCount } = 981 memoryCounts 982 if (memoryReadCount > 0) { 983 const verb = isActive 984 ? parts.length === 0 985 ? 'Recalling' 986 : 'recalling' 987 : parts.length === 0 988 ? 'Recalled' 989 : 'recalled' 990 parts.push( 991 `${verb} ${memoryReadCount} ${memoryReadCount === 1 ? 'memory' : 'memories'}`, 992 ) 993 } 994 if (memorySearchCount > 0) { 995 const verb = isActive 996 ? parts.length === 0 997 ? 'Searching' 998 : 'searching' 999 : parts.length === 0 1000 ? 'Searched' 1001 : 'searched' 1002 parts.push(`${verb} memories`) 1003 } 1004 if (memoryWriteCount > 0) { 1005 const verb = isActive 1006 ? parts.length === 0 1007 ? 'Writing' 1008 : 'writing' 1009 : parts.length === 0 1010 ? 'Wrote' 1011 : 'wrote' 1012 parts.push( 1013 `${verb} ${memoryWriteCount} ${memoryWriteCount === 1 ? 'memory' : 'memories'}`, 1014 ) 1015 } 1016 // Team memory operations 1017 if (feature('TEAMMEM') && teamMemOps) { 1018 teamMemOps.appendTeamMemorySummaryParts(memoryCounts, isActive, parts) 1019 } 1020 } 1021 1022 if (searchCount > 0) { 1023 const searchVerb = isActive 1024 ? parts.length === 0 1025 ? 'Searching for' 1026 : 'searching for' 1027 : parts.length === 0 1028 ? 'Searched for' 1029 : 'searched for' 1030 parts.push( 1031 `${searchVerb} ${searchCount} ${searchCount === 1 ? 'pattern' : 'patterns'}`, 1032 ) 1033 } 1034 1035 if (readCount > 0) { 1036 const readVerb = isActive 1037 ? parts.length === 0 1038 ? 'Reading' 1039 : 'reading' 1040 : parts.length === 0 1041 ? 'Read' 1042 : 'read' 1043 parts.push(`${readVerb} ${readCount} ${readCount === 1 ? 'file' : 'files'}`) 1044 } 1045 1046 if (listCount > 0) { 1047 const listVerb = isActive 1048 ? parts.length === 0 1049 ? 'Listing' 1050 : 'listing' 1051 : parts.length === 0 1052 ? 'Listed' 1053 : 'listed' 1054 parts.push( 1055 `${listVerb} ${listCount} ${listCount === 1 ? 'directory' : 'directories'}`, 1056 ) 1057 } 1058 1059 if (replCount > 0) { 1060 const replVerb = isActive ? "REPL'ing" : "REPL'd" 1061 parts.push(`${replVerb} ${replCount} ${replCount === 1 ? 'time' : 'times'}`) 1062 } 1063 1064 const text = parts.join(', ') 1065 return isActive ? `${text}` : text 1066} 1067 1068/** 1069 * Summarize a list of recent tool activities into a compact description. 1070 * Rolls up trailing consecutive search/read operations using pre-computed 1071 * isSearch/isRead classifications from recording time. Falls back to the 1072 * last activity's description for non-collapsible tool uses. 1073 */ 1074export function summarizeRecentActivities( 1075 activities: readonly { 1076 activityDescription?: string 1077 isSearch?: boolean 1078 isRead?: boolean 1079 }[], 1080): string | undefined { 1081 if (activities.length === 0) { 1082 return undefined 1083 } 1084 // Count trailing search/read activities from the end of the list 1085 let searchCount = 0 1086 let readCount = 0 1087 for (let i = activities.length - 1; i >= 0; i--) { 1088 const activity = activities[i]! 1089 if (activity.isSearch) { 1090 searchCount++ 1091 } else if (activity.isRead) { 1092 readCount++ 1093 } else { 1094 break 1095 } 1096 } 1097 const collapsibleCount = searchCount + readCount 1098 if (collapsibleCount >= 2) { 1099 return getSearchReadSummaryText(searchCount, readCount, true) 1100 } 1101 // Fall back to most recent activity with a description (some tools like 1102 // SendMessage don't implement getActivityDescription, so search backward) 1103 for (let i = activities.length - 1; i >= 0; i--) { 1104 if (activities[i]?.activityDescription) { 1105 return activities[i]!.activityDescription 1106 } 1107 } 1108 return undefined 1109}