import { Text } from '@hermes/ink'
import { memo, useEffect, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { FACES, TOOL_VERBS, VERBS } from '../constants.js'
import {
isToolTrailResultLine,
lastCotTrailIndex,
pick,
scaleHex,
THINKING_COT_FADE,
THINKING_COT_MAX,
thinkingCotTail
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem } from '../types.js'
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
const tone = (item: ActivityItem, t: Theme) =>
item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·')
const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '}
const fmtElapsed = (ms: number) => {
const sec = Math.max(0, ms) / 1000
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
}
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 {spin.frames[frame]}
}
export const ToolTrail = memo(function ToolTrail({
t,
tools = [],
trail = [],
activity = [],
animateCot = false
}: {
t: Theme
tools?: ActiveTool[]
trail?: string[]
activity?: ActivityItem[]
animateCot?: boolean
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!tools.length) {
return
}
const id = setInterval(() => setNow(Date.now()), 200)
return () => clearInterval(id)
}, [tools.length])
if (!trail.length && !tools.length && !activity.length) {
return null
}
const act = activity.slice(-4)
const rowCount = trail.length + tools.length + act.length
const activeCotIdx = animateCot && !tools.length ? lastCotTrailIndex(trail) : -1
return (
<>
{trail.map((line, i) => {
const lastInBlock = i === rowCount - 1
if (isToolTrailResultLine(line)) {
return (
{line}
)
}
if (i === activeCotIdx) {
return (
{line}
)
}
return (
{line}
)
})}
{tools.map((tool, j) => {
const lastInBlock = trail.length + j === rowCount - 1
return (
{TOOL_VERBS[tool.name] ?? tool.name}
{tool.context ? `: ${tool.context}` : ''}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
)
})}
{act.map((item, k) => {
const lastInBlock = trail.length + tools.length + k === rowCount - 1
return (
{activityGlyph(item)} {item.text}
)
})}
>
)
})
export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) {
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(v => v + 1), 1100)
return () => clearInterval(id)
}, [])
const tail = thinkingCotTail(reasoning)
const clipped = reasoning.length > THINKING_COT_MAX
return (
<>
{FACES[tick % FACES.length] ?? '(•_•)'}{' '}
{VERBS[tick % VERBS.length] ?? 'thinking'}…
{tail ? (
{clipped &&
Array.from({ length: Math.min(THINKING_COT_FADE, tail.length) }, (_, i) => (
{tail[i]}
))}
{clipped ? tail.slice(THINKING_COT_FADE) : tail}
) : null}
>
)
})