// 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}}
)
}