Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
38 kB · 1109 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110import { 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')) : nullconst 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<string>,): 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<string>,): 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<CollapsibleMessage, { type: 'grouped_tool_use' }> { 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<string> // 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<string> // Memory file operation counts (tracked separately from regular counts) memorySearchCount: number memoryReadFilePaths: Set<string> memoryWriteCount: number // Team memory file operation counts (tracked separately) teamMemorySearchCount?: number teamMemoryReadFilePaths?: Set<string> 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<string> // 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<string, string> 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}