import { feature } from 'bun:bundle'; import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'; import type { ImageBlockParam, TextBlockParam, ThinkingBlockParam, ToolResultBlockParam, ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/index.mjs'; import * as React from 'react'; import type { Command } from '../commands.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { Box } from '@anthropic/ink'; import type { Tools } from '../Tool.js'; import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.js'; import type { AssistantMessage, AttachmentMessage as AttachmentMessageType, CollapsedReadSearchGroup as CollapsedReadSearchGroupType, GroupedToolUseMessage as GroupedToolUseMessageType, NormalizedUserMessage, ProgressMessage, SystemMessage, } from '../types/message.js'; import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; import { logError } from '../utils/log.js'; import type { buildMessageLookups } from '../utils/messages.js'; import { CompactSummary } from './CompactSummary.js'; import { AdvisorMessage } from './messages/AdvisorMessage.js'; import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js'; import { AssistantTextMessage } from './messages/AssistantTextMessage.js'; import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js'; import { AttachmentMessage } from './messages/AttachmentMessage.js'; import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js'; import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js'; import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js'; import { SystemTextMessage } from './messages/SystemTextMessage.js'; import { UserImageMessage } from './messages/UserImageMessage.js'; import { UserTextMessage } from './messages/UserTextMessage.js'; import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js'; import { OffscreenFreeze } from './OffscreenFreeze.js'; import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js'; export type Props = { message: | NormalizedUserMessage | AssistantMessage | AttachmentMessageType | SystemMessage | GroupedToolUseMessageType | CollapsedReadSearchGroupType; lookups: ReturnType; // TODO: Find a way to remove this, and leave spacing to the consumer /** Absolute width for the container Box. When provided, eliminates a wrapper Box in the caller. */ containerWidth?: number; addMargin: boolean; tools: Tools; commands: Command[]; verbose: boolean; inProgressToolUseIDs: Set; progressMessagesForMessage: ProgressMessage[]; shouldAnimate: boolean; shouldShowDot: boolean; style?: 'condensed'; width?: number | string; isTranscriptMode: boolean; isStatic: boolean; onOpenRateLimitOptions?: () => void; isActiveCollapsedGroup?: boolean; isUserContinuation?: boolean; /** ID of the last thinking block (uuid:index) to show, used for hiding past thinking in transcript mode */ lastThinkingBlockId?: string | null; /** UUID of the latest user bash output message (for auto-expanding) */ latestBashOutputUUID?: string | null; /** Whether to collapse diff display for this message */ shouldCollapseDiffs?: boolean; }; function MessageImpl({ message, lookups, containerWidth, addMargin, tools, commands, verbose, inProgressToolUseIDs, progressMessagesForMessage, shouldAnimate, shouldShowDot, style, width, isTranscriptMode, onOpenRateLimitOptions, isActiveCollapsedGroup, isUserContinuation = false, lastThinkingBlockId, latestBashOutputUUID, shouldCollapseDiffs, }: Props): React.ReactNode { switch (message.type) { case 'attachment': return ( ); case 'assistant': return ( {(message.message.content as BetaContentBlock[]).map((_, index) => ( ))} ); case 'user': { if (message.isCompactSummary) { return ; } // Precompute the imageIndex prop for each content block. The previous // version incremented a counter inside the .map() callback, which // React Compiler bails on ("UpdateExpression to variables captured // within lambdas"). A plain for loop keeps the mutation out of a // closure so the compiler can memoize MessageImpl. const imageIndices: number[] = []; let imagePosition = 0; for (const param of message.message.content as Array<{ type: string }>) { if (param.type === 'image') { const id = message.imagePasteIds?.[imagePosition]; imagePosition++; imageIndices.push(id ?? imagePosition); } else { imageIndices.push(imagePosition); } } // Check if this message is the latest bash output - if so, wrap content // with provider so OutputLine can show full output via context const isLatestBashOutput = latestBashOutputUUID === message.uuid; const content = ( {( message.message.content as Array< TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam > ).map((param, index) => ( ))} ); return isLatestBashOutput ? {content} : content; } case 'system': if (message.subtype === 'compact_boundary') { // Fullscreen keeps pre-compact messages in the ScrollBox (REPL.tsx // appends instead of resetting, Messages.tsx skips the boundary // filter) — scroll up for history, no need for the ctrl+o hint. if (isFullscreenEnvEnabled()) { return null; } return ; } if (message.subtype === 'microcompact_boundary') { // Logged at creation time in createMicrocompactBoundaryMessage return null; } if (feature('HISTORY_SNIP')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isSnipBoundaryMessage } = require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js'); const { isSnipMarkerMessage } = require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (isSnipBoundaryMessage(message)) { /* eslint-disable @typescript-eslint/no-require-imports */ const { SnipBoundaryMessage } = require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js'); /* eslint-enable @typescript-eslint/no-require-imports */ return ; } if (isSnipMarkerMessage(message)) { // Internal registration marker — not user-facing. The boundary // message (above) is what shows when snips actually execute. return null; } } if (message.subtype === 'local_command') { return ( ); } return ( ); case 'grouped_tool_use': return ( ); case 'collapsed_read_search': // OffscreenFreeze: the verb flips "Reading…"→"Read" when tools complete. // If the group has scrolled into scrollback by then, the update triggers // a full terminal reset (CC-1155). This component is never marked static // in prompt mode (shouldRenderStatically returns false to allow live // updates between API turns), so the memo can't help. Freeze when // offscreen — scrollback shows whatever state was visible when it left. return ( ); } } function UserMessage({ message, addMargin, tools, progressMessagesForMessage, param, style, verbose, imageIndex, isUserContinuation, lookups, isTranscriptMode, shouldCollapseDiffs, }: { message: NormalizedUserMessage; addMargin: boolean; tools: Tools; progressMessagesForMessage: ProgressMessage[]; param: TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam; style?: 'condensed'; verbose: boolean; imageIndex?: number; isUserContinuation: boolean; lookups: ReturnType; isTranscriptMode: boolean; shouldCollapseDiffs?: boolean; }): React.ReactNode { const { columns } = useTerminalSize(); switch (param.type) { case 'text': return ( ); case 'image': // If previous message is user (text or image), this is a continuation - use connector // Otherwise this image starts a new user turn - use margin return ; case 'tool_result': return ( ); default: return undefined; } } function AssistantMessageBlock({ param, addMargin, tools, commands, verbose, inProgressToolUseIDs, progressMessagesForMessage, shouldAnimate, shouldShowDot, width, inProgressToolCallCount, isTranscriptMode, lookups, onOpenRateLimitOptions, thinkingBlockId, lastThinkingBlockId, advisorModel, }: { param: | BetaContentBlock | ConnectorTextBlock | AdvisorBlock | TextBlockParam | ImageBlockParam | ThinkingBlockParam | ToolUseBlockParam | ToolResultBlockParam; addMargin: boolean; tools: Tools; commands: Command[]; verbose: boolean; inProgressToolUseIDs: Set; progressMessagesForMessage: ProgressMessage[]; shouldAnimate: boolean; shouldShowDot: boolean; width?: number | string; inProgressToolCallCount?: number; isTranscriptMode: boolean; lookups: ReturnType; onOpenRateLimitOptions?: () => void; /** ID of this content block's message:index for thinking block comparison */ thinkingBlockId: string; /** ID of the last thinking block to show, null means show all */ lastThinkingBlockId?: string | null; advisorModel?: string; }): React.ReactNode { if (feature('CONNECTOR_TEXT')) { if (isConnectorTextBlock(param)) { return ( ); } } switch (param.type) { case 'tool_use': return ( ); case 'text': return ( ); case 'redacted_thinking': if (!isTranscriptMode && !verbose) { return null; } return ; case 'thinking': { if (!isTranscriptMode && !verbose) { return null; } // In transcript mode with hidePastThinking, only show the last thinking block const isLastThinking = !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId; return ( ); } case 'server_tool_use': case 'advisor_tool_result': if (isAdvisorBlock(param)) { return ( ); } logError(new Error(`Unable to render server tool block: ${param.type}`)); return null; default: logError(new Error(`Unable to render message type: ${param.type}`)); return null; } } export function hasThinkingContent(m: { type: string; message?: { content: Array<{ type: string }> } }): boolean { if (m.type !== 'assistant' || !m.message) return false; return m.message.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking'); } /** Exported for testing */ export function areMessagePropsEqual(prev: Props, next: Props): boolean { if (prev.message.uuid !== next.message.uuid) return false; // Only re-render on lastThinkingBlockId change if this message actually // has thinking content — otherwise every message in scrollback re-renders // whenever streaming thinking starts/stops (CC-941). if ( prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as Parameters[0]) ) { return false; } // Verbose toggle changes thinking block visibility/expansion if (prev.verbose !== next.verbose) return false; // Only re-render if this message's "is latest bash output" status changed, // not when the global latestBashOutputUUID changes to a different message const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid; const nextIsLatest = next.latestBashOutputUUID === next.message.uuid; if (prevIsLatest !== nextIsLatest) return false; if (prev.isTranscriptMode !== next.isTranscriptMode) return false; // containerWidth is an absolute number in the no-metadata path (wrapper // Box is skipped). Static messages must re-render on terminal resize. if (prev.containerWidth !== next.containerWidth) return false; if (prev.isStatic && next.isStatic) return true; return false; } export const Message = React.memo(MessageImpl, areMessagePropsEqual);