Files
claude-code/src/components/Messages.tsx
claude-code-best 2006ab25ff fix: 添加 React Error Boundary 防止生产环境渲染崩溃
增强 SentryErrorBoundary 组件,捕获渲染错误时输出诊断信息
(错误消息 + component stack)到 stderr 和终端,而非静默返回
null。在 replLauncher 根节点和 Messages 组件层级包裹 Error
Boundary,防止 Ink 内部的 Error Boundary 直接终止进程。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-09 22:02:04 +08:00

1115 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<OffscreenFreeze>
<Box flexDirection="column" gap={1}>
<LogoV2 />
<React.Suspense fallback={null}>
<StatusNotices agentDefinitions={agentDefinitions} />
</React.Suspense>
</Box>
</OffscreenFreeze>
);
});
// 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<string>();
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<number>();
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<string>;
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<ScrollBoxHandle | null>;
/** Fullscreen-mode: enable sticky-prompt tracking (writes via ScrollChromeContext). */
trackStickyPrompt?: boolean;
/** Transcript search: jump-to-index + setSearchQuery/nextMatch/prevMatch. */
jumpRef?: RefObject<JumpHandle | null>;
/** 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<MessageActionsNav>;
/** 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('<bash-stdout') || text.startsWith('<bash-stderr')) {
bashUUID = msg.uuid;
break;
}
}
}
}
// Thinking stop condition — reached a previous user turn without tool result
if (needThinkingScan && !thinkingId) {
const hasToolResult = content.some(block => 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<SliceAnchor>(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<NormalizedMessage, ProgressMessageType> =>
// 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<typeof reorderMessagesInUI>[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<typeof filterForBriefTool>[0], briefToolNames)
: dropTextToolNames.length > 0
? dropTextInBriefTurns(
messagesToShowNotTruncated as Parameters<typeof dropTextInBriefTurns>[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<ReadonlySet<string>>(() => 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<string | null>(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.
const DIFF_COLLAPSE_DISTANCE = 0;
const shouldCollapseDiffs = renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE;
const k = messageKey(msg);
const row = (
<MessageRow
key={k}
message={msg}
isUserContinuation={isUserContinuation}
hasContentAfter={hasContentAfter}
tools={tools}
commands={commands}
verbose={verbose || isItemExpanded(msg) || (cursor?.expanded === true && index === selectedIdx)}
inProgressToolUseIDs={inProgressToolUseIDs}
streamingToolUseIDs={streamingToolUseIDs}
screen={screen}
canAnimate={canAnimate}
onOpenRateLimitOptions={onOpenRateLimitOptions}
lastThinkingBlockId={lastThinkingBlockId}
latestBashOutputUUID={latestBashOutputUUID}
columns={columns}
isLoading={isLoading}
lookups={lookups}
shouldCollapseDiffs={shouldCollapseDiffs}
/>
);
// Per-row Provider — only 2 rows re-render on selection change.
// Wrapped BEFORE divider branch so both return paths get it.
const wrapped = (
<MessageActionsSelectedContext.Provider key={k} value={index === selectedIdx}>
{row}
</MessageActionsSelectedContext.Provider>
);
if (unseenDivider && index === dividerBeforeIndex) {
return [
<Box key="unseen-divider" marginTop={1}>
<Divider
title={`${unseenDivider.count} new ${plural(unseenDivider.count, 'message')}`}
width={columns}
color="inactive"
/>
</Box>,
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<RenderableMessage, string>());
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 (
<SentryErrorBoundary name="MessagesBoundary">
{/* Logo */}
{!hideLogo && !(renderRange && renderRange[0] > 0) && <LogoHeader agentDefinitions={agentDefinitions} />}
{/* Truncation indicator */}
{hasTruncatedMessages && (
<Divider
title={`${toggleShowAllShortcut} to show ${chalk.bold(hiddenMessageCount)} previous messages`}
width={columns}
/>
)}
{/* 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 && (
<Divider
title={`${toggleShowAllShortcut} to hide ${chalk.bold(hiddenMessageCount)} previous messages`}
width={columns}
/>
)}
{/* 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 ? (
<InVirtualListContext.Provider value={true}>
<VirtualMessageList
messages={renderableMessages}
scrollRef={scrollRef}
columns={columns}
itemKey={messageKey}
renderItem={renderMessageRow}
onItemClick={onItemClick}
isItemClickable={isItemClickable}
isItemExpanded={isItemExpanded}
trackStickyPrompt={trackStickyPrompt}
selectedIndex={selectedIdx >= 0 ? selectedIdx : undefined}
cursorNavRef={cursorNavRef}
setCursor={setCursor}
jumpRef={jumpRef}
onSearchMatchesChange={onSearchMatchesChange}
scanElement={scanElement}
setPositions={setPositions}
extractSearchText={extractSearchText}
/>
</InVirtualListContext.Provider>
) : (
renderableMessages.flatMap(renderMessageRow)
)}
{streamingText && !isBriefOnly && (
<Box alignItems="flex-start" flexDirection="row" marginTop={1} width="100%">
<Box flexDirection="row">
<Box minWidth={2}>
<Text color="text">{BLACK_CIRCLE}</Text>
</Box>
<Box flexDirection="column">
<StreamingMarkdown>{streamingText}</StreamingMarkdown>
</Box>
</Box>
</Box>
)}
{isStreamingThinkingVisible && streamingThinking && !isBriefOnly && (
<Box marginTop={1}>
<AssistantThinkingMessage
param={{
type: 'thinking',
thinking: streamingThinking.thinking,
}}
addMargin={false}
isTranscriptMode={true}
verbose={verbose}
hideInTranscript={false}
/>
</Box>
)}
</SentryErrorBoundary>
);
};
/** 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<T>(a: Set<T>, b: Set<T>): 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<string>,
inProgressToolUseIDs: Set<string>,
siblingToolUseIDs: ReadonlySet<string>,
screen: Screen,
lookups: ReturnType<typeof buildMessageLookups>,
): 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;
}
}