diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e0ccff15b..a5a0d1296 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -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(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([]) @@ -370,6 +372,7 @@ export function App({ gw }: { gw: GatewayClient }) { const colsRef = useRef(cols) const turnToolsRef = useRef([]) const persistedToolLabelsRef = useRef>(new Set()) + const reasoningStreamingTimerRef = useRef | null>(null) const statusTimerRef = useRef | 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} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 4e609f5e6..c16b9fd65 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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 {active && on ? '▍' : ' '} +} + // ── 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({ {preview} + + + ) : streaming ? ( + + + ) : null} @@ -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} + + + ), + dimColor: true, + key: 'cot' + }) + } else if (reasoningStreaming && groups.length && thinkingMode === 'collapsed') { + detail({ + color: t.color.dim, + content: , + dimColor: true, + key: 'cot' + }) } // ── activity → meta ───────────────────────────────────────────── @@ -230,7 +271,7 @@ export const ToolTrail = memo(function ToolTrail({ return ( - {busy && !groups.length && } + {busy && !groups.length && } {!busy && !groups.length && reasoningTail && ( )}