feat: scroll aware sticky prompt

This commit is contained in:
Brooklyn Nicholson
2026-04-14 11:49:32 -05:00
141 changed files with 8867 additions and 829 deletions

View File

@@ -8,6 +8,7 @@ import {
Box,
NoSelect,
ScrollBox,
type ScrollBoxHandle,
Text,
useApp,
useHasSelection,
@@ -15,7 +16,7 @@ import {
useSelection,
useStdout
} from '@hermes/ink'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { Banner, Panel, SessionPanel } from './components/branding.js'
import { MaskedPrompt } from './components/maskedPrompt.js'
@@ -44,7 +45,8 @@ import {
pick,
sameToolTrailGroup,
stripTrailingPasteNewlines,
toolTrailLabel
toolTrailLabel,
userDisplay
} from './lib/text.js'
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
import type {
@@ -70,6 +72,7 @@ const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
const LARGE_PASTE = { chars: 8000, lines: 80 }
const MAX_HISTORY = 800
const REASONING_PULSE_MS = 700
const STREAM_BATCH_MS = 16
const WHEEL_SCROLL_STEP = 3
const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase())
const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g
@@ -78,14 +81,18 @@ const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
const parseDetailsMode = (v: unknown): DetailsMode | null => {
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
}
const resolveDetailsMode = (d: any): DetailsMode =>
parseDetailsMode(d?.details_mode)
?? ({ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
String(d?.thinking_mode ?? '').trim().toLowerCase()
] ?? 'collapsed')
parseDetailsMode(d?.details_mode) ??
{ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
String(d?.thinking_mode ?? '')
.trim()
.toLowerCase()
] ??
'collapsed'
const nextDetailsMode = (m: DetailsMode): DetailsMode =>
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
@@ -98,6 +105,7 @@ type PasteSnippet = { label: string; text: string }
const shortCwd = (cwd: string, max = 28) => {
const home = process.env.HOME
const path = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd
return path.length <= max ? path : `${path.slice(-(max - 1))}`
}
@@ -288,6 +296,78 @@ function PromptBox({ children, color }: { children: React.ReactNode; color: stri
)
}
const upperBound = (arr: ArrayLike<number>, target: number) => {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (arr[mid]! <= target) {
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
function StickyPromptTracker({
messages,
offsets,
scrollRef,
onChange
}: {
messages: readonly Msg[]
offsets: ArrayLike<number>
scrollRef: RefObject<ScrollBoxHandle | null>
onChange: (text: string) => void
}) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => {
const s = scrollRef.current
if (!s) {
return NaN
}
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
return s.isSticky() ? -1 - top : top
},
() => NaN
)
const s = scrollRef.current
const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
let text = ''
if (!(s?.isSticky() ?? true) && messages.length) {
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
if (!(messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top)) {
for (let i = first - 1; i >= 0; i--) {
if (messages[i]?.role !== 'user') {
continue
}
if ((offsets[i] ?? 0) + 1 >= top) {
continue
}
text = userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
break
}
}
}
useEffect(() => onChange(text), [onChange, text])
return null
}
// ── App ──────────────────────────────────────────────────────────────
export function App({ gw }: { gw: GatewayClient }) {
@@ -343,6 +423,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [statusBar, setStatusBar] = useState(true)
const [lastUserMsg, setLastUserMsg] = useState('')
const [stickyPrompt, setStickyPrompt] = useState('')
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const [streaming, setStreaming] = useState('')
const [turnTrail, setTurnTrail] = useState<string[]>([])
@@ -371,11 +452,13 @@ export function App({ gw }: { gw: GatewayClient }) {
const colsRef = useRef(cols)
const turnToolsRef = useRef<string[]>([])
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reasoningTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const busyRef = useRef(busy)
const sidRef = useRef<string | null>(sid)
const scrollRef = useRef<import('@hermes/ink').ScrollBoxHandle | null>(null)
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
const configMtimeRef = useRef(0)
colsRef.current = cols
@@ -407,6 +490,28 @@ export function App({ gw }: { gw: GatewayClient }) {
}, REASONING_PULSE_MS)
}, [])
const scheduleStreaming = useCallback(() => {
if (streamTimerRef.current) {
return
}
streamTimerRef.current = setTimeout(() => {
streamTimerRef.current = null
setStreaming(buf.current.trimStart())
}, STREAM_BATCH_MS)
}, [])
const scheduleReasoning = useCallback(() => {
if (reasoningTimerRef.current) {
return
}
reasoningTimerRef.current = setTimeout(() => {
reasoningTimerRef.current = null
setReasoning(reasoningRef.current)
}, STREAM_BATCH_MS)
}, [])
const endReasoningPhase = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
@@ -419,6 +524,14 @@ export function App({ gw }: { gw: GatewayClient }) {
useEffect(
() => () => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current)
}
if (reasoningTimerRef.current) {
clearTimeout(reasoningTimerRef.current)
}
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
@@ -432,6 +545,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const empty = !messages.length
const isBlocked = blocked()
const virtualRows = useMemo(
() =>
historyItems.map((msg, index) => ({
@@ -441,6 +555,7 @@ export function App({ gw }: { gw: GatewayClient }) {
})),
[historyItems]
)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows)
// ── Resize RPC ───────────────────────────────────────────────────
@@ -651,10 +766,26 @@ export function App({ gw }: { gw: GatewayClient }) {
setApproval(null)
setSudo(null)
setSecret(null)
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current)
streamTimerRef.current = null
}
setStreaming('')
buf.current = ''
}
const clearReasoning = () => {
if (reasoningTimerRef.current) {
clearTimeout(reasoningTimerRef.current)
reasoningTimerRef.current = null
}
reasoningRef.current = ''
setReasoning('')
}
const die = () => {
gw.kill()
exit()
@@ -670,13 +801,14 @@ export function App({ gw }: { gw: GatewayClient }) {
const resetSession = () => {
idle()
setReasoning('')
clearReasoning()
setVoiceRecording(false)
setVoiceProcessing(false)
setSid(null as any) // will be set by caller
setInfo(null)
setHistoryItems([])
setMessages([])
setStickyPrompt('')
setPasteSnips([])
setActivity([])
setBgTasks(new Set())
@@ -688,11 +820,12 @@ export function App({ gw }: { gw: GatewayClient }) {
const resetVisibleHistory = (info: SessionInfo | null = null) => {
idle()
setReasoning('')
clearReasoning()
setMessages([])
setHistoryItems(info ? [introMsg(info)] : [])
setInfo(info)
setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO)
setStickyPrompt('')
setPasteSnips([])
setActivity([])
setLastUserMsg('')
@@ -888,10 +1021,12 @@ export function App({ gw }: { gw: GatewayClient }) {
const send = (text: string) => {
const expandPasteSnips = (value: string) => {
const byLabel = new Map<string, string[]>()
for (const item of pasteSnips) {
const list = byLabel.get(item.label)
list ? list.push(item.text) : byLabel.set(item.label, [item.text])
}
return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token)
}
@@ -1192,11 +1327,13 @@ export function App({ gw }: { gw: GatewayClient }) {
if (key.wheelUp) {
scrollRef.current?.scrollBy(-WHEEL_SCROLL_STEP)
return
}
if (key.wheelDown) {
scrollRef.current?.scrollBy(WHEEL_SCROLL_STEP)
return
}
@@ -1204,6 +1341,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8)
const step = Math.max(4, viewport - 2)
scrollRef.current?.scrollBy(key.pageUp ? -step : step)
return
}
@@ -1272,7 +1410,7 @@ export function App({ gw }: { gw: GatewayClient }) {
partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted')
idle()
setReasoning('')
clearReasoning()
setActivity([])
turnToolsRef.current = []
setStatus('interrupted')
@@ -1287,6 +1425,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}, 1500)
} else if (hasSelection) {
const copied = selection.copySelection()
if (copied) {
sys('copied selection')
}
@@ -1457,7 +1596,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'message.start':
setBusy(true)
endReasoningPhase()
setReasoning('')
clearReasoning()
setActivity([])
setTurnTrail([])
turnToolsRef.current = []
@@ -1534,7 +1673,8 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'reasoning.delta':
if (p?.text) {
setReasoning(prev => prev + p.text)
reasoningRef.current += p.text
scheduleReasoning()
pulseReasoningStreaming()
}
@@ -1657,7 +1797,7 @@ export function App({ gw }: { gw: GatewayClient }) {
if (p?.text && !interruptedRef.current) {
buf.current = p.rendered ?? buf.current + p.text
setStreaming(buf.current.trimStart())
scheduleStreaming()
}
break
@@ -1673,7 +1813,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart()
idle()
setReasoning('')
clearReasoning()
setStreaming('')
if (!wasInterrupted) {
@@ -1715,7 +1855,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'error':
idle()
setReasoning('')
clearReasoning()
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
@@ -1734,6 +1874,7 @@ export function App({ gw }: { gw: GatewayClient }) {
[
appendMessage,
bellOnComplete,
clearReasoning,
dequeue,
endReasoningPhase,
gw,
@@ -1743,6 +1884,8 @@ export function App({ gw }: { gw: GatewayClient }) {
pushActivity,
pushTrail,
rpc,
scheduleReasoning,
scheduleStreaming,
sendQueued,
sys,
stdout
@@ -1883,8 +2026,10 @@ export function App({ gw }: { gw: GatewayClient }) {
{
const mode = arg.trim().toLowerCase()
if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) {
sys('usage: /details [hidden|collapsed|expanded|cycle]')
return true
}
@@ -1895,12 +2040,13 @@ export function App({ gw }: { gw: GatewayClient }) {
}
return true
case 'copy': {
if (!arg && hasSelection) {
const copied = selection.copySelection()
if (copied) {
sys('copied selection')
return true
}
}
@@ -1949,6 +2095,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'sb':
if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
sys('usage: /statusbar [on|off|toggle]')
return true
}
@@ -2798,7 +2945,7 @@ export function App({ gw }: { gw: GatewayClient }) {
}
idle()
setReasoning('')
clearReasoning()
setActivity([])
turnToolsRef.current = []
setStatus('interrupted')
@@ -2855,16 +3002,17 @@ export function App({ gw }: { gw: GatewayClient }) {
const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : ''
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
const cwdLabel = shortCwd(info?.cwd || process.env.HERMES_CWD || process.cwd())
const showStreamingArea = Boolean(streaming)
const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end)
const showStickyPrompt = !!stickyPrompt
const hasReasoning = Boolean(reasoning.trim())
const showProgressArea =
detailsMode === 'hidden'
? activity.some(i => i.tone !== 'info')
: Boolean(busy || tools.length || turnTrail.length || hasReasoning || activity.length)
const showStreamingArea = Boolean(streaming)
const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end)
// ── Render ───────────────────────────────────────────────────────
return (
@@ -2891,23 +3039,7 @@ export function App({ gw }: { gw: GatewayClient }) {
{virtualHistory.bottomSpacer > 0 ? <Box height={virtualHistory.bottomSpacer} /> : null}
{showStreamingArea && (
<Box marginTop={1}>
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
msg={{ role: 'assistant', text: streaming }}
t={theme}
/>
</Box>
)}
</Box>
</ScrollBox>
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
{showProgressArea && (
<Box>
{showProgressArea && (
<ToolTrail
activity={activity}
busy={busy && !streaming}
@@ -2919,9 +3051,29 @@ export function App({ gw }: { gw: GatewayClient }) {
tools={tools}
trail={turnTrail}
/>
</Box>
)}
)}
{showStreamingArea && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
isStreaming
msg={{ role: 'assistant', text: streaming }}
t={theme}
/>
)}
</Box>
</ScrollBox>
<StickyPromptTracker
messages={historyItems}
offsets={virtualHistory.offsets}
onChange={setStickyPrompt}
scrollRef={scrollRef}
/>
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
{clarify && (
<PromptBox color={theme.color.bronze}>
<ClarifyPrompt
@@ -3026,7 +3178,14 @@ export function App({ gw }: { gw: GatewayClient }) {
</Text>
)}
<Text> </Text>
{showStickyPrompt ? (
<Text color={theme.color.dim} dimColor wrap="truncate-end">
<Text color={theme.color.label}> </Text>
{stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
{statusBar && (
<StatusRule

View File

@@ -5,6 +5,7 @@ import { LONG_MSG, ROLE } from '../constants.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, Msg } from '../types.js'
import { Md } from './markdown.js'
import { ToolTrail } from './thinking.js'
@@ -12,12 +13,14 @@ export const MessageLine = memo(function MessageLine({
cols,
compact,
detailsMode = 'collapsed',
isStreaming = false,
msg,
t
}: {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
t: Theme
}) {
@@ -33,7 +36,8 @@ export const MessageLine = memo(function MessageLine({
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
<Text color={t.color.dim} wrap="truncate-end">
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'}
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
'(empty tool result)'}
</Text>
</Box>
)
@@ -44,16 +48,27 @@ export const MessageLine = memo(function MessageLine({
const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking))
const content = (() => {
if (msg.kind === 'slash') return <Text color={t.color.dim}>{msg.text}</Text>
if (msg.role !== 'user' && hasAnsi(msg.text)) return <Ansi>{msg.text}</Ansi>
if (msg.role === 'assistant') return <Md compact={compact} t={t} text={msg.text} />
if (msg.kind === 'slash') {
return <Text color={t.color.dim}>{msg.text}</Text>
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{msg.text}</Ansi>
}
if (msg.role === 'assistant') {
return isStreaming ? <Text color={body}>{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
return (
<Text color={body}>
{head}
<Text color={t.color.dim} dimColor>[long message]</Text>
<Text color={t.color.dim} dimColor>
[long message]
</Text>
{rest.join('')}
</Text>
)
@@ -76,7 +91,9 @@ export const MessageLine = memo(function MessageLine({
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={3}>
<Text bold={msg.role === 'user'} color={prefix}>{glyph}{' '}</Text>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={Math.max(20, cols - 5)}>{content}</Box>

View File

@@ -176,8 +176,10 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
if (line === targetRow) {
return index
}
line++
column = 0
continue
}
@@ -187,6 +189,7 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
if (line === targetRow) {
return index
}
line++
column = 0
}
@@ -333,7 +336,9 @@ export function TextInput({
}, [cur, display, focus, placeholder])
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
if (!focus) return
if (!focus) {
return
}
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
setCur(next)
curRef.current = next
@@ -442,7 +447,6 @@ export function TextInput({
k.upArrow ||
k.downArrow ||
(k.ctrl && inp === 'c') ||
(k.ctrl && inp === 't') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
@@ -568,7 +572,7 @@ export function TextInput({
// ── Render ───────────────────────────────────────────────────────
return (
<Box ref={boxRef} onClick={clickCursor}>
<Box onClick={clickCursor} ref={boxRef}>
<Text wrap="wrap">{rendered}</Text>
</Box>
)

View File

@@ -4,7 +4,6 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { FACES, VERBS } from '../constants.js'
import {
compactPreview,
formatToolCall,
parseToolTrailResultLine,
pick,
@@ -20,6 +19,7 @@ const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep',
const fmtElapsed = (ms: number) => {
const sec = Math.max(0, ms) / 1000
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
}
@@ -28,6 +28,7 @@ const fmtElapsed = (ms: number) => {
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] ?? '') }
})
@@ -35,6 +36,7 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
useEffect(() => {
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
return () => clearInterval(id)
}, [spin])
@@ -52,22 +54,46 @@ function Detail({ color, content, dimColor }: DetailRow) {
)
}
function StreamCursor({ color, dimColor, streaming = false, visible = false }: {
color: string; dimColor?: boolean; streaming?: boolean; visible?: boolean
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
return visible ? (
<Text color={color} dimColor={dimColor}>
{streaming && on ? '▍' : ' '}
</Text>
) : null
}
function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
count?: number; onClick: () => void; open: boolean; summary?: string
t: Theme; title: string; tone?: 'dim' | 'error' | 'warn'
function Chevron({
count,
onClick,
open,
t,
title,
tone = 'dim'
}: {
count?: number
onClick: () => void
open: boolean
t: Theme
title: string
tone?: 'dim' | 'error' | 'warn'
}) {
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
@@ -75,8 +101,8 @@ function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
<Box onClick={onClick}>
<Text color={color} dimColor={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}{typeof count === 'number' ? ` (${count})` : ''}
{summary ? <Text color={t.color.dim}> · {summary}</Text> : null}
{title}
{typeof count === 'number' ? ` (${count})` : ''}
</Text>
</Box>
)
@@ -85,14 +111,23 @@ function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
// ── Thinking ─────────────────────────────────────────────────────────
export const Thinking = memo(function Thinking({
active = false, mode = 'truncated', reasoning, streaming = false, t
active = false,
mode = 'truncated',
reasoning,
streaming = false,
t
}: {
active?: boolean; mode?: ThinkingMode; reasoning: string; streaming?: boolean; t: Theme
active?: boolean
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)
}, [])
@@ -126,13 +161,25 @@ export const Thinking = memo(function Thinking({
type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string }
export const ToolTrail = memo(function ToolTrail({
busy = false, detailsMode = 'collapsed', reasoningActive = false,
reasoning = '', reasoningStreaming = false, t,
tools = [], trail = [], activity = []
busy = false,
detailsMode = 'collapsed',
reasoningActive = false,
reasoning = '',
reasoningStreaming = false,
t,
tools = [],
trail = [],
activity = []
}: {
busy?: boolean; detailsMode?: DetailsMode; reasoningActive?: boolean
reasoning?: string; reasoningStreaming?: boolean; t: Theme
tools?: ActiveTool[]; trail?: string[]; activity?: ActivityItem[]
busy?: boolean
detailsMode?: DetailsMode
reasoningActive?: boolean
reasoning?: string
reasoningStreaming?: boolean
t: Theme
tools?: ActiveTool[]
trail?: string[]
activity?: ActivityItem[]
}) {
const [now, setNow] = useState(() => Date.now())
const [openThinking, setOpenThinking] = useState(false)
@@ -140,19 +187,33 @@ export const ToolTrail = memo(function ToolTrail({
const [openMeta, setOpenMeta] = useState(false)
useEffect(() => {
if (!tools.length) return
const id = setInterval(() => setNow(Date.now()), 200)
if (!tools.length || (detailsMode === 'collapsed' && !openTools)) {
return
}
const id = setInterval(() => setNow(Date.now()), 500)
return () => clearInterval(id)
}, [tools.length])
}, [detailsMode, openTools, tools.length])
useEffect(() => {
if (detailsMode === 'expanded') { setOpenThinking(true); setOpenTools(true); setOpenMeta(true) }
if (detailsMode === 'hidden') { setOpenThinking(false); setOpenTools(false); setOpenMeta(false) }
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
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
return null
}
// ── Build groups + meta ────────────────────────────────────────
@@ -167,12 +228,19 @@ export const ToolTrail = memo(function ToolTrail({
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`
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
}
@@ -183,16 +251,24 @@ export const ToolTrail = memo(function ToolTrail({
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
color: t.color.dim,
dimColor: true,
key: `tr-${i}`,
content: groups.length ? (
<>
<Spinner color={t.color.amber} variant="think" /> {line}
</>
) : (
line
)
})
continue
}
@@ -201,7 +277,9 @@ export const ToolTrail = memo(function ToolTrail({
for (const tool of tools) {
groups.push({
color: t.color.cornsilk, key: tool.id, details: [],
color: t.color.cornsilk,
key: tool.id,
details: [],
content: (
<>
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
@@ -211,18 +289,6 @@ export const ToolTrail = memo(function ToolTrail({
})
}
if (cot && groups.length) {
pushDetail({
color: t.color.dim, dimColor: true, key: 'cot',
content: <>{cot}<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} /></>
})
} else if (reasoningActive && groups.length) {
pushDetail({
color: t.color.dim, dimColor: true, key: 'cot',
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
})
}
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
@@ -233,12 +299,13 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0
const hasMeta = meta.length > 0
const hasThinking = !hasTools && (busy || !!cot || reasoningActive)
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
// ── 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 => (
@@ -253,61 +320,95 @@ export const ToolTrail = memo(function ToolTrail({
// ── 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" />
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 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 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
// ── Expanded: flat, no accordions ──────────────────────────────
if (detailsMode === 'expanded') {
return <Box flexDirection="column">{thinkingBlock}{toolBlock}{metaBlock}</Box>
return (
<Box flexDirection="column">
{thinkingBlock}
{toolBlock}
{metaBlock}
</Box>
)
}
// ── Collapsed: clickable accordions ────────────────────────────
const metaTone: 'dim' | 'error' | 'warn' =
activity.some(i => i.tone === 'error') ? 'error'
: activity.some(i => i.tone === 'warn') ? 'warn' : 'dim'
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 && (
<>
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" />
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" />
{openThinking && thinkingBlock}
</>
)}
{hasTools && (
<>
<Chevron count={groups.length} onClick={() => setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" />
<Chevron
count={groups.length}
onClick={() => setOpenTools(v => !v)}
open={openTools}
t={t}
title="Tool calls"
/>
{openTools && toolBlock}
</>
)}
{hasMeta && (
<>
<Chevron count={meta.length} onClick={() => setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} />
<Chevron
count={meta.length}
onClick={() => setOpenMeta(v => !v)}
open={openMeta}
t={t}
title="Activity"
tone={metaTone}
/>
{openMeta && metaBlock}
</>
)}

View File

@@ -1,19 +1,30 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from 'react'
import type { ScrollBoxHandle } from '@hermes/ink'
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore
} from 'react'
const ESTIMATE = 4
const OVERSCAN = 40
const MAX_MOUNTED = 260
const COLD_START = 40
const QUANTUM = 8
const QUANTUM = OVERSCAN >> 1
const upperBound = (arr: number[], target: number) => {
let lo = 0, hi = arr.length
let lo = 0,
hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >> 1
arr[mid]! <= target ? lo = mid + 1 : hi = mid
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
}
return lo
}
@@ -28,14 +39,15 @@ export function useVirtualHistory(
const [ver, setVer] = useState(0)
useSyncExternalStore(
useCallback(
(cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}),
[scrollRef]
),
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}), [scrollRef]),
() => {
const s = scrollRef.current
if (!s) return NaN
if (!s) {
return NaN
}
const b = Math.floor(s.getScrollTop() / QUANTUM)
return s.isSticky() ? -b - 1 : b
},
() => NaN
@@ -44,6 +56,7 @@ export function useVirtualHistory(
useEffect(() => {
const keep = new Set(items.map(i => i.key))
let dirty = false
for (const k of heights.current.keys()) {
if (!keep.has(k)) {
heights.current.delete(k)
@@ -52,13 +65,19 @@ export function useVirtualHistory(
dirty = true
}
}
if (dirty) setVer(v => v + 1)
if (dirty) {
setVer(v => v + 1)
}
}, [items])
const offsets = useMemo(() => {
const out = new Array<number>(items.length + 1).fill(0)
for (let i = 0; i < items.length; i++)
for (let i = 0; i < items.length; i++) {
out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
}
return out
}, [estimate, items, ver])
@@ -67,7 +86,8 @@ export function useVirtualHistory(
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
const sticky = scrollRef.current?.isSticky() ?? true
let start = 0, end = items.length
let start = 0,
end = items.length
if (items.length > 0) {
if (vp <= 0) {
@@ -79,37 +99,46 @@ export function useVirtualHistory(
}
if (end - start > maxMounted) {
sticky
? (start = Math.max(0, end - maxMounted))
: (end = Math.min(items.length, start + maxMounted))
sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(items.length, start + maxMounted))
}
const measureRef = useCallback((key: string) => {
let fn = refs.current.get(key)
if (!fn) {
fn = (el: any) => el ? nodes.current.set(key, el) : nodes.current.delete(key)
fn = (el: any) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
refs.current.set(key, fn)
}
return fn
}, [])
useLayoutEffect(() => {
let dirty = false
for (let i = start; i < end; i++) {
const k = items[i]?.key
if (!k) continue
if (!k) {
continue
}
const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0)
if (h > 0 && heights.current.get(k) !== h) {
heights.current.set(k, h)
dirty = true
}
}
if (dirty) setVer(v => v + 1)
if (dirty) {
setVer(v => v + 1)
}
}, [end, items, start])
return {
start,
end,
offsets,
topSpacer: offsets[start] ?? 0,
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
measureRef

View File

@@ -49,8 +49,10 @@ declare module '@hermes/ink' {
export type ScrollBoxHandle = {
readonly scrollTo: (y: number) => void
readonly scrollBy: (dy: number) => void
readonly scrollToElement: (el: unknown, offset?: number) => void
readonly scrollToBottom: () => void
readonly getScrollTop: () => number
readonly getPendingDelta: () => number
readonly getViewportHeight: () => number
readonly isSticky: () => boolean
readonly subscribe: (listener: () => void) => () => void