// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { Box, Text, type TextProps } from '../../ink.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 { FilePathLink } from '../FilePathLink.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 Link from '../../ink/components/Link.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 } if (message.subtype === 'memory_saved') { return } if (message.subtype === 'away_summary') { return ( {REFERENCE_MARK} {message.content} ) } // Agents killed confirmation if (message.subtype === 'agents_killed') { return ( {BLACK_CIRCLE} All background agents stopped ) } // Thinking messages are subtle, like turn duration (ant-only) if (message.subtype === 'thinking') { if (process.env.USER_TYPE === 'ant') { return } return null } if (message.subtype === 'bridge_status') { return } if (message.subtype === 'scheduled_task_fire') { return ( {TEARDROP_ASTERISK} {message.content} ) } if (message.subtype === 'permission_retry') { return ( {TEARDROP_ASTERISK} Allowed {message.commands.join(', ')} ) } // 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 } if (message.subtype === 'stop_hook_summary') { return ( ) } 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 ( ) } function StopHookSummaryMessage({ message, addMargin, verbose, isTranscriptMode, }: { message: SystemStopHookSummaryMessage addMargin: boolean verbose: boolean isTranscriptMode?: boolean }): React.ReactNode { const bg = useSelectedMessageBg() const { hookCount, hookInfos, hookErrors, preventedContinuation, stopReason, } = message 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 ( {' ⎿ '}Ran {hookCount} {message.hookLabel}{' '} {hookCount === 1 ? 'hook' : 'hooks'} {totalStr} {isTranscriptMode && hookInfos.map((info, idx) => { const durationStr = isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : '' return ( {' ⎿ '} {info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command} {durationStr} ) })} ) } return ( {BLACK_CIRCLE} Ran {hookCount} {message.hookLabel ?? 'stop'}{' '} {hookCount === 1 ? 'hook' : 'hooks'} {totalStr} {!verbose && hookInfos.length > 0 && ( <> {' '} )} {verbose && hookInfos.length > 0 && hookInfos.map((info, idx) => { const durationStr = isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : '' return ( ⎿   {info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command} {durationStr} ) })} {preventedContinuation && stopReason && ( ⎿   {stopReason} )} {hookErrors.length > 0 && hookErrors.map((err, idx) => ( ⎿   {message.hookLabel ?? 'Stop'} hook error: {err} ))} ) } 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 ( {dot && ( {BLACK_CIRCLE} )} {content.trim()} ) } 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) const hasBudget = message.budgetLimit !== undefined const budgetSuffix = (() => { if (!hasBudget) return '' const tokens = message.budgetTokens! const limit = message.budgetLimit! 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! > 0 ? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? 'nudge' : 'nudges'}` : '' return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}` })() if (!showTurnDuration && !hasBudget) { return null } return ( {TEARDROP_ASTERISK} {showTurnDuration && `${verb} for ${duration}`} {budgetSuffix} {backgroundTaskSummary && ` \u00B7 ${backgroundTaskSummary} still running`} ) } function MemorySavedMessage({ message, addMargin, }: { message: SystemMemorySavedMessage addMargin: boolean }): React.ReactNode { const bg = useSelectedMessageBg() const { writtenPaths } = message 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, ].filter(Boolean) return ( {BLACK_CIRCLE} {message.verb ?? 'Saved'} {parts.join(' \u00B7 ')} {writtenPaths.map(p => ( ))} ) } function MemoryFileRow({ path }: { path: string }): React.ReactNode { const [hover, setHover] = useState(false) return ( void openPath(path)} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} > {basename(path)} ) } function ThinkingMessage({ message, addMargin, }: { message: SystemThinkingMessage addMargin: boolean }): React.ReactNode { const bg = useSelectedMessageBg() return ( {TEARDROP_ASTERISK} {message.content} ) } function BridgeStatusMessage({ message, addMargin, }: { message: SystemBridgeStatusMessage addMargin: boolean }): React.ReactNode { const bg = useSelectedMessageBg() return ( /remote-control is active. Code in CLI or at {message.url} {message.upgradeNudge && ⎿ {message.upgradeNudge}} ) }