/ utils / collapseReadSearch.ts
collapseReadSearch.ts
   1  import { feature } from 'bun:bundle'
   2  import type { UUID } from 'crypto'
   3  import { findToolByName, type Tools } from '../Tool.js'
   4  import { extractBashCommentLabel } from '../tools/BashTool/commentLabel.js'
   5  import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
   6  import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js'
   7  import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js'
   8  import { REPL_TOOL_NAME } from '../tools/REPLTool/constants.js'
   9  import { getReplPrimitiveTools } from '../tools/REPLTool/primitiveTools.js'
  10  import {
  11    type BranchAction,
  12    type CommitKind,
  13    detectGitOperation,
  14    type PrAction,
  15  } from '../tools/shared/gitOperationTracking.js'
  16  import { TOOL_SEARCH_TOOL_NAME } from '../tools/ToolSearchTool/prompt.js'
  17  import type {
  18    CollapsedReadSearchGroup,
  19    CollapsibleMessage,
  20    RenderableMessage,
  21    StopHookInfo,
  22    SystemStopHookSummaryMessage,
  23  } from '../types/message.js'
  24  import { getDisplayPath } from './file.js'
  25  import { isFullscreenEnvEnabled } from './fullscreen.js'
  26  import {
  27    isAutoManagedMemoryFile,
  28    isAutoManagedMemoryPattern,
  29    isMemoryDirectory,
  30    isShellCommandTargetingMemory,
  31  } from './memoryFileDetection.js'
  32  
  33  /* eslint-disable @typescript-eslint/no-require-imports */
  34  const teamMemOps = feature('TEAMMEM')
  35    ? (require('./teamMemoryOps.js') as typeof import('./teamMemoryOps.js'))
  36    : null
  37  const 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   */
  47  export 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   */
  71  function 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   */
  81  function 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   */
 109  function 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.
 118  const 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   */
 125  function 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   */
 143  export 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   */
 244  export 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   */
 278  function 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   */
 290  function 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   */
 331  function 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   */
 345  function 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  
 370  function 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   */
 384  function 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   */
 406  function 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   */
 431  function 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   */
 452  function 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   */
 473  export 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   */
 486  export 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   */
 500  export 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   */
 513  function 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   */
 524  function 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   */
 555  function 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  
 581  type 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  
 625  function 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  
 661  function 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   */
 762  export 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   */
 961  export 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   */
1074  export 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  }