import { feature } from 'bun:bundle'; import chalk from 'chalk'; import { SentryErrorBoundary } from './SentryErrorBoundary.js'; import type { UUID } from 'crypto'; import type { RefObject } from 'react'; import * as React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { every } from 'src/utils/set.js'; import { getIsRemoteMode } from '../bootstrap/state.js'; import type { Command } from '../commands.js'; import { BLACK_CIRCLE } from '../constants/figures.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import type { ScrollBoxHandle } from '@anthropic/ink'; import { useTerminalNotification } from '@anthropic/ink'; import { Box, Text } from '@anthropic/ink'; import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; import type { Screen } from '../screens/REPL.js'; import type { Tools } from '../Tool.js'; import { findToolByName } from '../Tool.js'; import type { AgentDefinitionsResult } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; import type { AssistantMessage, Message as MessageType, NormalizedMessage, ProgressMessage as ProgressMessageType, RenderableMessage, } from '../types/message.js'; import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js'; import { collapseHookSummaries } from '../utils/collapseHookSummaries.js'; import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js'; import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js'; import { getGlobalConfig } from '../utils/config.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; import { applyGrouping } from '../utils/groupToolUses.js'; import { buildMessageLookups, computeMessageStructureKey, type MessageLookups, updateMessageLookupsIncremental, createAssistantMessage, deriveUUID, getMessagesAfterCompactBoundary, getToolUseID, getToolUseIDs, hasUnresolvedHooksFromLookup, isNotEmptyMessage, normalizeMessages, reorderMessagesInUI, type StreamingThinking, type StreamingToolUse, shouldShowUserMessage, } from '../utils/messages.js'; import { plural } from '../utils/stringUtils.js'; import { renderableSearchText } from '../utils/transcriptSearch.js'; import { Divider } from '@anthropic/ink'; import type { UnseenDivider } from './FullscreenLayout.js'; import { LogoV2 } from './LogoV2/LogoV2.js'; import { StreamingMarkdown } from './Markdown.js'; import { hasContentAfterIndex, MessageRow } from './MessageRow.js'; import { InVirtualListContext, type MessageActionsNav, MessageActionsSelectedContext, type MessageActionsState, } from './messageActions.js'; import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; import { OffscreenFreeze } from './OffscreenFreeze.js'; import type { ToolUseConfirm } from './permissions/PermissionRequest.js'; import { StatusNotices } from './StatusNotices.js'; import type { JumpHandle } from './VirtualMessageList.js'; // Memoed logo header: this box is the FIRST sibling before all MessageRows // in main-screen mode. If it becomes dirty on every Messages re-render, // renderChildren's seenDirtyChild cascade disables prevScreen (blit) for // ALL subsequent siblings — every MessageRow re-writes from scratch instead // of blitting. In long sessions (~2800 messages) this is 150K+ writes/frame // and pegs CPU at 100%. Memo on agentDefinitions so a new messages array // doesn't invalidate the logo subtree. LogoV2/StatusNotices internally // subscribe to useAppState/useSettings for their own updates. const LogoHeader = React.memo(function LogoHeader({ agentDefinitions, }: { agentDefinitions: AgentDefinitionsResult | undefined; }): React.ReactNode { // LogoV2 has its own internal OffscreenFreeze (catches its useAppState // re-renders). This outer freeze catches agentDefinitions changes and any // future StatusNotices subscriptions while the header is in scrollback. return ( ); }); // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? ( require('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') ).BRIEF_TOOL_NAME : null; const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') ? ( require('@claude-code-best/builtin-tools/tools/SendUserFileTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/SendUserFileTool/prompt.js') ).SEND_USER_FILE_TOOL_NAME : null; /* eslint-enable @typescript-eslint/no-require-imports */ import { VirtualMessageList } from './VirtualMessageList.js'; /** * In brief-only mode, filter messages to show ONLY Brief tool_use blocks, * their tool_results, and real user input. All assistant text is dropped — * if the model forgets to call Brief, the user sees nothing for that turn. * That's on the model to get right; the filter does not second-guess it. */ export function filterForBriefTool< T extends { type: string; subtype?: string; isMeta?: boolean; isApiErrorMessage?: boolean; message?: { content: Array<{ type: string; name?: string; tool_use_id?: string; }>; }; attachment?: { type: string; isMeta?: boolean; origin?: unknown; commandMode?: string; }; }, >(messages: T[], briefToolNames: string[]): T[] { const nameSet = new Set(briefToolNames); // tool_use always precedes its tool_result in the array, so we can collect // IDs and match against them in a single pass. const briefToolUseIDs = new Set(); return messages.filter(msg => { // System messages (attach confirmation, remote errors, compact boundaries) // must stay visible — dropping them leaves the viewer with no feedback. // Exception: api_metrics is per-turn debug noise (TTFT, config writes, // hook timing) that defeats the point of brief mode. Still visible in // transcript mode (ctrl+o) which bypasses this filter. if (msg.type === 'system') return msg.subtype !== 'api_metrics'; const block = msg.message?.content[0]; if (msg.type === 'assistant') { // API error messages (auth failures, rate limits, etc.) must stay visible if (msg.isApiErrorMessage) return true; // Keep Brief tool_use blocks (renders with standard tool call chrome, // and must be in the list so buildMessageLookups can resolve tool results) if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { if ('id' in block) { briefToolUseIDs.add((block as { id: string }).id); } return true; } return false; } if (msg.type === 'user') { if (block?.type === 'tool_result') { return block.tool_use_id !== undefined && briefToolUseIDs.has(block.tool_use_id); } // Real user input only — drop meta/tick messages. return !msg.isMeta; } if (msg.type === 'attachment') { // Human input drained mid-turn arrives as a queued_command attachment // (query.ts mid-chain drain → getQueuedCommandAttachments). Keep it — // it's what the user typed. commandMode === 'prompt' positively // identifies human-typed input; task-notification callers set // mode: 'task-notification' but not origin/isMeta, so the positive // commandMode check is required to exclude them. const att = msg.attachment; return att?.type === 'queued_command' && att.commandMode === 'prompt' && !att.isMeta && att.origin === undefined; } return false; }); } /** * Full-transcript companion to filterForBriefTool. When the Brief tool is * in use, the model's text output is redundant with the SendUserMessage * content it wrote right after — drop the text so only the SendUserMessage * block shows. Tool calls and their results stay visible. * * Per-turn: only drops text in turns that actually called Brief. If the * model forgets, text still shows — otherwise the user would see nothing. */ export function dropTextInBriefTurns< T extends { type: string; isMeta?: boolean; message?: { content: Array<{ type: string; name?: string }> }; }, >(messages: T[], briefToolNames: string[]): T[] { const nameSet = new Set(briefToolNames); // First pass: find which turns (bounded by non-meta user messages) contain // a Brief tool_use. Tag each assistant text block with its turn index. const turnsWithBrief = new Set(); const textIndexToTurn: number[] = []; let turn = 0; for (let i = 0; i < messages.length; i++) { const msg = messages[i]!; const block = msg.message?.content[0]; if (msg.type === 'user' && block?.type !== 'tool_result' && !msg.isMeta) { turn++; continue; } if (msg.type === 'assistant') { if (block?.type === 'text') { textIndexToTurn[i] = turn; } else if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { turnsWithBrief.add(turn); } } } if (turnsWithBrief.size === 0) return messages; // Second pass: drop text blocks whose turn called Brief. return messages.filter((_, i) => { const t = textIndexToTurn[i]; return t === undefined || !turnsWithBrief.has(t); }); } type Props = { messages: MessageType[]; tools: Tools; commands: Command[]; verbose: boolean; toolJSX: { jsx: React.ReactNode | null; shouldHidePromptInput: boolean; shouldContinueAnimation?: true; } | null; toolUseConfirmQueue: ToolUseConfirm[]; inProgressToolUseIDs: Set; isMessageSelectorVisible: boolean; conversationId: string; screen: Screen; streamingToolUses: StreamingToolUse[]; showAllInTranscript?: boolean; agentDefinitions?: AgentDefinitionsResult; onOpenRateLimitOptions?: () => void; /** Hide the logo/header - used for subagent zoom view */ hideLogo?: boolean; isLoading: boolean; /** In transcript mode, hide all thinking blocks except the last one */ hidePastThinking?: boolean; /** Streaming thinking content (live updates, not frozen) */ streamingThinking?: StreamingThinking | null; /** Streaming text preview (rendered as last item so transition to final message is positionally seamless) */ streamingText?: string | null; /** When true, only show Brief tool output (hide everything else) */ isBriefOnly?: boolean; /** Fullscreen-mode "─── N new ───" divider. Renders before the first * renderableMessage derived from firstUnseenUuid (matched by the 24-char * prefix that deriveUUID preserves). */ unseenDivider?: UnseenDivider; /** Fullscreen-mode ScrollBox handle. Enables React-level virtualization when present. */ scrollRef?: RefObject; /** Fullscreen-mode: enable sticky-prompt tracking (writes via ScrollChromeContext). */ trackStickyPrompt?: boolean; /** Transcript search: jump-to-index + setSearchQuery/nextMatch/prevMatch. */ jumpRef?: RefObject; /** Transcript search: fires when match count/position changes. */ onSearchMatchesChange?: (count: number, current: number) => void; /** Paint an existing DOM subtree to fresh Screen, scan. Element comes * from the main tree (all real providers). Message-relative positions. */ scanElement?: (el: import('@anthropic/ink').DOMElement) => import('@anthropic/ink').MatchPosition[]; /** Position-based CURRENT highlight. positions stable (msg-relative), * rowOffset tracks scroll. null clears. */ setPositions?: ( state: { positions: import('@anthropic/ink').MatchPosition[]; rowOffset: number; currentIdx: number; } | null, ) => void; /** Bypass MAX_MESSAGES_WITHOUT_VIRTUALIZATION. For one-shot headless renders * (e.g. /export via renderToString) where the memory concern doesn't apply * and the "already in scrollback" justification doesn't hold. */ disableRenderCap?: boolean; /** In-transcript cursor; expanded overrides verbose for selected message. */ cursor?: MessageActionsState | null; setCursor?: (cursor: MessageActionsState | null) => void; /** Passed through to VirtualMessageList (heightCache owns visibility). */ cursorNavRef?: React.Ref; /** Render only collapsed.slice(start, end). For chunked headless export * (streamRenderedMessages in exportRenderer.tsx): prep runs on the FULL * messages array so grouping/lookups are correct, but only this slice * chunk instead of the full session. The logo renders only for chunk 0 * (start === 0); later chunks are mid-stream continuations. * Measured Mar 2026: 538-msg session, 20 slices → −55% plateau RSS. */ renderRange?: readonly [start: number, end: number]; }; const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30; // Safety cap for the non-virtualized render path (fullscreen off or // explicitly disabled). Ink mounts a full fiber tree per message (~250 KB // RSS each); yoga layout height grows unbounded; the screen buffer is sized // to fit every line. At ~2000 messages this is ~3000-line screens, ~500 MB // of fibers, and per-frame write costs that push the process into a GC // death spiral (observed: 59 GB RSS, 14k mmap/munmap/sec). Content dropped // from this slice has already been printed to terminal scrollback — users // can still scroll up natively. VirtualMessageList (the default ant path) // bypasses this cap entirely. Headless one-shot renders (e.g. /export) // pass disableRenderCap to opt out — they have no scrollback and the // memory concern doesn't apply to renderToString. // // The slice boundary is tracked as a UUID anchor, not a count-derived // index. Count-based slicing (slice(-200)) drops one message from the // front on every append, shifting scrollback content and forcing a full // terminal reset per turn (CC-941). Quantizing to 50-message steps // (CC-1154) helped but still shifted on compaction and collapse regrouping // since those change collapsed.length without adding messages. The UUID // anchor only advances when rendered count genuinely exceeds CAP+STEP — // immune to length churn from grouping/compaction (CC-1174). // // The anchor stores BOTH uuid and index. Some uuids are unstable between // renders: collapseHookSummaries derives the merged uuid from the first // summary in a group, but reorderMessagesInUI reshuffles hook adjacency // as tool results stream in, changing which summary is first. When the // uuid vanishes, falling back to the stored index (clamped) keeps the // slice roughly where it was instead of resetting to 0 — which would // jump from ~200 rendered messages to the full history, orphaning // in-progress badge snapshots in scrollback. const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200; const MESSAGE_CAP_STEP = 50; export type SliceAnchor = { uuid: string; idx: number } | null; /** Exported for testing. Mutates anchorRef when the window needs to advance. */ export function computeSliceStart( collapsed: ReadonlyArray<{ uuid: string }>, anchorRef: { current: SliceAnchor }, cap = MAX_MESSAGES_WITHOUT_VIRTUALIZATION, step = MESSAGE_CAP_STEP, ): number { const anchor = anchorRef.current; const anchorIdx = anchor ? collapsed.findIndex(m => m.uuid === anchor.uuid) : -1; // Anchor found → use it. Anchor lost → fall back to stored index // (clamped) so collapse-regrouping uuid churn doesn't reset to 0. let start = anchorIdx >= 0 ? anchorIdx : anchor ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) : 0; if (collapsed.length - start > cap + step) { start = collapsed.length - cap; } // Refresh anchor from whatever lives at the current start — heals a // stale uuid after fallback and captures a new one after advancement. const msgAtStart = collapsed[start]; if (msgAtStart && (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start)) { anchorRef.current = { uuid: msgAtStart.uuid, idx: start }; } else if (!msgAtStart && anchor) { anchorRef.current = null; } return start; } const MessagesImpl = ({ messages, tools, commands, verbose, toolJSX, toolUseConfirmQueue, inProgressToolUseIDs, isMessageSelectorVisible, conversationId, screen, streamingToolUses, showAllInTranscript = false, agentDefinitions, onOpenRateLimitOptions, hideLogo = false, isLoading, hidePastThinking = false, streamingThinking, streamingText, isBriefOnly = false, unseenDivider, scrollRef, trackStickyPrompt, jumpRef, onSearchMatchesChange, scanElement, setPositions, disableRenderCap = false, cursor = null, setCursor, cursorNavRef, renderRange, }: Props): React.ReactNode => { const { columns } = useTerminalSize(); const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E'); const normalizedMessages = useMemo(() => normalizeMessages(messages).filter(isNotEmptyMessage), [messages]); // Check if streaming thinking should be visible (streaming or within 30s timeout) const isStreamingThinkingVisible = useMemo(() => { if (!streamingThinking) return false; if (streamingThinking.isStreaming) return true; if (streamingThinking.streamingEndedAt) { return Date.now() - streamingThinking.streamingEndedAt < 30000; } return false; }, [streamingThinking]); // Find the last thinking block and latest bash output in a single backward pass. // Merged from two separate reverse iterations to reduce total traversals. const { lastThinkingBlockId, latestBashOutputUUID } = useMemo(() => { let thinkingId: string | null = null; let bashUUID: string | null = null; const needThinkingScan = hidePastThinking && !isStreamingThinkingVisible; if (hidePastThinking && isStreamingThinkingVisible) { thinkingId = 'streaming'; } for (let i = normalizedMessages.length - 1; i >= 0; i--) { const msg = normalizedMessages[i]; if (msg?.type === 'user') { const content = msg.message!.content as Array<{ type: string; text?: string }>; // Bash output detection if (!bashUUID) { for (const block of content) { if (block.type === 'text') { const text = block.text ?? ''; if (text.startsWith(' block.type === 'tool_result'); if (!hasToolResult) { thinkingId = 'no-thinking'; } } } else if (msg?.type === 'assistant') { if (needThinkingScan && !thinkingId) { const content = msg.message!.content as Array<{ type: string }>; for (let j = content.length - 1; j >= 0; j--) { if (content[j]?.type === 'thinking') { thinkingId = `${msg.uuid}:${j}`; break; } } } } if (thinkingId !== null && bashUUID) break; } if (!hidePastThinking) { thinkingId = null; } return { lastThinkingBlockId: thinkingId, latestBashOutputUUID: bashUUID }; }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]); // streamingToolUses updates on every input_json_delta while normalizedMessages // stays stable — precompute the Set so the filter is O(k) not O(n×k) per chunk. const normalizedToolUseIDs = useMemo(() => getToolUseIDs(normalizedMessages), [normalizedMessages]); const streamingToolUsesWithoutInProgress = useMemo( () => streamingToolUses.filter( stu => !inProgressToolUseIDs.has(stu.contentBlock.id) && !normalizedToolUseIDs.has(stu.contentBlock.id), ), [streamingToolUses, inProgressToolUseIDs, normalizedToolUseIDs], ); const syntheticStreamingToolUseMessages = useMemo( () => streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => { const msg = createAssistantMessage({ content: [streamingToolUse.contentBlock], }); // Override randomUUID with deterministic value derived from content // block ID to prevent React key changes on every memo recomputation. // Same class of bug fixed in normalizeMessages (commit 383326e613): // fresh randomUUID → unstable React keys → component remounts → // Ink rendering corruption (overlapping text from stale DOM nodes). msg.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0); return normalizeMessages([msg]); }), [streamingToolUsesWithoutInProgress], ); const isTranscriptMode = screen === 'transcript'; // Hoisted to mount-time — this component re-renders on every scroll. const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); // Virtual scroll replaces the transcript cap: everything is scrollable and // memory is bounded by the mounted-item count, not the total. scrollRef is // only passed when isFullscreenEnvEnabled() is true (REPL.tsx gates it), // so scrollRef's presence is the signal. const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll; const shouldTruncate = isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate; // Anchor for the first rendered message in the non-virtualized cap slice. // Monotonic advance only — mutation during render is idempotent (safe // under StrictMode double-render). See MAX_MESSAGES_WITHOUT_VIRTUALIZATION // comment above for why this replaced count-based slicing. const sliceAnchorRef = useRef(null); // Cache for buildMessageLookups: avoids rebuilding 8 Maps/Sets when only // message content changed during streaming (text/thinking deltas). The key // captures only structural info (types, IDs), so content-only deltas skip // the rebuild entirely. const lookupsCacheRef = useRef<{ key: string; lookups: MessageLookups; normalizedCount: number; messageCount: number; lastAssistantMsgId: string | undefined; } | null>(null); // Expensive message transforms — filter, reorder, group, collapse, lookups. // All O(n) over 27k messages. Split from the renderRange slice so scrolling // (which only changes renderRange) doesn't re-run these. Previously this // useMemo included renderRange → every scroll rebuilt 6 Maps over 27k // messages + 4 filter/map passes = ~50ms alloc per scroll → GC pressure → // 100-173ms stop-the-world pauses on the 1GB heap. const { collapsed, lookups, hasTruncatedMessages, hiddenMessageCount } = useMemo(() => { // In fullscreen mode the alt buffer has no native scrollback, so the // compact-boundary filter just hides history the ScrollBox could // otherwise scroll to. Main-screen mode keeps the filter — pre-compact // rows live above the viewport in native scrollback there, and // re-rendering them triggers full resets. // includeSnipped: UI rendering keeps snipped messages for scrollback // (this PR's core goal — full history in UI, filter only for the model). // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so // projectSnippedView's check against original removedUuids would fail. const compactAwareMessages = verbose || isFullscreenEnvEnabled() ? normalizedMessages : getMessagesAfterCompactBoundary(normalizedMessages, { includeSnipped: true, }); const messagesToShowNotTruncated = reorderMessagesInUI( compactAwareMessages.filter( (msg): msg is Exclude => // CC-724: drop attachment messages that AttachmentMessage renders as // null (hook_success, hook_additional_context, hook_cancelled, etc.) // BEFORE counting/slicing so they don't inflate the "N messages" // count in ctrl-o or consume slots in the 200-message render cap. msg.type !== 'progress' && !isNullRenderingAttachment(msg) && shouldShowUserMessage(msg, isTranscriptMode), ) as Parameters[0], syntheticStreamingToolUseMessages, ); // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. // Brief-only: SendUserMessage + user input only. Default: drop redundant // assistant text in turns where SendUserMessage was called (the model's // text is working-notes that duplicate the SendUserMessage content). const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter((n): n is string => n !== null); // dropTextInBriefTurns should only trigger on SendUserMessage turns — // SendUserFile delivers a file without replacement text, so dropping // assistant text for file-only turns would leave the user with no context. const dropTextToolNames = [BRIEF_TOOL_NAME].filter((n): n is string => n !== null); const briefFiltered = briefToolNames.length > 0 && !isTranscriptMode ? isBriefOnly ? filterForBriefTool(messagesToShowNotTruncated as Parameters[0], briefToolNames) : dropTextToolNames.length > 0 ? dropTextInBriefTurns( messagesToShowNotTruncated as Parameters[0], dropTextToolNames, ) : messagesToShowNotTruncated : messagesToShowNotTruncated; const messagesToShow = shouldTruncate ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) : briefFiltered; const hasTruncatedMessages = shouldTruncate && briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; const { messages: groupedMessages } = applyGrouping(messagesToShow as MessageType[], tools, verbose); const collapsed = collapseBackgroundBashNotifications( collapseHookSummaries(collapseTeammateShutdowns(collapseReadSearchGroups(groupedMessages, tools))), verbose, ); const lookupsKey = computeMessageStructureKey(normalizedMessages, messagesToShow as MessageType[]); const currentLastAssistantMsgId = (() => { const lastMsg = (messagesToShow as MessageType[]).at(-1); return lastMsg?.type === 'assistant' ? (lastMsg as AssistantMessage).message?.id : undefined; })(); let lookups: MessageLookups; if (lookupsCacheRef.current && lookupsCacheRef.current.key === lookupsKey) { lookups = lookupsCacheRef.current.lookups; } else if ( lookupsCacheRef.current && normalizedMessages.length >= lookupsCacheRef.current.normalizedCount && (messagesToShow as MessageType[]).length >= lookupsCacheRef.current.messageCount && // If lastAssistantMsgId changed, previous "in-progress" assistant may // now be orphaned — force a full rebuild to pick up the new status. lookupsCacheRef.current.lastAssistantMsgId === currentLastAssistantMsgId ) { // Try incremental update when only new messages were appended const updated = updateMessageLookupsIncremental( lookupsCacheRef.current.lookups, lookupsCacheRef.current.normalizedCount, lookupsCacheRef.current.messageCount, normalizedMessages, messagesToShow as MessageType[], ); if (updated) { lookups = updated; lookupsCacheRef.current = { key: lookupsKey, lookups, normalizedCount: normalizedMessages.length, messageCount: (messagesToShow as MessageType[]).length, lastAssistantMsgId: currentLastAssistantMsgId, }; } else { lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[]); lookupsCacheRef.current = { key: lookupsKey, lookups, normalizedCount: normalizedMessages.length, messageCount: (messagesToShow as MessageType[]).length, lastAssistantMsgId: currentLastAssistantMsgId, }; } } else { lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[]); lookupsCacheRef.current = { key: lookupsKey, lookups, normalizedCount: normalizedMessages.length, messageCount: (messagesToShow as MessageType[]).length, lastAssistantMsgId: currentLastAssistantMsgId, }; } const hiddenMessageCount = messagesToShowNotTruncated.length - MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; return { collapsed, lookups, hasTruncatedMessages, hiddenMessageCount, }; }, [ verbose, normalizedMessages, isTranscriptMode, syntheticStreamingToolUseMessages, shouldTruncate, tools, isBriefOnly, ]); // Cheap slice — only runs when scroll range or slice config changes. const renderableMessages = useMemo(() => { // Safety cap for the non-virtualized render path. Applied here (not at // the JSX site) so renderMessageRow's index-based lookups and // dividerBeforeIndex compute on the same array. VirtualMessageList // never sees this slice — virtualScrollRuntimeGate is constant for the // component's lifetime (scrollRef is either always passed or never). // renderRange is first: the chunked export path slices the // post-grouping array so each chunk gets correct tool-call grouping. const capApplies = !virtualScrollRuntimeGate && !disableRenderCap; const sliceStart = capApplies ? computeSliceStart(collapsed, sliceAnchorRef) : 0; return renderRange ? collapsed.slice(renderRange[0], renderRange[1]) : sliceStart > 0 ? collapsed.slice(sliceStart) : collapsed; }, [collapsed, renderRange, virtualScrollRuntimeGate, disableRenderCap]); const streamingToolUseIDs = useMemo( () => new Set(streamingToolUses.map(_ => _.contentBlock.id)), [streamingToolUses], ); // Divider insertion point and selected index: combined into a single pass // over renderableMessages to avoid two separate findIndex traversals. const { dividerBeforeIndex, selectedIdx } = useMemo(() => { if (!unseenDivider && !cursor) return { dividerBeforeIndex: -1, selectedIdx: -1 }; let dIdx = -1; let sIdx = -1; const prefix = unseenDivider?.firstUnseenUuid.slice(0, 24); for (let i = 0; i < renderableMessages.length; i++) { const m = renderableMessages[i]; if (dIdx === -1 && prefix && m.uuid.slice(0, 24) === prefix) dIdx = i; if (sIdx === -1 && cursor && m.uuid === cursor.uuid) sIdx = i; if (dIdx !== -1 && sIdx !== -1) break; } return { dividerBeforeIndex: dIdx, selectedIdx: sIdx }; }, [unseenDivider, cursor, renderableMessages]); // Fullscreen: click a message to toggle verbose rendering for it. Keyed by // tool_use_id where available so a tool_use and its tool_result (separate // rows) expand together; falls back to uuid for groups/thinking. Stale keys // are harmless — they never match anything in renderableMessages. const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); const onItemClick = useCallback((msg: RenderableMessage) => { const k = expandKey(msg); setExpandedKeys(prev => { const next = new Set(prev); if (next.has(k)) next.delete(k); else next.add(k); return next; }); }, []); const isItemExpanded = useCallback( (msg: RenderableMessage) => expandedKeys.size > 0 && expandedKeys.has(expandKey(msg)), [expandedKeys], ); // Only hover/click messages where the verbose toggle reveals more: // collapsed read/search groups, or tool results that self-report truncation // via isResultTruncated. Callback must be stable across message updates: if // its identity (or return value) flips during streaming, onMouseEnter // attaches after the mouse is already inside → hover never fires. tools is // session-stable; lookups is read via ref so the callback doesn't churn on // every new message. const lookupsRef = useRef(lookups); lookupsRef.current = lookups; const isItemClickable = useCallback( (msg: RenderableMessage): boolean => { if (msg.type === 'collapsed_read_search') return true; if (msg.type === 'assistant') { const content = msg.message!.content; const b = (Array.isArray(content) ? content[0] : undefined) as unknown as AdvisorBlock | undefined; return ( b != null && isAdvisorBlock(b) && b.type === 'advisor_tool_result' && b.content.type === 'advisor_result' ); } if (msg.type !== 'user') return false; const b = ( msg.message!.content as Array<{ type: string; tool_use_id?: string; is_error?: boolean; [key: string]: unknown; }> )[0]; if (b?.type !== 'tool_result' || b.is_error || !msg.toolUseResult) return false; const name = lookupsRef.current.toolUseByToolUseID.get(b.tool_use_id ?? '')?.name; const tool = name ? findToolByName(tools, name) : undefined; return tool?.isResultTruncated?.(msg.toolUseResult as never) ?? false; }, [tools], ); const canAnimate = (!toolJSX || !!toolJSX.shouldContinueAnimation) && !toolUseConfirmQueue.length && !isMessageSelectorVisible; const hasToolsInProgress = inProgressToolUseIDs.size > 0; // Report progress to terminal (for terminals that support OSC 9;4) const { progress } = useTerminalNotification(); const prevProgressState = useRef(null); const progressEnabled = getGlobalConfig().terminalProgressBarEnabled && !getIsRemoteMode() && !(proactiveModule?.isProactiveActive() ?? false); useEffect(() => { const state = progressEnabled ? (hasToolsInProgress ? 'indeterminate' : 'completed') : null; if (prevProgressState.current === state) return; prevProgressState.current = state; progress(state); }, [progress, progressEnabled, hasToolsInProgress]); useEffect(() => { return () => progress(null); }, [progress]); const messageKey = useCallback((msg: RenderableMessage) => `${msg.uuid}-${conversationId}`, [conversationId]); const renderMessageRow = (msg: RenderableMessage, index: number) => { const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined; const isUserContinuation = msg.type === 'user' && prevType === 'user'; // hasContentAfter is only consumed for collapsed_read_search groups; // skip the scan for everything else. streamingText is rendered as a // sibling after this map, so it's never in renderableMessages — OR it // in explicitly so the group flips to past tense as soon as text starts // streaming instead of waiting for the block to finalize. const hasContentAfter = msg.type === 'collapsed_read_search' && (!!streamingText || hasContentAfterIndex(renderableMessages, index, tools, streamingToolUseIDs)); // Collapse diffs for messages beyond the latest N messages. // verbose (ctrl+o) overrides and always shows full diffs. // 0 was too aggressive — tool results are never the last message (assistant // text follows), so diffs were always collapsed. 3 keeps recent edits visible. const DIFF_COLLAPSE_DISTANCE = 3; const shouldCollapseDiffs = renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE; const k = messageKey(msg); const row = ( ); // Per-row Provider — only 2 rows re-render on selection change. // Wrapped BEFORE divider branch so both return paths get it. const wrapped = ( {row} ); if (unseenDivider && index === dividerBeforeIndex) { return [ , wrapped, ]; } return wrapped; }; // Search indexing: for tool_result messages, look up the Tool and use // its extractSearchText — tool-owned, precise, matches what // renderToolResultMessage shows. Falls back to renderableSearchText // (duck-types toolUseResult) for tools that haven't implemented it, // and for all non-tool-result message types. The drift-catcher test // (searchExtraToolsText.test.tsx) renders + compares to keep these in sync. // // A second-React-root reconcile approach was tried and ruled out // (measured 3.1ms/msg, growing — flushSyncWork processes all roots; // component hooks mutate shared state → main root accumulates updates). const searchTextCache = useRef(new WeakMap()); const extractSearchText = useCallback( (msg: RenderableMessage): string => { const cached = searchTextCache.current.get(msg); if (cached !== undefined) return cached; let text = renderableSearchText(msg); // If this is a tool_result message and the tool implements // extractSearchText, prefer that — it's precise (tool-owned) // vs renderableSearchText's field-name heuristic. if (msg.type === 'user' && msg.toolUseResult && Array.isArray(msg.message.content)) { const tr = msg.message.content.find(b => b.type === 'tool_result'); if (tr && 'tool_use_id' in tr) { const tu = lookups.toolUseByToolUseID.get(tr.tool_use_id); const tool = tu && findToolByName(tools, tu.name); const extracted = tool?.extractSearchText?.(msg.toolUseResult as never); // undefined = tool didn't implement → keep heuristic. Empty // string = tool says "nothing to index" → respect that. if (extracted !== undefined) text = extracted; } } // Cache LOWERED: setSearchQuery's hot loop indexOfs per keystroke. // Lowering here (once, at warm) vs there (every keystroke) trades // ~same steady-state memory for zero per-keystroke alloc. Cache // GC's with messages on transcript exit. Tool methods return raw; // renderableSearchText already lowercases (redundant but cheap). const lowered = text.toLowerCase(); searchTextCache.current.set(msg, lowered); return lowered; }, [tools, lookups], ); return ( {/* Logo */} {!hideLogo && !(renderRange && renderRange[0] > 0) && } {/* Truncation indicator */} {hasTruncatedMessages && ( )} {/* Show all indicator */} {isTranscriptMode && showAllInTranscript && hiddenMessageCount > 0 && // disableRenderCap (e.g. [ dump-to-scrollback) means we're uncapped // as a one-shot escape hatch, not a toggle — ctrl+e is dead and // nothing is actually "hidden" to restore. !disableRenderCap && ( )} {/* Messages - rendered as memoized MessageRow components. flatMap inserts the unseen-divider as a separate keyed sibling so (a) non-fullscreen renders pay no per-message Fragment wrap, and (b) divider toggle in fullscreen preserves all MessageRows by key. Pre-compute derived values instead of passing renderableMessages to each row - React Compiler pins props in the fiber's memoCache, so passing the array would accumulate every historical version (~1-2MB over a 7-turn session). */} {virtualScrollRuntimeGate ? ( = 0 ? selectedIdx : undefined} cursorNavRef={cursorNavRef} setCursor={setCursor} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} extractSearchText={extractSearchText} /> ) : ( renderableMessages.flatMap(renderMessageRow) )} {streamingText && !isBriefOnly && ( {BLACK_CIRCLE} {streamingText} )} {isStreamingThinkingVisible && streamingThinking && !isBriefOnly && ( )} ); }; /** Key for click-to-expand: tool_use_id where available (so tool_use + its * tool_result expand together), else uuid for groups/thinking. */ function expandKey(msg: RenderableMessage): string { return (msg.type === 'assistant' || msg.type === 'user' ? getToolUseID(msg) : null) ?? msg.uuid; } // Custom comparator to prevent unnecessary re-renders during streaming. // Default React.memo does shallow comparison which fails when: // 1. onOpenRateLimitOptions callback is recreated (doesn't affect render output) // 2. streamingToolUses array is recreated on every delta, but only contentBlock matters for rendering // 3. streamingThinking changes on every delta - we DO want to re-render for this function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const item of a) { if (!b.has(item)) return false; } return true; } export const Messages = React.memo(MessagesImpl, (prev, next) => { const keys = Object.keys(prev) as (keyof typeof prev)[]; for (const key of keys) { if ( key === 'onOpenRateLimitOptions' || key === 'scrollRef' || key === 'trackStickyPrompt' || key === 'setCursor' || key === 'cursorNavRef' || key === 'jumpRef' || key === 'onSearchMatchesChange' || key === 'scanElement' || key === 'setPositions' ) continue; if (prev[key] !== next[key]) { if (key === 'streamingToolUses') { const p = prev.streamingToolUses; const n = next.streamingToolUses; if (p.length === n.length && p.every((item, i) => item.contentBlock === n[i]?.contentBlock)) { continue; } } if (key === 'inProgressToolUseIDs') { if (setsEqual(prev.inProgressToolUseIDs, next.inProgressToolUseIDs)) { continue; } } if (key === 'unseenDivider') { const p = prev.unseenDivider; const n = next.unseenDivider; if (p?.firstUnseenUuid === n?.firstUnseenUuid && p?.count === n?.count) { continue; } } if (key === 'tools') { const p = prev.tools; const n = next.tools; if (p.length === n.length && p.every((tool, i) => tool.name === n[i]?.name)) { continue; } } // streamingThinking changes frequently - always re-render when it changes // (no special handling needed, default behavior is correct) return false; } } return true; }); export function shouldRenderStatically( message: RenderableMessage, streamingToolUseIDs: Set, inProgressToolUseIDs: Set, siblingToolUseIDs: ReadonlySet, screen: Screen, lookups: ReturnType, ): boolean { if (screen === 'transcript') { return true; } switch (message.type) { case 'attachment': case 'user': case 'assistant': { if (message.type === 'assistant') { const block = (message.message!.content as Array<{ type: string; id?: string }>)[0]; if (block?.type === 'server_tool_use') { return lookups.resolvedToolUseIDs.has(block.id!); } } const toolUseID = getToolUseID(message); if (!toolUseID) { return true; } if (streamingToolUseIDs.has(toolUseID)) { return false; } if (inProgressToolUseIDs.has(toolUseID)) { return false; } // Check if there are any unresolved PostToolUse hooks for this tool use // If so, keep the message transient so the HookProgressMessage can update if (hasUnresolvedHooksFromLookup(toolUseID, 'PostToolUse', lookups)) { return false; } return every(siblingToolUseIDs, lookups.resolvedToolUseIDs); } case 'system': { // api errors always render dynamically, since we hide // them as soon as we see another non-error message. return message.subtype !== 'api_error'; } case 'grouped_tool_use': { const allResolved = message.messages.every(msg => { const content = (msg.message!.content as Array<{ type: string; id?: string }>)[0]; return content?.type === 'tool_use' && lookups.resolvedToolUseIDs.has(content.id!); }); return allResolved; } case 'collapsed_read_search': { // In prompt mode, never mark as static to prevent flicker between API turns // (In transcript mode, we already returned true at the top of this function) return false; } default: return true; } }