Files
claude-code/src/components/Message.tsx
claude-code-best a14d3dc8f0 fix(types): clean type fixes across 92 files
Apply proper TypeScript type corrections without any unsafe casts:
- Fix unknown/never/{} types from decompilation
- Correct function signatures and parameter types
- Add missing type declarations and interfaces
- Fix Ink component prop types
- Update API client/provider type annotations

Test files with mock data casts are included as-is (acceptable pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 23:45:56 +08:00

527 lines
18 KiB
TypeScript

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<typeof buildMessageLookups>
// 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<string>
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
}
function MessageImpl({
message,
lookups,
containerWidth,
addMargin,
tools,
commands,
verbose,
inProgressToolUseIDs,
progressMessagesForMessage,
shouldAnimate,
shouldShowDot,
style,
width,
isTranscriptMode,
onOpenRateLimitOptions,
isActiveCollapsedGroup,
isUserContinuation = false,
lastThinkingBlockId,
latestBashOutputUUID,
}: Props): React.ReactNode {
switch (message.type) {
case 'attachment':
return (
<AttachmentMessage
addMargin={addMargin}
attachment={message.attachment as import('../utils/attachments.js').Attachment}
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
)
case 'assistant':
return (
<Box flexDirection="column" width={containerWidth ?? '100%'}>
{(message.message.content as BetaContentBlock[]).map((_, index) => (
<AssistantMessageBlock
key={index}
param={_}
addMargin={addMargin}
tools={tools}
commands={commands}
verbose={verbose}
inProgressToolUseIDs={inProgressToolUseIDs}
progressMessagesForMessage={progressMessagesForMessage}
shouldAnimate={shouldAnimate}
shouldShowDot={shouldShowDot}
width={width}
inProgressToolCallCount={inProgressToolUseIDs.size}
isTranscriptMode={isTranscriptMode}
lookups={lookups}
onOpenRateLimitOptions={onOpenRateLimitOptions}
thinkingBlockId={`${message.uuid}:${index}`}
lastThinkingBlockId={lastThinkingBlockId}
advisorModel={message.advisorModel as string | undefined}
/>
))}
</Box>
)
case 'user': {
if (message.isCompactSummary) {
return (
<CompactSummary
message={message}
screen={isTranscriptMode ? 'transcript' : 'prompt'}
/>
)
}
// 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 = (
<Box flexDirection="column" width={containerWidth ?? '100%'}>
{(message.message.content as Array<TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam>).map((param, index) => (
<UserMessage
key={index}
message={message}
addMargin={addMargin}
tools={tools}
progressMessagesForMessage={progressMessagesForMessage}
param={param}
style={style}
verbose={verbose}
imageIndex={imageIndices[index]!}
isUserContinuation={isUserContinuation}
lookups={lookups}
isTranscriptMode={isTranscriptMode}
/>
))}
</Box>
)
return isLatestBashOutput ? (
<ExpandShellOutputProvider>{content}</ExpandShellOutputProvider>
) : (
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 <CompactBoundaryMessage />
}
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 <SnipBoundaryMessage message={message} />
}
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 (
<UserTextMessage
addMargin={addMargin}
param={{ type: 'text', text: String(message.content ?? '') }}
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
)
}
return (
<SystemTextMessage
message={message}
addMargin={addMargin}
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
)
case 'grouped_tool_use':
return (
<GroupedToolUseContent
message={message}
tools={tools}
lookups={lookups}
inProgressToolUseIDs={inProgressToolUseIDs}
shouldAnimate={shouldAnimate}
/>
)
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 (
<OffscreenFreeze>
<CollapsedReadSearchContent
message={message}
inProgressToolUseIDs={inProgressToolUseIDs}
shouldAnimate={shouldAnimate}
// ctrl+o transcript mode should expand the group the same way
// --verbose does, so recalled memories + tool details are visible.
// AttachmentMessage.tsx's standalone relevant_memories branch
// already checks (verbose || isTranscriptMode); this aligns the
// collapsed-group path to match.
verbose={verbose || isTranscriptMode}
tools={tools}
lookups={lookups}
isActiveGroup={isActiveCollapsedGroup}
/>
</OffscreenFreeze>
)
}
}
function UserMessage({
message,
addMargin,
tools,
progressMessagesForMessage,
param,
style,
verbose,
imageIndex,
isUserContinuation,
lookups,
isTranscriptMode,
}: {
message: NormalizedUserMessage
addMargin: boolean
tools: Tools
progressMessagesForMessage: ProgressMessage[]
param:
| TextBlockParam
| ImageBlockParam
| ToolUseBlockParam
| ToolResultBlockParam
style?: 'condensed'
verbose: boolean
imageIndex?: number
isUserContinuation: boolean
lookups: ReturnType<typeof buildMessageLookups>
isTranscriptMode: boolean
}): React.ReactNode {
const { columns } = useTerminalSize()
switch (param.type) {
case 'text':
return (
<UserTextMessage
addMargin={addMargin}
param={param}
verbose={verbose}
planContent={message.planContent as string | undefined}
isTranscriptMode={isTranscriptMode}
timestamp={message.timestamp as string | undefined}
/>
)
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 (
<UserImageMessage
imageId={imageIndex}
addMargin={addMargin && !isUserContinuation}
/>
)
case 'tool_result':
return (
<UserToolResultMessage
param={param}
message={message}
lookups={lookups}
progressMessagesForMessage={progressMessagesForMessage}
style={style}
tools={tools}
verbose={verbose}
width={columns - 5}
isTranscriptMode={isTranscriptMode}
/>
)
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<string>
progressMessagesForMessage: ProgressMessage[]
shouldAnimate: boolean
shouldShowDot: boolean
width?: number | string
inProgressToolCallCount?: number
isTranscriptMode: boolean
lookups: ReturnType<typeof buildMessageLookups>
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 (
<AssistantTextMessage
param={{ type: 'text', text: param.connector_text }}
addMargin={addMargin}
shouldShowDot={shouldShowDot}
verbose={verbose}
width={width}
onOpenRateLimitOptions={onOpenRateLimitOptions}
/>
)
}
}
switch (param.type) {
case 'tool_use':
return (
<AssistantToolUseMessage
param={param as ToolUseBlockParam}
addMargin={addMargin}
tools={tools}
commands={commands}
verbose={verbose}
inProgressToolUseIDs={inProgressToolUseIDs}
progressMessagesForMessage={progressMessagesForMessage}
shouldAnimate={shouldAnimate}
shouldShowDot={shouldShowDot}
inProgressToolCallCount={inProgressToolCallCount}
lookups={lookups}
isTranscriptMode={isTranscriptMode}
/>
)
case 'text':
return (
<AssistantTextMessage
param={param as TextBlockParam}
addMargin={addMargin}
shouldShowDot={shouldShowDot}
verbose={verbose}
width={width}
onOpenRateLimitOptions={onOpenRateLimitOptions}
/>
)
case 'redacted_thinking':
if (!isTranscriptMode && !verbose) {
return null
}
return <AssistantRedactedThinkingMessage addMargin={addMargin} />
case 'thinking': {
if (!isTranscriptMode && !verbose) {
return null
}
// In transcript mode with hidePastThinking, only show the last thinking block
const isLastThinking =
!lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId
return (
<AssistantThinkingMessage
addMargin={addMargin}
param={param as ThinkingBlockParam | { type: 'thinking'; thinking: string }}
isTranscriptMode={isTranscriptMode}
verbose={verbose}
hideInTranscript={isTranscriptMode && !isLastThinking}
/>
)
}
case 'server_tool_use':
case 'advisor_tool_result':
if (isAdvisorBlock(param)) {
return (
<AdvisorMessage
block={param}
addMargin={addMargin}
resolvedToolUseIDs={lookups.resolvedToolUseIDs}
erroredToolUseIDs={lookups.erroredToolUseIDs}
shouldAnimate={shouldAnimate}
verbose={verbose || isTranscriptMode}
advisorModel={advisorModel}
/>
)
}
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<typeof hasThinkingContent>[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)