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 EXCERPT = { chars: 1200, lines: 14 }
const MAX_HISTORY = 800
const REASONING_PULSE_MS = 700
const SECRET_PATTERNS = [
/AKIA[0-9A-Z]{16}/g,
@ -337,6 +338,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [secret, setSecret] = useState<SecretReq | null>(null)
const [picker, setPicker] = useState(false)
const [reasoning, setReasoning] = useState('')
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [statusBar, setStatusBar] = useState(true)
const [lastUserMsg, setLastUserMsg] = useState('')
const [pastes, setPastes] = useState<PendingPaste[]>([])
@ -370,6 +372,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const colsRef = useRef(cols)
const turnToolsRef = useRef<string[]>([])
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const busyRef = useRef(busy)
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
@ -386,6 +389,36 @@ export function App({ gw }: { gw: GatewayClient }) {
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
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() {
return !!(clarify || approval || pasteReview || picker || secret || sudo || pager)
}
@ -1356,6 +1389,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'reasoning.delta':
if (p?.text) {
setReasoning(prev => prev + p.text)
pulseReasoningStreaming()
}
break
@ -1382,6 +1416,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'tool.start':
pruneTransient()
clearReasoningStreaming()
setTools(prev => [
...prev,
{ 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':
pruneTransient()
clearReasoningStreaming()
if (p?.text && !interruptedRef.current) {
buf.current = p.rendered ?? buf.current + p.text
@ -1492,6 +1528,7 @@ export function App({ gw }: { gw: GatewayClient }) {
idle()
setReasoning('')
clearReasoningStreaming()
setStreaming('')
if (inflightPasteIdsRef.current.length) {
@ -2447,6 +2484,7 @@ export function App({ gw }: { gw: GatewayClient }) {
activity={busy ? activity : []}
busy={busy && !streaming}
reasoning={reasoning}
reasoningStreaming={reasoningStreaming}
t={theme}
thinkingMode={hasReasoning ? thinkingMode : 'truncated'}
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) ─────────────────────────────────────
export const Thinking = memo(function Thinking({
mode = 'truncated',
reasoning,
streaming = false,
t
}: {
mode?: ThinkingMode
reasoning: string
streaming?: boolean
t: Theme
}) {
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 dimColor> </Text>
{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>
) : null}
</Box>
@ -102,6 +124,7 @@ export const ToolTrail = memo(function ToolTrail({
busy = false,
thinkingMode = 'truncated',
reasoning = '',
reasoningStreaming = false,
t,
tools = [],
trail = [],
@ -110,6 +133,7 @@ export const ToolTrail = memo(function ToolTrail({
busy?: boolean
thinkingMode?: ThinkingMode
reasoning?: string
reasoningStreaming?: boolean
t: Theme
tools?: ActiveTool[]
trail?: string[]
@ -214,7 +238,24 @@ export const ToolTrail = memo(function ToolTrail({
}
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 ─────────────────────────────────────────────
@ -230,7 +271,7 @@ export const ToolTrail = memo(function ToolTrail({
return (
<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 && (
<Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" />
)}