claude-code-best/src/components/messages/SystemTextMessage.tsx
2026-05-01 21:39:30 +08:00

427 lines
14 KiB
TypeScript

// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { Box, Link, Text, type TextProps } from '@anthropic/ink';
import { FilePathLink } from '../FilePathLink.js';
import { feature } from 'bun:bundle';
import * as React from 'react';
import { useState } from 'react';
import sample from 'lodash-es/sample.js';
import { BLACK_CIRCLE, REFERENCE_MARK, TEARDROP_ASTERISK } from '../../constants/figures.js';
import figures from 'figures';
import { basename } from 'path';
import { MessageResponse } from '../MessageResponse.js';
import { openPath } from '../../utils/browser.js';
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemSaved = feature('TEAMMEM') ? (require('./teamMemSaved.js') as typeof import('./teamMemSaved.js')) : null;
/* eslint-enable @typescript-eslint/no-require-imports */
import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type {
SystemMessage,
SystemStopHookSummaryMessage,
SystemBridgeStatusMessage,
SystemTurnDurationMessage,
SystemThinkingMessage,
SystemMemorySavedMessage,
} from '../../types/message.js';
import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js';
import { formatDuration, formatNumber, formatSecondsShort } from '../../utils/format.js';
import { getGlobalConfig } from '../../utils/config.js';
import ThemedText from '../design-system/ThemedText.js';
import { CtrlOToExpand } from '../CtrlOToExpand.js';
import { useAppStateStore } from '../../state/AppState.js';
import { isBackgroundTask, type TaskState } from '../../tasks/types.js';
import { getPillLabel } from '../../tasks/pillLabel.js';
import { useSelectedMessageBg } from '../messageActions.js';
type Props = {
message: SystemMessage;
addMargin: boolean;
verbose: boolean;
isTranscriptMode?: boolean;
};
export function SystemTextMessage({ message, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode {
const bg = useSelectedMessageBg();
// Turn duration messages are always shown in grey
if (message.subtype === 'turn_duration') {
return <TurnDurationMessage message={message} addMargin={addMargin} />;
}
if (message.subtype === 'memory_saved') {
return <MemorySavedMessage message={message} addMargin={addMargin} />;
}
if (message.subtype === 'away_summary') {
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text dimColor>{REFERENCE_MARK}</Text>
</Box>
<Text dimColor>{String(message.content ?? '')}</Text>
</Box>
);
}
// Agents killed confirmation
if (message.subtype === 'agents_killed') {
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text color="error">{BLACK_CIRCLE}</Text>
</Box>
<Text dimColor>All background agents stopped</Text>
</Box>
);
}
// Thinking messages are subtle, like turn duration (ant-only)
if (message.subtype === 'thinking') {
if (process.env.USER_TYPE === 'ant') {
return <ThinkingMessage message={message} addMargin={addMargin} />;
}
return null;
}
if (message.subtype === 'bridge_status') {
return <BridgeStatusMessage message={message} addMargin={addMargin} />;
}
if (message.subtype === 'scheduled_task_fire') {
return (
<Box marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Text dimColor>
{TEARDROP_ASTERISK} {String(message.content ?? '')}
</Text>
</Box>
);
}
if (message.subtype === 'permission_retry') {
return (
<Box marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Text dimColor>{TEARDROP_ASTERISK} </Text>
<Text>Allowed </Text>
<Text bold>{(message.commands as string[]).join(', ')}</Text>
</Box>
);
}
// Stop hook summaries should always be visible
const isStopHookSummary = message.subtype === 'stop_hook_summary';
if (!isStopHookSummary && !verbose && message.level === 'info') {
return null;
}
if (message.subtype === 'api_error') {
return <SystemAPIErrorMessage message={message} verbose={verbose} />;
}
if (message.subtype === 'stop_hook_summary') {
return (
<StopHookSummaryMessage
message={message as SystemStopHookSummaryMessage}
addMargin={addMargin}
verbose={verbose}
isTranscriptMode={isTranscriptMode}
/>
);
}
const content = message.content;
// In case the event doesn't have a content
// validation, so content can be undefined at runtime despite the types.
if (typeof content !== 'string') {
return null;
}
return (
<Box flexDirection="row" width="100%">
<SystemTextMessageInner
content={content}
addMargin={addMargin}
dot={message.level !== 'info'}
color={message.level === 'warning' ? 'warning' : undefined}
dimColor={message.level === 'info'}
/>
</Box>
);
}
function StopHookSummaryMessage({
message,
addMargin,
verbose,
isTranscriptMode,
}: {
message: SystemStopHookSummaryMessage;
addMargin: boolean;
verbose: boolean;
isTranscriptMode?: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg();
const { hookCount, hookInfos } = message;
const hookErrors = (message.hookErrors ?? []) as string[];
const preventedContinuation = message.preventedContinuation as boolean | undefined;
const stopReason = message.stopReason as string | undefined;
const { columns } = useTerminalSize();
// Prefer wall-clock time when available (hooks run in parallel)
const totalDurationMs = message.totalDurationMs ?? hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0);
const isAnt = process.env.USER_TYPE === 'ant';
// Only show summary if there are errors or continuation was prevented
// For ants: also show when hooks took > 500ms
// Non-stop hooks (e.g. PreToolUse) are pre-filtered by the caller
if (hookErrors.length === 0 && !preventedContinuation && !message.hookLabel) {
if (!isAnt || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) {
return null;
}
}
const totalStr = isAnt && totalDurationMs > 0 ? ` (${formatSecondsShort(totalDurationMs)})` : '';
// Non-stop hooks (e.g. PreToolUse) render as a child line without bullet
if (message.hookLabel) {
return (
<Box flexDirection="column" width="100%">
<Text dimColor>
{' ⎿ '}Ran {hookCount} {message.hookLabel} {hookCount === 1 ? 'hook' : 'hooks'}
{totalStr}
</Text>
{isTranscriptMode &&
hookInfos.map((info, idx) => {
const durationStr =
isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : '';
return (
<Text key={`cmd-${idx}`} dimColor>
{' ⎿ '}
{info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command}
{durationStr}
</Text>
);
})}
</Box>
);
}
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text>{BLACK_CIRCLE}</Text>
</Box>
<Box flexDirection="column" width={columns - 10}>
<Text>
Ran <Text bold>{hookCount}</Text> {message.hookLabel ?? 'stop'} {hookCount === 1 ? 'hook' : 'hooks'}
{totalStr}
{!verbose && hookInfos.length > 0 && (
<>
{' '}
<CtrlOToExpand />
</>
)}
</Text>
{verbose &&
hookInfos.length > 0 &&
hookInfos.map((info, idx) => {
const durationStr =
isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : '';
return (
<Text key={`cmd-${idx}`} dimColor>
&nbsp;
{info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command}
{durationStr}
</Text>
);
})}
{preventedContinuation && stopReason && (
<Text>
<Text dimColor> &nbsp;</Text>
{stopReason}
</Text>
)}
{hookErrors.length > 0 &&
hookErrors.map((err, idx) => (
<Text key={idx}>
<Text dimColor> &nbsp;</Text>
{message.hookLabel ?? 'Stop'} hook error: {err}
</Text>
))}
</Box>
</Box>
);
}
function SystemTextMessageInner({
content,
addMargin,
dot,
color,
dimColor,
}: {
content: string;
addMargin: boolean;
dot: boolean;
color?: TextProps['color'];
dimColor?: boolean;
}): React.ReactNode {
const { columns } = useTerminalSize();
const bg = useSelectedMessageBg();
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
{dot && (
<Box minWidth={2}>
<Text color={color} dimColor={dimColor}>
{BLACK_CIRCLE}
</Text>
</Box>
)}
<Box flexDirection="column" width={columns - 10}>
<Text color={color} dimColor={dimColor} wrap="wrap">
{content.trim()}
</Text>
</Box>
</Box>
);
}
function TurnDurationMessage({
message,
addMargin,
}: {
message: SystemTurnDurationMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg();
const [verb] = useState(() => sample(TURN_COMPLETION_VERBS) ?? 'Worked');
const store = useAppStateStore();
const [backgroundTaskSummary] = useState(() => {
const tasks = store.getState().tasks;
const running = (Object.values(tasks ?? {}) as TaskState[]).filter(isBackgroundTask);
return running.length > 0 ? getPillLabel(running) : null;
});
const showTurnDuration = getGlobalConfig().showTurnDuration ?? true;
const duration = formatDuration(message.durationMs as number);
const hasBudget = message.budgetLimit !== undefined;
const budgetSuffix = (() => {
if (!hasBudget) return '';
const tokens = message.budgetTokens as number;
const limit = message.budgetLimit as number;
const usage =
tokens >= limit
? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})`
: `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)`;
const nudges =
(message.budgetNudges as number) > 0
? ` \u00B7 ${message.budgetNudges as number} ${(message.budgetNudges as number) === 1 ? 'nudge' : 'nudges'}`
: '';
return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}`;
})();
if (!showTurnDuration && !hasBudget) {
return null;
}
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text dimColor>{TEARDROP_ASTERISK}</Text>
</Box>
<Text dimColor>
{showTurnDuration && `${verb} for ${duration}`}
{budgetSuffix}
{backgroundTaskSummary && ` \u00B7 ${backgroundTaskSummary} still running`}
</Text>
</Box>
);
}
function MemorySavedMessage({
message,
addMargin,
}: {
message: SystemMemorySavedMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg();
const writtenPaths = (message.writtenPaths ?? []) as string[];
const team = feature('TEAMMEM') ? teamMemSaved!.teamMemSavedPart(message) : null;
const privateCount = writtenPaths.length - (team?.count ?? 0);
const parts = [
privateCount > 0 ? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}` : null,
team?.segment as React.ReactNode,
].filter(Boolean);
return (
<Box flexDirection="column" marginTop={addMargin ? 1 : 0} backgroundColor={bg}>
<Box flexDirection="row">
<Box minWidth={2}>
<Text dimColor>{BLACK_CIRCLE}</Text>
</Box>
<Text>
{(message.verb as string) ?? 'Saved'} {parts.join(' \u00B7 ')}
</Text>
</Box>
{writtenPaths.map(p => (
<MemoryFileRow key={p} path={p} />
))}
</Box>
);
}
function MemoryFileRow({ path }: { path: string }): React.ReactNode {
const [hover, setHover] = useState(false);
return (
<MessageResponse>
<Box onClick={() => void openPath(path)} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<Text dimColor={!hover} underline={hover}>
<FilePathLink filePath={path}>{basename(path)}</FilePathLink>
</Text>
</Box>
</MessageResponse>
);
}
function ThinkingMessage({
message,
addMargin,
}: {
message: SystemThinkingMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg();
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width="100%">
<Box minWidth={2}>
<Text dimColor>{TEARDROP_ASTERISK}</Text>
</Box>
<Text dimColor>{String(message.content ?? '')}</Text>
</Box>
);
}
function BridgeStatusMessage({
message,
addMargin,
}: {
message: SystemBridgeStatusMessage;
addMargin: boolean;
}): React.ReactNode {
const bg = useSelectedMessageBg();
const url = message.url as string;
const upgradeNudge = message.upgradeNudge as string | undefined;
return (
<Box flexDirection="row" marginTop={addMargin ? 1 : 0} backgroundColor={bg} width={999}>
<Box minWidth={2} />
<Box flexDirection="column">
<Text>
<ThemedText color="suggestion">/remote-control</ThemedText> is active. Code in CLI or at
</Text>
<Link url={url}>{url}</Link>
{upgradeNudge && <Text dimColor> {upgradeNudge}</Text>}
</Box>
</Box>
);
}