2026-04-13 10:11:18 -05:00

301 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { FACES, VERBS } from '../constants.js'
import {
formatToolCall,
parseToolTrailResultLine,
pick,
THINKING_COT_MAX,
thinkingPreview,
toolTrailLabel
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem, ThinkingMode } from '../types.js'
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
const fmtElapsed = (ms: number) => {
const sec = Math.max(0, ms) / 1000
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
}
// ── Spinner ──────────────────────────────────────────────────────────
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
const [spin] = useState(() => {
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '') }
})
const [frame, setFrame] = useState(0)
useEffect(() => {
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
return () => clearInterval(id)
}, [spin])
return <Text color={color}>{spin.frames[frame]}</Text>
}
// ── Detail row ───────────────────────────────────────────────────────
type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string }
function Detail({ color, content, dimColor }: DetailRow) {
return (
<Text color={color} dimColor={dimColor}>
<Text dimColor> </Text>
{content}
</Text>
)
}
// ── Streaming cursor ─────────────────────────────────────────────────
function StreamCursor({ active = false, color, dimColor }: { active?: boolean; color: string; dimColor?: boolean }) {
const [on, setOn] = useState(true)
useEffect(() => {
const id = setInterval(() => setOn(v => !v), 420)
return () => clearInterval(id)
}, [])
return <Text color={color} dimColor={dimColor}>{active && on ? '▍' : ' '}</Text>
}
// ── Thinking (pre-tool fallback) ─────────────────────────────────────
export const Thinking = memo(function Thinking({
mode = 'truncated',
reasoning,
streaming = false,
t
}: {
mode?: ThinkingMode
reasoning: string
streaming?: boolean
t: Theme
}) {
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(v => v + 1), 1100)
return () => clearInterval(id)
}, [])
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
return (
<Box flexDirection="column">
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'}{' '}
{VERBS[tick % VERBS.length] ?? 'thinking'}
</Text>
{preview ? (
<Text color={t.color.dim} dimColor {...(mode !== 'full' ? { wrap: 'truncate-end' as const } : {})}>
<Text dimColor> </Text>
{preview}
<StreamCursor active={streaming} color={t.color.dim} dimColor />
</Text>
) : streaming ? (
<Text color={t.color.dim} dimColor>
<Text dimColor> </Text>
<StreamCursor active={streaming} color={t.color.dim} dimColor />
</Text>
) : null}
</Box>
)
})
// ── ToolTrail (canonical progress block) ─────────────────────────────
type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string }
export const ToolTrail = memo(function ToolTrail({
busy = false,
thinkingMode = 'truncated',
reasoning = '',
reasoningStreaming = false,
t,
tools = [],
trail = [],
activity = []
}: {
busy?: boolean
thinkingMode?: ThinkingMode
reasoning?: string
reasoningStreaming?: boolean
t: Theme
tools?: ActiveTool[]
trail?: string[]
activity?: ActivityItem[]
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!tools.length) {
return
}
const id = setInterval(() => setNow(Date.now()), 200)
return () => clearInterval(id)
}, [tools.length])
const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX)
if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail) {
return null
}
const groups: Group[] = []
const meta: DetailRow[] = []
const detail = (row: DetailRow) => {
const g = groups.at(-1)
g ? g.details.push(row) : meta.push(row)
}
// ── trail → groups + details ────────────────────────────────────
for (const [i, line] of trail.entries()) {
const parsed = parseToolTrailResultLine(line)
if (parsed) {
groups.push({
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
details: [],
key: `tr-${i}`
})
if (parsed.detail) {
detail({
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
content: parsed.detail,
dimColor: parsed.mark !== '✗',
key: `tr-${i}-d`
})
}
continue
}
if (line.startsWith('drafting ')) {
groups.push({
color: t.color.cornsilk,
content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()),
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
key: `tr-${i}`
})
continue
}
if (line === 'analyzing tool output…') {
detail({
color: t.color.dim,
content: groups.length ? (
<>
<Spinner color={t.color.amber} variant="think" /> {line}
</>
) : (
line
),
dimColor: true,
key: `tr-${i}`
})
continue
}
meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` })
}
// ── live tools → groups ─────────────────────────────────────────
for (const tool of tools) {
groups.push({
color: t.color.cornsilk,
content: (
<>
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
</>
),
details: [],
key: tool.id
})
}
if (reasoningTail && groups.length) {
detail({
color: t.color.dim,
content: (
<>
{reasoningTail}
<StreamCursor active={reasoningStreaming} color={t.color.dim} dimColor />
</>
),
dimColor: true,
key: 'cot'
})
} else if (reasoningStreaming && groups.length && thinkingMode === 'collapsed') {
detail({
color: t.color.dim,
content: <StreamCursor active={reasoningStreaming} color={t.color.dim} dimColor />,
dimColor: true,
key: 'cot'
})
}
// ── activity → meta ─────────────────────────────────────────────
for (const item of activity.slice(-4)) {
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·'
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` })
}
// ── render ──────────────────────────────────────────────────────
return (
<Box flexDirection="column">
{busy && !groups.length && <Thinking mode={thinkingMode} reasoning={reasoning} streaming={reasoningStreaming} t={t} />}
{!busy && !groups.length && reasoningTail && (
<Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" />
)}
{groups.map(g => (
<Box flexDirection="column" key={g.key}>
<Text color={g.color}>
<Text color={t.color.amber}> </Text>
{g.content}
</Text>
{g.details.map(d => (
<Detail {...d} key={d.key} />
))}
</Box>
))}
{meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
{row.content}
</Text>
))}
</Box>
)
})