import { feature } from 'bun:bundle'
import chalk from 'chalk'
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 {
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,
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 (message UUID + content index) for hiding past thinking in transcript mode
// When streaming thinking is visible, use a special ID that won't match any completed thinking block
// With adaptive thinking, only consider thinking blocks from the current turn and stop searching once we
// hit the last user message.
const lastThinkingBlockId = useMemo(() => {
if (!hidePastThinking) return null
// If streaming thinking is visible, hide all completed thinking blocks by using a non-matching ID
if (isStreamingThinkingVisible) return 'streaming'
// Iterate backwards to find the last message with a thinking block
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
const msg = normalizedMessages[i]
if (msg?.type === 'assistant') {
const content = msg.message!.content as Array<{ type: string }>
// Find the last thinking block in this message
for (let j = content.length - 1; j >= 0; j--) {
if (content[j]?.type === 'thinking') {
return `${msg.uuid}:${j}`
}
}
} else if (msg?.type === 'user') {
const content = msg.message!.content as Array<{ type: string }>
const hasToolResult = content.some(
block => block.type === 'tool_result',
)
if (!hasToolResult) {
// Reached a previous user turn so don't show stale thinking from before
return 'no-thinking'
}
}
}
return null
}, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible])
// Find the latest user bash output message (from ! commands)
// This allows us to show full output for the most recent bash command
const latestBashOutputUUID = useMemo(() => {
// Iterate backwards to find the last user message with bash output
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 }>
// Check if any text content is bash output
for (const block of content) {
if (block.type === 'text') {
const text = block.text ?? ''
if (
text.startsWith(' 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)
// 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 =>
msg.type !== 'progress',
)
// 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.
.filter(msg => !isNullRenderingAttachment(msg))
.filter(_ => shouldShowUserMessage(_, 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 lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[])
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: first renderableMessage whose uuid shares the
// 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
// chars of the source message uuid, so this matches any block from it).
const dividerBeforeIndex = useMemo(() => {
if (!unseenDivider) return -1
const prefix = unseenDivider.firstUnseenUuid.slice(0, 24)
return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix)
}, [unseenDivider, renderableMessages])
const selectedIdx = useMemo(() => {
if (!cursor) return -1
return renderableMessages.findIndex(m => m.uuid === cursor.uuid)
}, [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,
))
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
// (toolSearchText.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
}
}