hermes-agent/ui-tui/src/components/thinking.tsx
Brooklyn Nicholson 5b386ced71 fix(tui): approval flow + input ergonomics + selection perf
- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/
  HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of
  silently falling through the CLI input() path and auto-denying
- approval UX: dedicated PromptZone between transcript and composer, safer
  defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line,
  outcome footer under the cost row
- text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed
  (fixes Ctrl+Backspace at cursor 0 inserting literal w)
- hermes-ink selection: swap synchronous onRender() for throttled
  scheduleRender() on drag, and only notify React subscribers on presence
  change — no more per-cell paint/subscribe spam
- useConfigSync: silence config.get polling failures instead of surfacing
  'error: timeout: config.get' in the transcript
2026-04-17 10:37:48 -05:00

984 lines
25 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, NoSelect, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { THINKING_COT_MAX } from '../config/limits.js'
import {
compactPreview,
estimateTokensRough,
fmtK,
formatToolCall,
parseToolTrailResultLine,
pick,
thinkingPreview,
toolTrailLabel
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, 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`
}
type TreeBranch = 'mid' | 'last'
type TreeRails = readonly boolean[]
const nextTreeRails = (rails: TreeRails, branch: TreeBranch) => [...rails, branch === 'mid']
const treeLead = (rails: TreeRails, branch: TreeBranch) =>
`${rails.map(on => (on ? '│ ' : ' ')).join('')}${branch === 'mid' ? '├─ ' : '└─ '}`
// ── Primitives ───────────────────────────────────────────────────────
function TreeRow({
branch,
children,
rails = [],
stemColor,
stemDim = true,
t
}: {
branch: TreeBranch
children: ReactNode
rails?: TreeRails
stemColor?: string
stemDim?: boolean
t: Theme
}) {
const lead = treeLead(rails, branch)
return (
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
{lead}
</Text>
</NoSelect>
<Box flexDirection="column" flexGrow={1}>
{children}
</Box>
</Box>
)
}
function TreeTextRow({
branch,
color,
content,
dimColor,
rails = [],
t,
wrap = 'wrap-trim'
}: {
branch: TreeBranch
color: string
content: ReactNode
dimColor?: boolean
rails?: TreeRails
t: Theme
wrap?: 'truncate-end' | 'wrap' | 'wrap-trim'
}) {
const text = dimColor ? (
<Text color={color} dim wrap={wrap}>
{content}
</Text>
) : (
<Text color={color} wrap={wrap}>
{content}
</Text>
)
return (
<TreeRow branch={branch} rails={rails} t={t}>
{text}
</TreeRow>
)
}
function TreeNode({
branch,
children,
header,
open,
rails = [],
t
}: {
branch: TreeBranch
children?: (rails: boolean[]) => ReactNode
header: ReactNode
open: boolean
rails?: TreeRails
t: Theme
}) {
return (
<Box flexDirection="column">
<TreeRow branch={branch} rails={rails} t={t}>
{header}
</TreeRow>
{open ? children?.(nextTreeRails(rails, branch)) : null}
</Box>
)
}
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
const spin = useMemo(() => {
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '') }
}, [variant])
const [frame, setFrame] = useState(0)
useEffect(() => {
setFrame(0)
}, [spin])
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({
branch = 'last',
color,
content,
dimColor,
rails = [],
t
}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) {
return <TreeTextRow branch={branch} color={color} content={content} dimColor={dimColor} rails={rails} t={t} />
}
function StreamCursor({
color,
dimColor,
streaming = false,
visible = false
}: {
color: string
dimColor?: boolean
streaming?: boolean
visible?: boolean
}) {
const [on, setOn] = useState(true)
useEffect(() => {
if (!visible || !streaming) {
setOn(true)
return
}
const id = setInterval(() => setOn(v => !v), 420)
return () => clearInterval(id)
}, [streaming, visible])
if (!visible) {
return null
}
return dimColor ? (
<Text color={color} dim>
{streaming && on ? '▍' : ' '}
</Text>
) : (
<Text color={color}>{streaming && on ? '▍' : ' '}</Text>
)
}
function Chevron({
count,
onClick,
open,
suffix,
t,
title,
tone = 'dim'
}: {
count?: number
onClick: (deep?: boolean) => 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={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
<Text color={color} dim={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
{suffix ? (
<Text color={t.color.statusFg} dim>
{' '}
{suffix}
</Text>
) : null}
</Text>
</Box>
)
}
function SubagentAccordion({
branch,
expanded,
item,
rails = [],
t
}: {
branch: TreeBranch
expanded: boolean
item: SubagentProgress
rails?: TreeRails
t: Theme
}) {
const [open, setOpen] = useState(expanded)
const [deep, setDeep] = useState(expanded)
const [openThinking, setOpenThinking] = useState(expanded)
const [openTools, setOpenTools] = useState(expanded)
const [openNotes, setOpenNotes] = useState(expanded)
useEffect(() => {
if (!expanded) {
return
}
setOpen(true)
setDeep(true)
setOpenThinking(true)
setOpenTools(true)
setOpenNotes(true)
}, [expanded])
const expandAll = () => {
setOpen(true)
setDeep(true)
setOpenThinking(true)
setOpenTools(true)
setOpenNotes(true)
}
const statusTone: 'dim' | 'error' | 'warn' =
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
const goalLabel = item.goal || `Subagent ${item.index + 1}`
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
const suffix =
item.status === 'running'
? 'running'
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
const thinkingText = item.thinking.join('\n')
const hasThinking = Boolean(thinkingText)
const hasTools = item.tools.length > 0
const noteRows = [...(summary ? [summary] : []), ...item.notes]
const hasNotes = noteRows.length > 0
const showChildren = expanded || deep
const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim
const sections: {
header: ReactNode
key: string
open: boolean
render: (rails: boolean[]) => ReactNode
}[] = []
if (hasThinking) {
sections.push({
header: (
<Chevron
count={item.thinking.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenThinking(v => !v)
}
}}
open={showChildren || openThinking}
t={t}
title="Thinking"
/>
),
key: 'thinking',
open: showChildren || openThinking,
render: childRails => (
<Thinking
active={item.status === 'running'}
branch="last"
mode="full"
rails={childRails}
reasoning={thinkingText}
streaming={item.status === 'running'}
t={t}
/>
)
})
}
if (hasTools) {
sections.push({
header: (
<Chevron
count={item.tools.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenTools(v => !v)
}
}}
open={showChildren || openTools}
t={t}
title="Tool calls"
/>
),
key: 'tools',
open: showChildren || openTools,
render: childRails => (
<Box flexDirection="column">
{item.tools.map((line, index) => (
<TreeTextRow
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
color={t.color.cornsilk}
content={
<>
<Text color={t.color.amber}> </Text>
{line}
</>
}
key={`${item.id}-tool-${index}`}
rails={childRails}
t={t}
/>
))}
</Box>
)
})
}
if (hasNotes) {
sections.push({
header: (
<Chevron
count={noteRows.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenNotes(v => !v)
}
}}
open={showChildren || openNotes}
t={t}
title="Progress"
tone={statusTone}
/>
),
key: 'notes',
open: showChildren || openNotes,
render: childRails => (
<Box flexDirection="column">
{noteRows.map((line, index) => (
<TreeTextRow
branch={index === noteRows.length - 1 ? 'last' : 'mid'}
color={noteColor}
content={line}
dimColor={statusTone === 'dim'}
key={`${item.id}-note-${index}`}
rails={childRails}
t={t}
/>
))}
</Box>
)
})
}
return (
<TreeNode
branch={branch}
header={
<Chevron
onClick={shift => {
if (shift) {
expandAll()
return
}
setOpen(v => {
if (!v) {
setDeep(false)
}
return !v
})
}}
open={open}
suffix={suffix}
t={t}
title={title}
tone={statusTone}
/>
}
open={open}
rails={rails}
t={t}
>
{childRails => (
<Box flexDirection="column">
{sections.map((section, index) => (
<TreeNode
branch={index === sections.length - 1 ? 'last' : 'mid'}
header={section.header}
key={`${item.id}-${section.key}`}
open={section.open}
rails={childRails}
t={t}
>
{section.render}
</TreeNode>
))}
</Box>
)}
</TreeNode>
)
}
// ── Thinking ─────────────────────────────────────────────────────────
export const Thinking = memo(function Thinking({
active = false,
branch = 'last',
mode = 'truncated',
rails = [],
reasoning,
streaming = false,
t
}: {
active?: boolean
branch?: TreeBranch
mode?: ThinkingMode
rails?: TreeRails
reasoning: string
streaming?: boolean
t: Theme
}) {
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
if (!preview && !active) {
return null
}
return (
<TreeRow branch={branch} rails={rails} t={t}>
<Box flexDirection="column" flexGrow={1}>
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.dim} dim key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
) : null}
</Text>
))
) : (
<Text color={t.color.dim} dim wrap="truncate-end">
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)
) : (
<Text color={t.color.dim} dim>
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)}
</Box>
</TreeRow>
)
})
// ── ToolTrail ────────────────────────────────────────────────────────
interface Group {
color: string
content: ReactNode
details: DetailRow[]
key: string
label: string
}
export const ToolTrail = memo(function ToolTrail({
busy = false,
detailsMode = 'collapsed',
outcome = '',
reasoningActive = false,
reasoning = '',
reasoningTokens,
reasoningStreaming = false,
subagents = [],
t,
tools = [],
toolTokens,
trail = [],
activity = []
}: {
busy?: boolean
detailsMode?: DetailsMode
outcome?: string
reasoningActive?: boolean
reasoning?: string
reasoningTokens?: number
reasoningStreaming?: boolean
subagents?: SubagentProgress[]
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 [openSubagents, setOpenSubagents] = useState(false)
const [deepSubagents, setDeepSubagents] = 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)
setOpenSubagents(true)
setOpenMeta(true)
}
if (detailsMode === 'hidden') {
setOpenThinking(false)
setOpenTools(false)
setOpenSubagents(false)
setOpenMeta(false)
}
}, [detailsMode])
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
if (
!busy &&
!trail.length &&
!tools.length &&
!subagents.length &&
!activity.length &&
!cot &&
!reasoningActive &&
!outcome
) {
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}`,
label: parsed.call
})
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 ')) {
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
groups.push({
color: t.color.cornsilk,
content: label,
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
key: `tr-${i}`,
label
})
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) {
const label = formatToolCall(tool.name, tool.context || '')
groups.push({
color: t.color.cornsilk,
key: tool.id,
label,
details: [],
content: (
<>
<Spinner color={t.color.amber} variant="tool" /> {label}
{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 hasSubagents = subagents.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || busy
const thinkingLive = reasoningActive || reasoningStreaming
const tokenCount =
reasoningTokens && reasoningTokens > 0 ? 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
const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task'))
const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : 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
}
// ── Tree render fragments ──────────────────────────────────────
const expandAll = () => {
setOpenThinking(true)
setOpenTools(true)
setOpenSubagents(true)
setDeepSubagents(true)
setOpenMeta(true)
}
const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error')
? 'error'
: activity.some(i => i.tone === 'warn')
? 'warn'
: 'dim'
const renderSubagentList = (rails: boolean[]) => (
<Box flexDirection="column">
{subagents.map((item, index) => (
<SubagentAccordion
branch={index === subagents.length - 1 ? 'last' : 'mid'}
expanded={detailsMode === 'expanded' || deepSubagents}
item={item}
key={item.id}
rails={rails}
t={t}
/>
))}
</Box>
)
const sections: {
header: ReactNode
key: string
open: boolean
render: (rails: boolean[]) => ReactNode
}[] = []
if (hasThinking) {
sections.push({
header: (
<Box
onClick={(e: any) => {
if (e?.shiftKey || e?.ctrlKey) {
expandAll()
} else {
setOpenThinking(v => !v)
}
}}
>
<Text color={t.color.dim} dim={!thinkingLive}>
<Text color={t.color.amber}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
{thinkingLive ? (
<Text bold color={t.color.cornsilk}>
Thinking
</Text>
) : (
<Text color={t.color.dim} dim>
Thinking
</Text>
)}
{thinkingTokensLabel ? (
<Text color={t.color.statusFg} dim>
{' '}
{thinkingTokensLabel}
</Text>
) : null}
</Text>
</Box>
),
key: 'thinking',
open: detailsMode === 'expanded' || openThinking,
render: rails => (
<Thinking
active={reasoningActive}
branch="last"
mode="full"
rails={rails}
reasoning={busy ? reasoning : cot}
streaming={busy && reasoningStreaming}
t={t}
/>
)
})
}
if (hasTools) {
sections.push({
header: (
<Chevron
count={groups.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenTools(v => !v)
}
}}
open={detailsMode === 'expanded' || openTools}
suffix={toolTokensLabel}
t={t}
title="Tool calls"
/>
),
key: 'tools',
open: detailsMode === 'expanded' || openTools,
render: rails => (
<Box flexDirection="column">
{groups.map((group, index) => {
const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid'
const childRails = nextTreeRails(rails, branch)
const hasInlineSubagents = inlineDelegateKey === group.key
return (
<Box flexDirection="column" key={group.key}>
<TreeTextRow
branch={branch}
color={group.color}
content={
<>
<Text color={t.color.amber}> </Text>
{group.content}
</>
}
rails={rails}
t={t}
/>
{group.details.map((detail, detailIndex) => (
<Detail
{...detail}
branch={detailIndex === group.details.length - 1 && !hasInlineSubagents ? 'last' : 'mid'}
key={detail.key}
rails={childRails}
t={t}
/>
))}
{hasInlineSubagents ? renderSubagentList(childRails) : null}
</Box>
)
})}
</Box>
)
})
}
if (hasSubagents && !inlineDelegateKey) {
sections.push({
header: (
<Chevron
count={subagents.length}
onClick={shift => {
if (shift) {
expandAll()
setDeepSubagents(true)
} else {
setOpenSubagents(v => !v)
setDeepSubagents(false)
}
}}
open={detailsMode === 'expanded' || openSubagents}
t={t}
title="Subagents"
/>
),
key: 'subagents',
open: detailsMode === 'expanded' || openSubagents,
render: renderSubagentList
})
}
if (hasMeta) {
sections.push({
header: (
<Chevron
count={meta.length}
onClick={shift => {
if (shift) {
expandAll()
} else {
setOpenMeta(v => !v)
}
}}
open={detailsMode === 'expanded' || openMeta}
t={t}
title="Activity"
tone={metaTone}
/>
),
key: 'meta',
open: detailsMode === 'expanded' || openMeta,
render: rails => (
<Box flexDirection="column">
{meta.map((row, index) => (
<TreeTextRow
branch={index === meta.length - 1 ? 'last' : 'mid'}
color={row.color}
content={row.content}
dimColor={row.dimColor}
key={row.key}
rails={rails}
t={t}
/>
))}
</Box>
)
})
}
const topCount = sections.length + (totalTokensLabel ? 1 : 0)
return (
<Box flexDirection="column">
{sections.map((section, index) => (
<TreeNode
branch={index === topCount - 1 ? 'last' : 'mid'}
header={section.header}
key={section.key}
open={section.open}
t={t}
>
{section.render}
</TreeNode>
))}
{totalTokensLabel ? (
<TreeTextRow
branch="last"
color={t.color.statusFg}
content={
<>
<Text color={t.color.amber}>Σ </Text>
{totalTokensLabel}
</>
}
dimColor
t={t}
/>
) : null}
{outcome ? (
<Box marginTop={1}>
<Text color={t.color.dim} dim>
· {outcome}
</Text>
</Box>
) : null}
</Box>
)
})