import * as React from 'react' import type { Command } from '../commands.js' import { Box } from '@anthropic/ink' import type { Screen } from '../screens/REPL.js' import type { Tools } from '../Tool.js' import type { RenderableMessage } from '../types/message.js' import { getDisplayMessageFromCollapsed, getToolSearchOrReadInfo, getToolUseIdsFromCollapsedGroup, hasAnyToolInProgress, } from '../utils/collapseReadSearch.js' import { type buildMessageLookups, EMPTY_STRING_SET, getProgressMessagesFromLookup, getSiblingToolUseIDsFromLookup, getToolUseID, } from '../utils/messages.js' import { hasThinkingContent, Message } from './Message.js' // Narrow the first element of MessageContent to a block with known shape. type ContentBlock = { type: string; name?: string; input?: unknown; id?: string; text?: string; [key: string]: unknown } const firstBlock = (content: unknown): ContentBlock | undefined => { if (!Array.isArray(content)) return undefined const b = content[0] if (b == null || typeof b === 'string') return undefined return b as ContentBlock } import { MessageModel } from './MessageModel.js' import { shouldRenderStatically } from './Messages.js' import { MessageTimestamp } from './MessageTimestamp.js' import { OffscreenFreeze } from './OffscreenFreeze.js' export type Props = { message: RenderableMessage /** Whether the previous message in renderableMessages is also a user message. */ isUserContinuation: boolean /** * Whether there is non-skippable content after this message in renderableMessages. * Only needs to be accurate for `collapsed_read_search` messages — used to decide * if the collapsed group spinner should stay active. Pass `false` otherwise. */ hasContentAfter: boolean tools: Tools commands: Command[] verbose: boolean inProgressToolUseIDs: Set streamingToolUseIDs: Set screen: Screen canAnimate: boolean onOpenRateLimitOptions?: () => void lastThinkingBlockId: string | null latestBashOutputUUID: string | null columns: number isLoading: boolean lookups: ReturnType shouldCollapseDiffs?: boolean } /** * Scans forward from `index+1` to check if any "real" content follows. Used to * decide whether a collapsed read/search group should stay in its active * (grey dot, present-tense "Reading…") state while the query is still loading. * * Exported so Messages.tsx can compute this once per message and pass the * result as a boolean prop — avoids passing the full `renderableMessages` array * to each MessageRow (which React Compiler would pin in the fiber's memoCache, * accumulating every historical version of the array ≈ 1-2MB over a 7-turn session). */ export function hasContentAfterIndex( messages: RenderableMessage[], index: number, tools: Tools, streamingToolUseIDs: Set, ): boolean { for (let i = index + 1; i < messages.length; i++) { const msg = messages[i] if (msg?.type === 'assistant') { const content = firstBlock(msg.message.content) if ( content?.type === 'thinking' || content?.type === 'redacted_thinking' ) { continue } if (content?.type === 'tool_use') { if ( getToolSearchOrReadInfo(content.name!, content.input, tools) .isCollapsible ) { continue } // Non-collapsible tool uses appear in syntheticStreamingToolUseMessages // before their ID is added to inProgressToolUseIDs. Skip while streaming // to avoid briefly finalizing the read group. if (streamingToolUseIDs.has(content.id!)) { continue } } return true } if (msg?.type === 'system' || msg?.type === 'attachment') { continue } // Tool results arrive while the collapsed group is still being built if (msg?.type === 'user') { const content = firstBlock(msg.message.content) if (content?.type === 'tool_result') { continue } } // Collapsible grouped_tool_use messages arrive transiently before being // merged into the current collapsed group on the next render cycle if (msg?.type === 'grouped_tool_use') { const firstInput = firstBlock(msg.messages[0]?.message.content)?.input if ( getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible ) { continue } } return true } return false } function MessageRowImpl({ message: msg, isUserContinuation, hasContentAfter, tools, commands, verbose, inProgressToolUseIDs, streamingToolUseIDs, screen, canAnimate, onOpenRateLimitOptions, lastThinkingBlockId, latestBashOutputUUID, columns, isLoading, lookups, shouldCollapseDiffs, }: Props): React.ReactNode { const isTranscriptMode = screen === 'transcript' const isGrouped = msg.type === 'grouped_tool_use' const isCollapsed = msg.type === 'collapsed_read_search' // A collapsed group is "active" (grey dot, present tense "Reading…") when its tools // are still executing OR when the overall query is still running with nothing after it. // hasAnyToolInProgress takes priority: if tools are running, always show active regardless // of what else is in the message list (avoids false finalization during parallel execution). const isActiveCollapsedGroup = isCollapsed && (hasAnyToolInProgress(msg, inProgressToolUseIDs) || (isLoading && !hasContentAfter)) const displayMsg = isGrouped ? msg.displayMessage : isCollapsed ? getDisplayMessageFromCollapsed(msg) : msg const progressMessagesForMessage = isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups) const siblingToolUseIDs = isGrouped || isCollapsed ? EMPTY_STRING_SET : getSiblingToolUseIDsFromLookup(msg, lookups) const isStatic = shouldRenderStatically( msg, streamingToolUseIDs, inProgressToolUseIDs, siblingToolUseIDs, screen, lookups, ) let shouldAnimate = false if (canAnimate) { if (isGrouped) { shouldAnimate = msg.messages.some(m => { const content = firstBlock(m.message.content) return ( content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id!) ) }) } else if (isCollapsed) { shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs) } else { const toolUseID = getToolUseID(msg) shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID) } } const hasMetadata = isTranscriptMode && displayMsg.type === 'assistant' && (Array.isArray(displayMsg.message.content) && (displayMsg.message.content as Array<{ type: string }>).some(c => c.type === 'text')) && (displayMsg.timestamp || displayMsg.message.model) const messageEl = ( [0]['message']} lookups={lookups} addMargin={!hasMetadata} containerWidth={hasMetadata ? undefined : columns} tools={tools} commands={commands} verbose={verbose} inProgressToolUseIDs={inProgressToolUseIDs} progressMessagesForMessage={progressMessagesForMessage} shouldAnimate={shouldAnimate} shouldShowDot={true} isTranscriptMode={isTranscriptMode} isStatic={isStatic} onOpenRateLimitOptions={onOpenRateLimitOptions} isActiveCollapsedGroup={isActiveCollapsedGroup} isUserContinuation={isUserContinuation} lastThinkingBlockId={lastThinkingBlockId} latestBashOutputUUID={latestBashOutputUUID} shouldCollapseDiffs={shouldCollapseDiffs} /> ) // OffscreenFreeze: the outer React.memo already bails for static messages, // so this only wraps rows that DO re-render — in-progress tools, collapsed // read/search spinners, bash elapsed timers. When those rows have scrolled // into terminal scrollback (non-fullscreen external builds), any content // change forces log-update.ts into a full terminal reset per tick. Freezing // returns the cached element ref so React bails and produces zero diff. if (!hasMetadata) { return {messageEl} } // Margin on children, not here — else null items (hook_success etc.) get phantom 1-row spacing. return ( {messageEl} ) } /** * Checks if a message is "streaming" - i.e., its content may still be changing. * Exported for testing. */ export function isMessageStreaming( msg: RenderableMessage, streamingToolUseIDs: Set, ): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.some(m => { const content = firstBlock(m.message.content) return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!) }) } if (msg.type === 'collapsed_read_search') { const toolIds = getToolUseIdsFromCollapsedGroup(msg) return toolIds.some(id => streamingToolUseIDs.has(id)) } const toolUseID = getToolUseID(msg) return !!toolUseID && streamingToolUseIDs.has(toolUseID) } /** * Checks if all tools in a message are resolved. * Exported for testing. */ export function allToolsResolved( msg: RenderableMessage, resolvedToolUseIDs: Set, ): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.every(m => { const content = firstBlock(m.message.content) return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!) }) } if (msg.type === 'collapsed_read_search') { const toolIds = getToolUseIdsFromCollapsedGroup(msg) return toolIds.every(id => resolvedToolUseIDs.has(id)) } if (msg.type === 'assistant') { const block = firstBlock(msg.message.content) if (block?.type === 'server_tool_use') { return resolvedToolUseIDs.has(block.id!) } } const toolUseID = getToolUseID(msg) return !toolUseID || resolvedToolUseIDs.has(toolUseID) } /** * Conservative memo comparator that only bails out when we're CERTAIN * the message won't change. Fails safe by re-rendering when uncertain. * * Exported for testing. */ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean { // Different message reference = content may have changed, must re-render if (prev.message !== next.message) return false // Screen mode change = re-render if (prev.screen !== next.screen) return false // Verbose toggle changes thinking block visibility if (prev.verbose !== next.verbose) return false // collapsed_read_search is never static in prompt mode (matches shouldRenderStatically) if ( prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript' ) { return false } // Width change affects Box layout if (prev.columns !== next.columns) return false // latestBashOutputUUID affects rendering (full vs truncated output) const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid if (prevIsLatestBash !== nextIsLatestBash) return false // lastThinkingBlockId affects thinking block visibility — but only for // messages that HAVE thinking content. Checking unconditionally busts the // memo for every scrollback message whenever thinking starts/stops (CC-941). if ( prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as Parameters[0]) ) { return false } // Check if this message is still "in flight" const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs) const isResolved = allToolsResolved( prev.message, prev.lookups.resolvedToolUseIDs, ) // Only bail out for truly static messages if (isStreaming || !isResolved) return false // Static message - safe to skip re-render return true } export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual)