import { feature } from 'bun:bundle' import type { UUID } from 'crypto' import { findToolByName, type Tools } from '../Tool.js' import { extractBashCommentLabel } from '../tools/BashTool/commentLabel.js' import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js' import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js' import { REPL_TOOL_NAME } from '../tools/REPLTool/constants.js' import { getReplPrimitiveTools } from '../tools/REPLTool/primitiveTools.js' import { type BranchAction, type CommitKind, detectGitOperation, type PrAction, } from '../tools/shared/gitOperationTracking.js' import { TOOL_SEARCH_TOOL_NAME } from '../tools/ToolSearchTool/prompt.js' import type { CollapsedReadSearchGroup, CollapsibleMessage, RenderableMessage, StopHookInfo, SystemStopHookSummaryMessage, } from '../types/message.js' import { getDisplayPath } from './file.js' import { isFullscreenEnvEnabled } from './fullscreen.js' import { isAutoManagedMemoryFile, isAutoManagedMemoryPattern, isMemoryDirectory, isShellCommandTargetingMemory, } from './memoryFileDetection.js' /* eslint-disable @typescript-eslint/no-require-imports */ const teamMemOps = feature('TEAMMEM') ? (require('./teamMemoryOps.js') as typeof import('./teamMemoryOps.js')) : null const SNIP_TOOL_NAME = feature('HISTORY_SNIP') ? ( require('../tools/SnipTool/prompt.js') as typeof import('../tools/SnipTool/prompt.js') ).SNIP_TOOL_NAME : null /* eslint-enable @typescript-eslint/no-require-imports */ /** * Result of checking if a tool use is a search or read operation. */ export type SearchOrReadResult = { isCollapsible: boolean isSearch: boolean isRead: boolean isList: boolean isREPL: boolean /** True if this is a Write/Edit targeting a memory file */ isMemoryWrite: boolean /** * True for meta-operations that should be absorbed into a collapse group * without incrementing any count (Snip, ToolSearch). They remain visible * in verbose mode via the groupMessages iteration. */ isAbsorbedSilently: boolean /** MCP server name when this is an MCP tool */ mcpServerName?: string /** Bash command that is NOT a search/read (under fullscreen mode) */ isBash?: boolean } /** * Extract the primary file/directory path from a tool_use input. * Handles both `file_path` (Read/Write/Edit) and `path` (Grep/Glob). */ function getFilePathFromToolInput(toolInput: unknown): string | undefined { const input = toolInput as | { file_path?: string; path?: string; pattern?: string; glob?: string } | undefined return input?.file_path ?? input?.path } /** * Check if a search tool use targets memory files by examining its path, pattern, and glob. */ function isMemorySearch(toolInput: unknown): boolean { const input = toolInput as | { path?: string; pattern?: string; glob?: string; command?: string } | undefined if (!input) { return false } // Check if the search path targets a memory file or directory (Grep/Glob tools) if (input.path) { if (isAutoManagedMemoryFile(input.path) || isMemoryDirectory(input.path)) { return true } } // Check glob patterns that indicate memory file access if (input.glob && isAutoManagedMemoryPattern(input.glob)) { return true } // For shell commands (bash grep/rg, PowerShell Select-String, etc.), // check if the command targets memory paths if (input.command && isShellCommandTargetingMemory(input.command)) { return true } return false } /** * Check if a Write or Edit tool use targets a memory file and should be collapsed. */ function isMemoryWriteOrEdit(toolName: string, toolInput: unknown): boolean { if (toolName !== FILE_WRITE_TOOL_NAME && toolName !== FILE_EDIT_TOOL_NAME) { return false } const filePath = getFilePathFromToolInput(toolInput) return filePath !== undefined && isAutoManagedMemoryFile(filePath) } // ~5 lines × ~60 cols. Generous static cap — the renderer lets Ink wrap. const MAX_HINT_CHARS = 300 /** * Format a bash command for the ⎿ hint. Drops blank lines, collapses runs of * inline whitespace, then caps total length. Newlines are preserved so the * renderer can indent continuation lines under ⎿. */ function commandAsHint(command: string): string { const cleaned = '$ ' + command .split('\n') .map(l => l.replace(/\s+/g, ' ').trim()) .filter(l => l !== '') .join('\n') return cleaned.length > MAX_HINT_CHARS ? cleaned.slice(0, MAX_HINT_CHARS - 1) + '…' : cleaned } /** * Checks if a tool is a search/read operation using the tool's isSearchOrReadCommand method. * Also treats Write/Edit of memory files as collapsible. * Returns detailed information about whether it's a search or read operation. */ export function getToolSearchOrReadInfo( toolName: string, toolInput: unknown, tools: Tools, ): SearchOrReadResult { // REPL is absorbed silently — its inner tool calls are emitted as virtual // messages (isVirtual: true) via newMessages and flow through this function // as regular Read/Grep/Bash messages. The REPL wrapper itself contributes // no counts and doesn't break the group, so consecutive REPL calls merge. if (toolName === REPL_TOOL_NAME) { return { isCollapsible: true, isSearch: false, isRead: false, isList: false, isREPL: true, isMemoryWrite: false, isAbsorbedSilently: true, } } // Memory file writes/edits are collapsible if (isMemoryWriteOrEdit(toolName, toolInput)) { return { isCollapsible: true, isSearch: false, isRead: false, isList: false, isREPL: false, isMemoryWrite: true, isAbsorbedSilently: false, } } // Meta-operations absorbed silently: Snip (context cleanup) and ToolSearch // (lazy tool schema loading). Neither should break a collapse group or // contribute to its count, but both stay visible in verbose mode. if ( (feature('HISTORY_SNIP') && toolName === SNIP_TOOL_NAME) || (isFullscreenEnvEnabled() && toolName === TOOL_SEARCH_TOOL_NAME) ) { return { isCollapsible: true, isSearch: false, isRead: false, isList: false, isREPL: false, isMemoryWrite: false, isAbsorbedSilently: true, } } // Fallback to REPL primitives: in REPL mode, Bash/Read/Grep/etc. are // stripped from the execution tools list, but REPL emits them as virtual // messages. Without the fallback they'd return isCollapsible: false and // vanish from the summary line. const tool = findToolByName(tools, toolName) ?? findToolByName(getReplPrimitiveTools(), toolName) if (!tool?.isSearchOrReadCommand) { return { isCollapsible: false, isSearch: false, isRead: false, isList: false, isREPL: false, isMemoryWrite: false, isAbsorbedSilently: false, } } // The tool's isSearchOrReadCommand method handles its own input validation via safeParse, // so passing the raw input is safe. The type assertion is necessary because Tool[] uses // the default generic which expects { [x: string]: any }, but we receive unknown at runtime. const result = tool.isSearchOrReadCommand( toolInput as { [x: string]: unknown }, ) const isList = result.isList ?? false const isCollapsible = result.isSearch || result.isRead || isList // Under fullscreen mode, non-search/read Bash commands are also collapsible // as their own category — "Ran N bash commands" instead of breaking the group. return { isCollapsible: isCollapsible || (isFullscreenEnvEnabled() ? toolName === BASH_TOOL_NAME : false), isSearch: result.isSearch, isRead: result.isRead, isList, isREPL: false, isMemoryWrite: false, isAbsorbedSilently: false, ...(tool.isMcp && { mcpServerName: tool.mcpInfo?.serverName }), isBash: isFullscreenEnvEnabled() ? !isCollapsible && toolName === BASH_TOOL_NAME : undefined, } } /** * Check if a tool_use content block is a search/read operation. * Returns { isSearch, isRead, isREPL } if it's a collapsible search/read, null otherwise. */ export function getSearchOrReadFromContent( content: { type: string; name?: string; input?: unknown } | undefined, tools: Tools, ): { isSearch: boolean isRead: boolean isList: boolean isREPL: boolean isMemoryWrite: boolean isAbsorbedSilently: boolean mcpServerName?: string isBash?: boolean } | null { if (content?.type === 'tool_use' && content.name) { const info = getToolSearchOrReadInfo(content.name, content.input, tools) if (info.isCollapsible || info.isREPL) { return { isSearch: info.isSearch, isRead: info.isRead, isList: info.isList, isREPL: info.isREPL, isMemoryWrite: info.isMemoryWrite, isAbsorbedSilently: info.isAbsorbedSilently, mcpServerName: info.mcpServerName, isBash: info.isBash, } } } return null } /** * Checks if a tool is a search/read operation (for backwards compatibility). */ function isToolSearchOrRead( toolName: string, toolInput: unknown, tools: Tools, ): boolean { return getToolSearchOrReadInfo(toolName, toolInput, tools).isCollapsible } /** * Get the tool name, input, and search/read info from a message if it's a collapsible tool use. * Returns null if the message is not a collapsible tool use. */ function getCollapsibleToolInfo( msg: RenderableMessage, tools: Tools, ): { name: string input: unknown isSearch: boolean isRead: boolean isList: boolean isREPL: boolean isMemoryWrite: boolean isAbsorbedSilently: boolean mcpServerName?: string isBash?: boolean } | null { if (msg.type === 'assistant') { const content = msg.message.content[0] const info = getSearchOrReadFromContent(content, tools) if (info && content?.type === 'tool_use') { return { name: content.name, input: content.input, ...info } } } if (msg.type === 'grouped_tool_use') { // For grouped tool uses, check the first message's input const firstContent = msg.messages[0]?.message.content[0] const info = getSearchOrReadFromContent( firstContent ? { type: 'tool_use', name: msg.toolName, input: firstContent.input } : undefined, tools, ) if (info && firstContent?.type === 'tool_use') { return { name: msg.toolName, input: firstContent.input, ...info } } } return null } /** * Check if a message is assistant text that should break a group. */ function isTextBreaker(msg: RenderableMessage): boolean { if (msg.type === 'assistant') { const content = msg.message.content[0] if (content?.type === 'text' && content.text.trim().length > 0) { return true } } return false } /** * Check if a message is a non-collapsible tool use that should break a group. * This includes tool uses like Edit, Write, etc. */ function isNonCollapsibleToolUse( msg: RenderableMessage, tools: Tools, ): boolean { if (msg.type === 'assistant') { const content = msg.message.content[0] if ( content?.type === 'tool_use' && !isToolSearchOrRead(content.name, content.input, tools) ) { return true } } if (msg.type === 'grouped_tool_use') { const firstContent = msg.messages[0]?.message.content[0] if ( firstContent?.type === 'tool_use' && !isToolSearchOrRead(msg.toolName, firstContent.input, tools) ) { return true } } return false } function isPreToolHookSummary( msg: RenderableMessage, ): msg is SystemStopHookSummaryMessage { return ( msg.type === 'system' && msg.subtype === 'stop_hook_summary' && msg.hookLabel === 'PreToolUse' ) } /** * Check if a message should be skipped (not break the group, just passed through). * This includes thinking blocks, redacted thinking, attachments, etc. */ function shouldSkipMessage(msg: RenderableMessage): boolean { if (msg.type === 'assistant') { const content = msg.message.content[0] // Skip thinking blocks and other non-text, non-tool content if (content?.type === 'thinking' || content?.type === 'redacted_thinking') { return true } } // Skip attachment messages if (msg.type === 'attachment') { return true } // Skip system messages if (msg.type === 'system') { return true } return false } /** * Type predicate: Check if a message is a collapsible tool use. */ function isCollapsibleToolUse( msg: RenderableMessage, tools: Tools, ): msg is CollapsibleMessage { if (msg.type === 'assistant') { const content = msg.message.content[0] return ( content?.type === 'tool_use' && isToolSearchOrRead(content.name, content.input, tools) ) } if (msg.type === 'grouped_tool_use') { const firstContent = msg.messages[0]?.message.content[0] return ( firstContent?.type === 'tool_use' && isToolSearchOrRead(msg.toolName, firstContent.input, tools) ) } return false } /** * Type predicate: Check if a message is a tool result for collapsible tools. * Returns true if ALL tool results in the message are for tracked collapsible tools. */ function isCollapsibleToolResult( msg: RenderableMessage, collapsibleToolUseIds: Set, ): msg is CollapsibleMessage { if (msg.type === 'user') { const toolResults = msg.message.content.filter( (c): c is { type: 'tool_result'; tool_use_id: string } => c.type === 'tool_result', ) // Only return true if there are tool results AND all of them are for collapsible tools return ( toolResults.length > 0 && toolResults.every(r => collapsibleToolUseIds.has(r.tool_use_id)) ) } return false } /** * Get all tool use IDs from a single message (handles grouped tool uses). */ function getToolUseIdsFromMessage(msg: RenderableMessage): string[] { if (msg.type === 'assistant') { const content = msg.message.content[0] if (content?.type === 'tool_use') { return [content.id] } } if (msg.type === 'grouped_tool_use') { return msg.messages .map(m => { const content = m.message.content[0] return content.type === 'tool_use' ? content.id : '' }) .filter(Boolean) } return [] } /** * Get all tool use IDs from a collapsed read/search group. */ export function getToolUseIdsFromCollapsedGroup( message: CollapsedReadSearchGroup, ): string[] { const ids: string[] = [] for (const msg of message.messages) { ids.push(...getToolUseIdsFromMessage(msg)) } return ids } /** * Check if any tool in a collapsed group is in progress. */ export function hasAnyToolInProgress( message: CollapsedReadSearchGroup, inProgressToolUseIDs: Set, ): boolean { return getToolUseIdsFromCollapsedGroup(message).some(id => inProgressToolUseIDs.has(id), ) } /** * Get the underlying NormalizedMessage for display (timestamp/model). * Handles nested GroupedToolUseMessage within collapsed groups. * Returns a NormalizedAssistantMessage or NormalizedUserMessage (never GroupedToolUseMessage). */ export function getDisplayMessageFromCollapsed( message: CollapsedReadSearchGroup, ): Exclude { const firstMsg = message.displayMessage if (firstMsg.type === 'grouped_tool_use') { return firstMsg.displayMessage } return firstMsg } /** * Count the number of tool uses in a message (handles grouped tool uses). */ function countToolUses(msg: RenderableMessage): number { if (msg.type === 'grouped_tool_use') { return msg.messages.length } return 1 } /** * Extract file paths from read tool inputs in a message. * Returns an array of file paths (may have duplicates if same file is read multiple times in one grouped message). */ function getFilePathsFromReadMessage(msg: RenderableMessage): string[] { const paths: string[] = [] if (msg.type === 'assistant') { const content = msg.message.content[0] if (content?.type === 'tool_use') { const input = content.input as { file_path?: string } | undefined if (input?.file_path) { paths.push(input.file_path) } } } else if (msg.type === 'grouped_tool_use') { for (const m of msg.messages) { const content = m.message.content[0] if (content?.type === 'tool_use') { const input = content.input as { file_path?: string } | undefined if (input?.file_path) { paths.push(input.file_path) } } } } return paths } /** * Scan a bash tool result for commit SHAs and PR URLs and push them into the * group accumulator. Called only for results whose tool_use_id was recorded * in bashCommands (non-search/read bash). */ function scanBashResultForGitOps( msg: CollapsibleMessage, group: GroupAccumulator, ): void { if (msg.type !== 'user') return const out = msg.toolUseResult as | { stdout?: string; stderr?: string } | undefined if (!out?.stdout && !out?.stderr) return // git push writes the ref update to stderr — scan both streams. const combined = (out.stdout ?? '') + '\n' + (out.stderr ?? '') for (const c of msg.message.content) { if (c.type !== 'tool_result') continue const command = group.bashCommands?.get(c.tool_use_id) if (!command) continue const { commit, push, branch, pr } = detectGitOperation(command, combined) if (commit) group.commits?.push(commit) if (push) group.pushes?.push(push) if (branch) group.branches?.push(branch) if (pr) group.prs?.push(pr) if (commit || push || branch || pr) { group.gitOpBashCount = (group.gitOpBashCount ?? 0) + 1 } } } type GroupAccumulator = { messages: CollapsibleMessage[] searchCount: number readFilePaths: Set // Count of read operations that don't have file paths (e.g., Bash cat commands) readOperationCount: number // Count of directory-listing operations (ls, tree, du) listCount: number toolUseIds: Set // Memory file operation counts (tracked separately from regular counts) memorySearchCount: number memoryReadFilePaths: Set memoryWriteCount: number // Team memory file operation counts (tracked separately) teamMemorySearchCount?: number teamMemoryReadFilePaths?: Set teamMemoryWriteCount?: number // Non-memory search patterns for display beneath the collapsed summary nonMemSearchArgs: string[] /** Most recently added non-memory operation, pre-formatted for display */ latestDisplayHint: string | undefined // MCP tool calls (tracked separately so display says "Queried slack" not "Read N files") mcpCallCount?: number mcpServerNames?: Set // Bash commands that aren't search/read (tracked separately for "Ran N bash commands") bashCount?: number // Bash tool_use_id → command string, so tool results can be scanned for // commit SHAs / PR URLs (surfaced as "committed abc123, created PR #42") bashCommands?: Map commits?: { sha: string; kind: CommitKind }[] pushes?: { branch: string }[] branches?: { ref: string; action: BranchAction }[] prs?: { number: number; url?: string; action: PrAction }[] gitOpBashCount?: number // PreToolUse hook timing absorbed from hook summary messages hookTotalMs: number hookCount: number hookInfos: StopHookInfo[] // relevant_memories attachments absorbed into this group (auto-injected // memories, not explicit Read calls). Paths mirrored into readFilePaths + // memoryReadFilePaths so the inline "recalled N memories" text is accurate. relevantMemories?: { path: string; content: string; mtimeMs: number }[] } function createEmptyGroup(): GroupAccumulator { const group: GroupAccumulator = { messages: [], searchCount: 0, readFilePaths: new Set(), readOperationCount: 0, listCount: 0, toolUseIds: new Set(), memorySearchCount: 0, memoryReadFilePaths: new Set(), memoryWriteCount: 0, nonMemSearchArgs: [], latestDisplayHint: undefined, hookTotalMs: 0, hookCount: 0, hookInfos: [], } if (feature('TEAMMEM')) { group.teamMemorySearchCount = 0 group.teamMemoryReadFilePaths = new Set() group.teamMemoryWriteCount = 0 } group.mcpCallCount = 0 group.mcpServerNames = new Set() if (isFullscreenEnvEnabled()) { group.bashCount = 0 group.bashCommands = new Map() group.commits = [] group.pushes = [] group.branches = [] group.prs = [] group.gitOpBashCount = 0 } return group } function createCollapsedGroup( group: GroupAccumulator, ): CollapsedReadSearchGroup { const firstMsg = group.messages[0]! // When file-path-based reads exist, use unique file count (Set.size) only. // Adding bash operation count on top would double-count — e.g. Read(README.md) // followed by Bash(wc -l README.md) should still show as 1 file, not 2. // Fall back to operation count only when there are no file-path reads (bash-only). const totalReadCount = group.readFilePaths.size > 0 ? group.readFilePaths.size : group.readOperationCount // memoryReadFilePaths ⊆ readFilePaths (both populated from Read tool calls), // so this count is safe to subtract from totalReadCount at readCount below. // Absorbed relevant_memories attachments are NOT in readFilePaths — added // separately after the subtraction so readCount stays correct. const toolMemoryReadCount = group.memoryReadFilePaths.size const memoryReadCount = toolMemoryReadCount + (group.relevantMemories?.length ?? 0) // Non-memory read file paths: exclude memory and team memory paths const teamMemReadPaths = feature('TEAMMEM') ? group.teamMemoryReadFilePaths : undefined const nonMemReadFilePaths = [...group.readFilePaths].filter( p => !group.memoryReadFilePaths.has(p) && !(teamMemReadPaths?.has(p) ?? false), ) const teamMemSearchCount = feature('TEAMMEM') ? (group.teamMemorySearchCount ?? 0) : 0 const teamMemReadCount = feature('TEAMMEM') ? (group.teamMemoryReadFilePaths?.size ?? 0) : 0 const teamMemWriteCount = feature('TEAMMEM') ? (group.teamMemoryWriteCount ?? 0) : 0 const result: CollapsedReadSearchGroup = { type: 'collapsed_read_search', // Subtract memory + team memory counts so regular counts only reflect non-memory operations searchCount: Math.max( 0, group.searchCount - group.memorySearchCount - teamMemSearchCount, ), readCount: Math.max( 0, totalReadCount - toolMemoryReadCount - teamMemReadCount, ), listCount: group.listCount, // REPL operations are intentionally not collapsed (see isCollapsible: false at line 32), // so replCount in collapsed groups is always 0. The replCount field is kept for // sub-agent progress display in AgentTool/UI.tsx which has a separate code path. replCount: 0, memorySearchCount: group.memorySearchCount, memoryReadCount, memoryWriteCount: group.memoryWriteCount, readFilePaths: nonMemReadFilePaths, searchArgs: group.nonMemSearchArgs, latestDisplayHint: group.latestDisplayHint, messages: group.messages, displayMessage: firstMsg, uuid: `collapsed-${firstMsg.uuid}` as UUID, timestamp: firstMsg.timestamp, } if (feature('TEAMMEM')) { result.teamMemorySearchCount = teamMemSearchCount result.teamMemoryReadCount = teamMemReadCount result.teamMemoryWriteCount = teamMemWriteCount } if ((group.mcpCallCount ?? 0) > 0) { result.mcpCallCount = group.mcpCallCount result.mcpServerNames = [...(group.mcpServerNames ?? [])] } if (isFullscreenEnvEnabled()) { if ((group.bashCount ?? 0) > 0) { result.bashCount = group.bashCount result.gitOpBashCount = group.gitOpBashCount } if ((group.commits?.length ?? 0) > 0) result.commits = group.commits if ((group.pushes?.length ?? 0) > 0) result.pushes = group.pushes if ((group.branches?.length ?? 0) > 0) result.branches = group.branches if ((group.prs?.length ?? 0) > 0) result.prs = group.prs } if (group.hookCount > 0) { result.hookTotalMs = group.hookTotalMs result.hookCount = group.hookCount result.hookInfos = group.hookInfos } if (group.relevantMemories && group.relevantMemories.length > 0) { result.relevantMemories = group.relevantMemories } return result } /** * Collapse consecutive Read/Search operations into summary groups. * * Rules: * - Groups consecutive search/read tool uses (Grep, Glob, Read, and Bash search/read commands) * - Includes their corresponding tool results in the group * - Breaks groups when assistant text appears */ export function collapseReadSearchGroups( messages: RenderableMessage[], tools: Tools, ): RenderableMessage[] { const result: RenderableMessage[] = [] let currentGroup = createEmptyGroup() let deferredSkippable: RenderableMessage[] = [] function flushGroup(): void { if (currentGroup.messages.length === 0) { return } result.push(createCollapsedGroup(currentGroup)) for (const deferred of deferredSkippable) { result.push(deferred) } deferredSkippable = [] currentGroup = createEmptyGroup() } for (const msg of messages) { if (isCollapsibleToolUse(msg, tools)) { // This is a collapsible tool use - type predicate narrows to CollapsibleMessage const toolInfo = getCollapsibleToolInfo(msg, tools)! if (toolInfo.isMemoryWrite) { // Memory file write/edit — check if it's team memory const count = countToolUses(msg) if ( feature('TEAMMEM') && teamMemOps?.isTeamMemoryWriteOrEdit(toolInfo.name, toolInfo.input) ) { currentGroup.teamMemoryWriteCount = (currentGroup.teamMemoryWriteCount ?? 0) + count } else { currentGroup.memoryWriteCount += count } } else if (toolInfo.isAbsorbedSilently) { // Snip/ToolSearch absorbed silently — no count, no summary text. // Hidden from the default view but still shown in verbose mode // (Ctrl+O) via the groupMessages iteration in CollapsedReadSearchContent. } else if (toolInfo.mcpServerName) { // MCP search/read — counted separately so the summary says // "Queried slack N times" instead of "Read N files". const count = countToolUses(msg) currentGroup.mcpCallCount = (currentGroup.mcpCallCount ?? 0) + count currentGroup.mcpServerNames?.add(toolInfo.mcpServerName) const input = toolInfo.input as { query?: string } | undefined if (input?.query) { currentGroup.latestDisplayHint = `"${input.query}"` } } else if (isFullscreenEnvEnabled() && toolInfo.isBash) { // Non-search/read Bash command — counted separately so the summary // says "Ran N bash commands" instead of breaking the group. const count = countToolUses(msg) currentGroup.bashCount = (currentGroup.bashCount ?? 0) + count const input = toolInfo.input as { command?: string } | undefined if (input?.command) { // Prefer the stripped `# comment` if present (it's what Claude wrote // for the human — same trigger as the comment-as-label tool-use render). currentGroup.latestDisplayHint = extractBashCommentLabel(input.command) ?? commandAsHint(input.command) // Remember tool_use_id → command so the result (arriving next) can // be scanned for commit SHA / PR URL. for (const id of getToolUseIdsFromMessage(msg)) { currentGroup.bashCommands?.set(id, input.command) } } } else if (toolInfo.isList) { // Directory-listing bash commands (ls, tree, du) — counted separately // so the summary says "Listed N directories" instead of "Read N files". currentGroup.listCount += countToolUses(msg) const input = toolInfo.input as { command?: string } | undefined if (input?.command) { currentGroup.latestDisplayHint = commandAsHint(input.command) } } else if (toolInfo.isSearch) { // Use the isSearch flag from the tool to properly categorize bash search commands const count = countToolUses(msg) currentGroup.searchCount += count // Check if the search targets memory files (via path or glob pattern) if ( feature('TEAMMEM') && teamMemOps?.isTeamMemorySearch(toolInfo.input) ) { currentGroup.teamMemorySearchCount = (currentGroup.teamMemorySearchCount ?? 0) + count } else if (isMemorySearch(toolInfo.input)) { currentGroup.memorySearchCount += count } else { // Regular (non-memory) search — collect pattern for display const input = toolInfo.input as { pattern?: string } | undefined if (input?.pattern) { currentGroup.nonMemSearchArgs.push(input.pattern) currentGroup.latestDisplayHint = `"${input.pattern}"` } } } else { // For reads, track unique file paths instead of counting operations const filePaths = getFilePathsFromReadMessage(msg) for (const filePath of filePaths) { currentGroup.readFilePaths.add(filePath) if (feature('TEAMMEM') && teamMemOps?.isTeamMemFile(filePath)) { currentGroup.teamMemoryReadFilePaths?.add(filePath) } else if (isAutoManagedMemoryFile(filePath)) { currentGroup.memoryReadFilePaths.add(filePath) } else { // Non-memory file read — update display hint currentGroup.latestDisplayHint = getDisplayPath(filePath) } } // If no file paths found (e.g., Bash read commands like ls, cat), count the operations if (filePaths.length === 0) { currentGroup.readOperationCount += countToolUses(msg) // Use the Bash command as the display hint (truncated for readability) const input = toolInfo.input as { command?: string } | undefined if (input?.command) { currentGroup.latestDisplayHint = commandAsHint(input.command) } } } // Track tool use IDs for matching results for (const id of getToolUseIdsFromMessage(msg)) { currentGroup.toolUseIds.add(id) } currentGroup.messages.push(msg) } else if (isCollapsibleToolResult(msg, currentGroup.toolUseIds)) { currentGroup.messages.push(msg) // Scan bash results for commit SHAs / PR URLs to surface in the summary if (isFullscreenEnvEnabled() && currentGroup.bashCommands?.size) { scanBashResultForGitOps(msg, currentGroup) } } else if (currentGroup.messages.length > 0 && isPreToolHookSummary(msg)) { // Absorb PreToolUse hook summaries into the group instead of deferring currentGroup.hookCount += msg.hookCount currentGroup.hookTotalMs += msg.totalDurationMs ?? msg.hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0) currentGroup.hookInfos.push(...msg.hookInfos) } else if ( currentGroup.messages.length > 0 && msg.type === 'attachment' && msg.attachment.type === 'relevant_memories' ) { // Absorb auto-injected memory attachments so "recalled N memories" // renders inline with "ran N bash commands" instead of as a separate // ⏺ block. Do NOT add paths to readFilePaths/memoryReadFilePaths — // that would poison the readOperationCount fallback (bash-only reads // have no paths; adding memory paths makes readFilePaths.size > 0 and // suppresses the fallback). createCollapsedGroup adds .length to // memoryReadCount after the readCount subtraction instead. currentGroup.relevantMemories ??= [] currentGroup.relevantMemories.push(...msg.attachment.memories) } else if (shouldSkipMessage(msg)) { // Don't flush the group for skippable messages (thinking, attachments, system) // If a group is in progress, defer these messages to output after the collapsed group // This preserves the visual ordering where the collapsed badge appears at the position // of the first tool use, not displaced by intervening skippable messages. // Exception: nested_memory attachments are pushed through even during a group so // ⎿ Loaded lines cluster tightly instead of being split by the badge's marginTop. if ( currentGroup.messages.length > 0 && !(msg.type === 'attachment' && msg.attachment.type === 'nested_memory') ) { deferredSkippable.push(msg) } else { result.push(msg) } } else if (isTextBreaker(msg)) { // Assistant text breaks the group flushGroup() result.push(msg) } else if (isNonCollapsibleToolUse(msg, tools)) { // Non-collapsible tool use breaks the group flushGroup() result.push(msg) } else { // User messages with non-collapsible tool results break the group flushGroup() result.push(msg) } } flushGroup() return result } /** * Generate a summary text for search/read/REPL counts. * @param searchCount Number of search operations * @param readCount Number of read operations * @param isActive Whether the group is still in progress (use present tense) or completed (use past tense) * @param replCount Number of REPL executions (optional) * @param memoryCounts Optional memory file operation counts * @returns Summary text like "Searching for 3 patterns, reading 2 files, REPL'd 5 times…" */ export function getSearchReadSummaryText( searchCount: number, readCount: number, isActive: boolean, replCount: number = 0, memoryCounts?: { memorySearchCount: number memoryReadCount: number memoryWriteCount: number teamMemorySearchCount?: number teamMemoryReadCount?: number teamMemoryWriteCount?: number }, listCount: number = 0, ): string { const parts: string[] = [] // Memory operations first if (memoryCounts) { const { memorySearchCount, memoryReadCount, memoryWriteCount } = memoryCounts if (memoryReadCount > 0) { const verb = isActive ? parts.length === 0 ? 'Recalling' : 'recalling' : parts.length === 0 ? 'Recalled' : 'recalled' parts.push( `${verb} ${memoryReadCount} ${memoryReadCount === 1 ? 'memory' : 'memories'}`, ) } if (memorySearchCount > 0) { const verb = isActive ? parts.length === 0 ? 'Searching' : 'searching' : parts.length === 0 ? 'Searched' : 'searched' parts.push(`${verb} memories`) } if (memoryWriteCount > 0) { const verb = isActive ? parts.length === 0 ? 'Writing' : 'writing' : parts.length === 0 ? 'Wrote' : 'wrote' parts.push( `${verb} ${memoryWriteCount} ${memoryWriteCount === 1 ? 'memory' : 'memories'}`, ) } // Team memory operations if (feature('TEAMMEM') && teamMemOps) { teamMemOps.appendTeamMemorySummaryParts(memoryCounts, isActive, parts) } } if (searchCount > 0) { const searchVerb = isActive ? parts.length === 0 ? 'Searching for' : 'searching for' : parts.length === 0 ? 'Searched for' : 'searched for' parts.push( `${searchVerb} ${searchCount} ${searchCount === 1 ? 'pattern' : 'patterns'}`, ) } if (readCount > 0) { const readVerb = isActive ? parts.length === 0 ? 'Reading' : 'reading' : parts.length === 0 ? 'Read' : 'read' parts.push(`${readVerb} ${readCount} ${readCount === 1 ? 'file' : 'files'}`) } if (listCount > 0) { const listVerb = isActive ? parts.length === 0 ? 'Listing' : 'listing' : parts.length === 0 ? 'Listed' : 'listed' parts.push( `${listVerb} ${listCount} ${listCount === 1 ? 'directory' : 'directories'}`, ) } if (replCount > 0) { const replVerb = isActive ? "REPL'ing" : "REPL'd" parts.push(`${replVerb} ${replCount} ${replCount === 1 ? 'time' : 'times'}`) } const text = parts.join(', ') return isActive ? `${text}…` : text } /** * Summarize a list of recent tool activities into a compact description. * Rolls up trailing consecutive search/read operations using pre-computed * isSearch/isRead classifications from recording time. Falls back to the * last activity's description for non-collapsible tool uses. */ export function summarizeRecentActivities( activities: readonly { activityDescription?: string isSearch?: boolean isRead?: boolean }[], ): string | undefined { if (activities.length === 0) { return undefined } // Count trailing search/read activities from the end of the list let searchCount = 0 let readCount = 0 for (let i = activities.length - 1; i >= 0; i--) { const activity = activities[i]! if (activity.isSearch) { searchCount++ } else if (activity.isRead) { readCount++ } else { break } } const collapsibleCount = searchCount + readCount if (collapsibleCount >= 2) { return getSearchReadSummaryText(searchCount, readCount, true) } // Fall back to most recent activity with a description (some tools like // SendMessage don't implement getActivityDescription, so search backward) for (let i = activities.length - 1; i >= 0; i--) { if (activities[i]?.activityDescription) { return activities[i]!.activityDescription } } return undefined }