427 lines
14 KiB
TypeScript
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>
|
|
⎿
|
|
{info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command}
|
|
{durationStr}
|
|
</Text>
|
|
);
|
|
})}
|
|
{preventedContinuation && stopReason && (
|
|
<Text>
|
|
<Text dimColor>⎿ </Text>
|
|
{stopReason}
|
|
</Text>
|
|
)}
|
|
{hookErrors.length > 0 &&
|
|
hookErrors.map((err, idx) => (
|
|
<Text key={idx}>
|
|
<Text dimColor>⎿ </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>
|
|
);
|
|
}
|