mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 15:06:57 +10:00
1110 lines
37 KiB
TypeScript
1110 lines
37 KiB
TypeScript
|
|
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<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
|
|||
|
|
}
|