Files
hermes/ui-tui/src/components/thinking.tsx
2026-04-15 10:20:56 -05:00

483 lines
14 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, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import {
estimateTokensRough,
fmtK,
formatToolCall,
parseToolTrailResultLine,
pick,
THINKING_COT_MAX,
thinkingPreview,
toolTrailLabel
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem, DetailsMode, 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`
}
// ── Primitives ───────────────────────────────────────────────────────
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>
}
interface DetailRow {
color: string
content: ReactNode
dimColor?: boolean
key: string
}
function Detail({ color, content, dimColor }: DetailRow) {
return (
<Text color={color} dimColor={dimColor} wrap="wrap-trim">
<Text dimColor> </Text>
{content}
</Text>
)
}
function StreamCursor({
color,
dimColor,
streaming = false,
visible = false
}: {
color: string
dimColor?: boolean
streaming?: boolean
visible?: boolean
}) {
const [on, setOn] = useState(true)
useEffect(() => {
const id = setInterval(() => setOn(v => !v), 420)
return () => clearInterval(id)
}, [])
return visible ? (
<Text color={color} dimColor={dimColor}>
{streaming && on ? '▍' : ' '}
</Text>
) : null
}
function Chevron({
count,
onClick,
open,
suffix,
t,
title,
tone = 'dim'
}: {
count?: number
onClick: () => void
open: boolean
suffix?: string
t: Theme
title: string
tone?: 'dim' | 'error' | 'warn'
}) {
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
return (
<Box onClick={onClick}>
<Text color={color} dimColor={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
{suffix ? (
<Text color={t.color.statusFg} dimColor>
{' '}
{suffix}
</Text>
) : null}
</Text>
</Box>
)
}
// ── Thinking ─────────────────────────────────────────────────────────
export const Thinking = memo(function Thinking({
active = false,
mode = 'truncated',
reasoning,
streaming = false,
t
}: {
active?: boolean
mode?: ThinkingMode
reasoning: string
streaming?: boolean
t: Theme
}) {
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
return (
<Box flexDirection="column">
{preview ? (
mode === 'full' ? (
<Box flexDirection="row">
<Text color={t.color.dim} dimColor>
{' '}
</Text>
<Box flexDirection="column" flexGrow={1}>
{lines.map((line, index) => (
<Text color={t.color.dim} dimColor key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
) : null}
</Text>
))}
</Box>
</Box>
) : (
<Text color={t.color.dim} dimColor wrap="truncate-end">
<Text dimColor> </Text>
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)
) : active ? (
<Text color={t.color.dim} dimColor>
<Text dimColor> </Text>
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
) : null}
</Box>
)
})
// ── ToolTrail ────────────────────────────────────────────────────────
interface Group {
color: string
content: ReactNode
details: DetailRow[]
key: string
}
export const ToolTrail = memo(function ToolTrail({
busy = false,
detailsMode = 'collapsed',
reasoningActive = false,
reasoning = '',
reasoningTokens,
reasoningStreaming = false,
t,
tools = [],
toolTokens,
trail = [],
activity = []
}: {
busy?: boolean
detailsMode?: DetailsMode
reasoningActive?: boolean
reasoning?: string
reasoningTokens?: number
reasoningStreaming?: boolean
t: Theme
tools?: ActiveTool[]
toolTokens?: number
trail?: string[]
activity?: ActivityItem[]
}) {
const [now, setNow] = useState(() => Date.now())
const [openThinking, setOpenThinking] = useState(false)
const [openTools, setOpenTools] = useState(false)
const [openMeta, setOpenMeta] = useState(false)
useEffect(() => {
if (!tools.length || (detailsMode === 'collapsed' && !openTools)) {
return
}
const id = setInterval(() => setNow(Date.now()), 500)
return () => clearInterval(id)
}, [detailsMode, openTools, tools.length])
useEffect(() => {
if (detailsMode === 'expanded') {
setOpenThinking(true)
setOpenTools(true)
setOpenMeta(true)
}
if (detailsMode === 'hidden') {
setOpenThinking(false)
setOpenTools(false)
setOpenMeta(false)
}
}, [detailsMode])
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
return null
}
// ── Build groups + meta ────────────────────────────────────────
const groups: Group[] = []
const meta: DetailRow[] = []
const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row)
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) {
pushDetail({
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…') {
pushDetail({
color: t.color.dim,
dimColor: true,
key: `tr-${i}`,
content: groups.length ? (
<>
<Spinner color={t.color.amber} variant="think" /> {line}
</>
) : (
line
)
})
continue
}
meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` })
}
for (const tool of tools) {
groups.push({
color: t.color.cornsilk,
key: tool.id,
details: [],
content: (
<>
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
</>
)
})
}
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}` })
}
// ── Derived ────────────────────────────────────────────────────
const hasTools = groups.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
const thinkingLive = reasoningActive || reasoningStreaming
const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0
const toolTokenCount = toolTokens ?? 0
const totalTokenCount = tokenCount + toolTokenCount
const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null
const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined
const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null
// ── Hidden: errors/warnings only ──────────────────────────────
if (detailsMode === 'hidden') {
const alerts = activity.filter(i => i.tone !== 'info').slice(-2)
return alerts.length ? (
<Box flexDirection="column">
{alerts.map(i => (
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
{i.tone === 'error' ? '✗' : '!'} {i.text}
</Text>
))}
</Box>
) : null
}
// ── Shared render fragments ────────────────────────────────────
const thinkingBlock = hasThinking ? (
busy ? (
<Thinking active={reasoningActive} mode="full" reasoning={reasoning} streaming={reasoningStreaming} t={t} />
) : cot ? (
<Detail color={t.color.dim} content={cot} dimColor key="cot" />
) : (
<Detail
color={t.color.dim}
content={<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />}
dimColor
key="cot"
/>
)
) : null
const toolBlock = hasTools
? 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>
))
: null
const metaBlock = hasMeta
? meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
{row.content}
</Text>
))
: null
const totalBlock = totalTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
<Text color={t.color.amber}>Σ </Text>
{totalTokensLabel}
</Text>
) : null
// ── Expanded: flat, no accordions ──────────────────────────────
if (detailsMode === 'expanded') {
return (
<Box flexDirection="column">
{thinkingBlock}
{toolBlock}
{metaBlock}
{totalBlock}
</Box>
)
}
// ── Collapsed: clickable accordions ────────────────────────────
const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error')
? 'error'
: activity.some(i => i.tone === 'warn')
? 'warn'
: 'dim'
return (
<Box flexDirection="column">
{hasThinking && (
<>
<Box onClick={() => setOpenThinking(v => !v)}>
<Text color={t.color.dim} dimColor={!thinkingLive}>
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
<Text bold={thinkingLive} color={thinkingLive ? t.color.cornsilk : t.color.dim} dimColor={!thinkingLive}>
Thinking
</Text>
{thinkingTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
{' '}
{thinkingTokensLabel}
</Text>
) : null}
</Text>
</Box>
{openThinking && thinkingBlock}
</>
)}
{hasTools && (
<>
<Chevron
count={groups.length}
onClick={() => setOpenTools(v => !v)}
open={openTools}
suffix={toolTokensLabel}
t={t}
title="Tool calls"
/>
{openTools && toolBlock}
</>
)}
{hasMeta && (
<>
<Chevron
count={meta.length}
onClick={() => setOpenMeta(v => !v)}
open={openMeta}
t={t}
title="Activity"
tone={metaTone}
/>
{openMeta && metaBlock}
</>
)}
{totalBlock}
</Box>
)
})