feat: show thinking indicator while inferencing

This commit is contained in:
Brooklyn Nicholson
2026-04-13 10:11:18 -05:00
parent 0fd33a98cd
commit a2c0597ae4
2 changed files with 81 additions and 2 deletions

View File

@@ -58,6 +58,7 @@ const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
const LARGE_PASTE = { chars: 8000, lines: 80 } const LARGE_PASTE = { chars: 8000, lines: 80 }
const EXCERPT = { chars: 1200, lines: 14 } const EXCERPT = { chars: 1200, lines: 14 }
const MAX_HISTORY = 800 const MAX_HISTORY = 800
const REASONING_PULSE_MS = 700
const SECRET_PATTERNS = [ const SECRET_PATTERNS = [
/AKIA[0-9A-Z]{16}/g, /AKIA[0-9A-Z]{16}/g,
@@ -337,6 +338,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [secret, setSecret] = useState<SecretReq | null>(null) const [secret, setSecret] = useState<SecretReq | null>(null)
const [picker, setPicker] = useState(false) const [picker, setPicker] = useState(false)
const [reasoning, setReasoning] = useState('') const [reasoning, setReasoning] = useState('')
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [statusBar, setStatusBar] = useState(true) const [statusBar, setStatusBar] = useState(true)
const [lastUserMsg, setLastUserMsg] = useState('') const [lastUserMsg, setLastUserMsg] = useState('')
const [pastes, setPastes] = useState<PendingPaste[]>([]) const [pastes, setPastes] = useState<PendingPaste[]>([])
@@ -370,6 +372,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const colsRef = useRef(cols) const colsRef = useRef(cols)
const turnToolsRef = useRef<string[]>([]) const turnToolsRef = useRef<string[]>([])
const persistedToolLabelsRef = useRef<Set<string>>(new Set()) const persistedToolLabelsRef = useRef<Set<string>>(new Set())
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const busyRef = useRef(busy) const busyRef = useRef(busy)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
@@ -386,6 +389,36 @@ export function App({ gw }: { gw: GatewayClient }) {
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw)
const pulseReasoningStreaming = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
setReasoningStreaming(true)
reasoningStreamingTimerRef.current = setTimeout(() => {
reasoningStreamingTimerRef.current = null
setReasoningStreaming(false)
}, REASONING_PULSE_MS)
}, [])
const clearReasoningStreaming = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
reasoningStreamingTimerRef.current = null
}
setReasoningStreaming(false)
}, [])
useEffect(
() => () => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
},
[]
)
function blocked() { function blocked() {
return !!(clarify || approval || pasteReview || picker || secret || sudo || pager) return !!(clarify || approval || pasteReview || picker || secret || sudo || pager)
} }
@@ -1356,6 +1389,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'reasoning.delta': case 'reasoning.delta':
if (p?.text) { if (p?.text) {
setReasoning(prev => prev + p.text) setReasoning(prev => prev + p.text)
pulseReasoningStreaming()
} }
break break
@@ -1382,6 +1416,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'tool.start': case 'tool.start':
pruneTransient() pruneTransient()
clearReasoningStreaming()
setTools(prev => [ setTools(prev => [
...prev, ...prev,
{ id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
@@ -1472,6 +1507,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'message.delta': case 'message.delta':
pruneTransient() pruneTransient()
clearReasoningStreaming()
if (p?.text && !interruptedRef.current) { if (p?.text && !interruptedRef.current) {
buf.current = p.rendered ?? buf.current + p.text buf.current = p.rendered ?? buf.current + p.text
@@ -1492,6 +1528,7 @@ export function App({ gw }: { gw: GatewayClient }) {
idle() idle()
setReasoning('') setReasoning('')
clearReasoningStreaming()
setStreaming('') setStreaming('')
if (inflightPasteIdsRef.current.length) { if (inflightPasteIdsRef.current.length) {
@@ -2447,6 +2484,7 @@ export function App({ gw }: { gw: GatewayClient }) {
activity={busy ? activity : []} activity={busy ? activity : []}
busy={busy && !streaming} busy={busy && !streaming}
reasoning={reasoning} reasoning={reasoning}
reasoningStreaming={reasoningStreaming}
t={theme} t={theme}
thinkingMode={hasReasoning ? thinkingMode : 'truncated'} thinkingMode={hasReasoning ? thinkingMode : 'truncated'}
tools={tools} tools={tools}

View File

@@ -56,15 +56,31 @@ function Detail({ color, content, dimColor }: DetailRow) {
) )
} }
// ── 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) ───────────────────────────────────── // ── Thinking (pre-tool fallback) ─────────────────────────────────────
export const Thinking = memo(function Thinking({ export const Thinking = memo(function Thinking({
mode = 'truncated', mode = 'truncated',
reasoning, reasoning,
streaming = false,
t t
}: { }: {
mode?: ThinkingMode mode?: ThinkingMode
reasoning: string reasoning: string
streaming?: boolean
t: Theme t: Theme
}) { }) {
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
@@ -88,6 +104,12 @@ export const Thinking = memo(function Thinking({
<Text color={t.color.dim} dimColor {...(mode !== 'full' ? { wrap: 'truncate-end' as const } : {})}> <Text color={t.color.dim} dimColor {...(mode !== 'full' ? { wrap: 'truncate-end' as const } : {})}>
<Text dimColor> </Text> <Text dimColor> </Text>
{preview} {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> </Text>
) : null} ) : null}
</Box> </Box>
@@ -102,6 +124,7 @@ export const ToolTrail = memo(function ToolTrail({
busy = false, busy = false,
thinkingMode = 'truncated', thinkingMode = 'truncated',
reasoning = '', reasoning = '',
reasoningStreaming = false,
t, t,
tools = [], tools = [],
trail = [], trail = [],
@@ -110,6 +133,7 @@ export const ToolTrail = memo(function ToolTrail({
busy?: boolean busy?: boolean
thinkingMode?: ThinkingMode thinkingMode?: ThinkingMode
reasoning?: string reasoning?: string
reasoningStreaming?: boolean
t: Theme t: Theme
tools?: ActiveTool[] tools?: ActiveTool[]
trail?: string[] trail?: string[]
@@ -214,7 +238,24 @@ export const ToolTrail = memo(function ToolTrail({
} }
if (reasoningTail && groups.length) { if (reasoningTail && groups.length) {
detail({ color: t.color.dim, content: reasoningTail, dimColor: true, key: 'cot' }) 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 ───────────────────────────────────────────── // ── activity → meta ─────────────────────────────────────────────
@@ -230,7 +271,7 @@ export const ToolTrail = memo(function ToolTrail({
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{busy && !groups.length && <Thinking mode={thinkingMode} reasoning={reasoning} t={t} />} {busy && !groups.length && <Thinking mode={thinkingMode} reasoning={reasoning} streaming={reasoningStreaming} t={t} />}
{!busy && !groups.length && reasoningTail && ( {!busy && !groups.length && reasoningTail && (
<Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" /> <Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" />
)} )}