diff --git a/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx b/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx new file mode 100644 index 0000000000000..0209f02fe32b9 --- /dev/null +++ b/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx @@ -0,0 +1,118 @@ +import { PassThrough } from 'stream' + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { describe, expect, it } from 'vitest' + +import { ToolTrail } from '../components/thinking.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const flushEffects = async () => { + // Passive effects + the re-render they trigger need a few macrotask + // turns (React's scheduler uses MessageChannel) before the next frame + // paints — setTimeout(0)-class waits, not setImmediate (which can land + // in the wrong phase and observe the pre-effect frame). + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +const mountTrail = (reasoningActive: boolean, sections?: Record) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 60, isTTY: false, rows: 20 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + , + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + // The PassThrough accumulates every repaint, and a collapsed panel stops + // repainting entirely once settled — so assert on the FINAL chevron state + // in the accumulated output rather than the tail after a clear(). + const finalChevronOpen = () => stripAnsi(output).lastIndexOf('▾ ') > stripAnsi(output).lastIndexOf('▸ ') + + return { finalChevronOpen, instance } +} + +describe('ToolTrail — collapsed mode auto-expands while reasoning is live', () => { + it('opens (▾) when reasoningActive is true under sections.thinking: collapsed', async () => { + const { finalChevronOpen, instance } = mountTrail(true) + + await flushEffects() + + expect(finalChevronOpen()).toBe(true) + + instance.unmount() + instance.cleanup() + }) + + it('collapses (▸) when reasoningActive is false under sections.thinking: collapsed', async () => { + const { finalChevronOpen, instance } = mountTrail(false) + + await flushEffects() + + expect(finalChevronOpen()).toBe(false) + + instance.unmount() + instance.cleanup() + }) + + it('closes the panel when the reasoning phase ends mid-turn (rerender)', async () => { + const { finalChevronOpen, instance } = mountTrail(true) + + await flushEffects() + + expect(finalChevronOpen()).toBe(true) + + // Reasoning phase finished (final answer / tool call started) — the + // turn's reasoningActive drops and the panel must collapse. + instance.rerender( + + ) + + await flushEffects() + + expect(finalChevronOpen()).toBe(false) + + instance.unmount() + instance.cleanup() + }) + + it('leaves expanded-mode panels fully manual (no forced collapse)', async () => { + const { finalChevronOpen, instance } = mountTrail(false, { thinking: 'expanded' }) + + await flushEffects() + + // `expanded` is a manual preference: reasoningActive=false must NOT + // force it closed (the auto behavior only applies to `collapsed`). + expect(finalChevronOpen()).toBe(true) + + instance.unmount() + instance.cleanup() + }) +}) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 91380466487da..3314c3554516c 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -266,6 +266,12 @@ class TurnController { endReasoningPhase() { this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + // Seal any open reasoning segment so its isLiveReasoning flag drops the + // moment the reasoning phase ends — the panel must stop tracking the + // turn's global reasoningActive, not stay "live" for the rest of the turn. + if (this.reasoningSegmentIndex !== null) { + this.syncReasoningSegment(false) + } patchTurnState({ reasoningActive: false, reasoningStreaming: false }) } @@ -359,7 +365,7 @@ class TurnController { }) } - private syncReasoningSegment() { + private syncReasoningSegment(live = true) { const thinking = this.activeReasoningText.trim() if (!thinking) { @@ -372,7 +378,8 @@ class TurnController { text: '', thinking, thinkingTokens: estimateTokensRough(thinking), - toolTokens: this.toolTokenAcc || undefined + toolTokens: this.toolTokenAcc || undefined, + ...(live ? { isLiveReasoning: true } : {}) } if (this.reasoningSegmentIndex === null) { @@ -386,7 +393,7 @@ class TurnController { } private closeReasoningSegment() { - this.syncReasoningSegment() + this.syncReasoningSegment(false) this.activeReasoningText = '' this.reasoningSegmentIndex = null } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 09b1c78a1ad44..907314b817c2a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -36,6 +36,7 @@ export const MessageLine = memo(function MessageLine({ isStreaming = false, msg, prev, + reasoningActive = false, sections, t, tools = [] @@ -82,6 +83,7 @@ export const MessageLine = memo(function MessageLine({ commandOverride={detailsModeCommandOverride} detailsMode={detailsMode} reasoning={thinking} + reasoningActive={reasoningActive} reasoningAlwaysVisible={msg.isMoaReference} reasoningTokens={msg.thinkingTokens} sections={sections} @@ -246,6 +248,7 @@ export const MessageLine = memo(function MessageLine({ commandOverride={detailsModeCommandOverride} detailsMode={detailsMode} reasoning={thinking} + reasoningActive={reasoningActive} reasoningTokens={msg.thinkingTokens} sections={sections} t={t} @@ -305,6 +308,7 @@ interface MessageLineProps { // lead gap (see domain/blockLayout.ts::hasLeadGap). Undefined at the top of // the transcript or when spacing is irrelevant. prev?: Msg + reasoningActive?: boolean sections?: SectionVisibility t: Theme tools?: ActiveTool[] diff --git a/ui-tui/src/components/streamingAssistant.tsx b/ui-tui/src/components/streamingAssistant.tsx index 3f4c500c7e8db..d7dd49dc4e74a 100644 --- a/ui-tui/src/components/streamingAssistant.tsx +++ b/ui-tui/src/components/streamingAssistant.tsx @@ -78,6 +78,7 @@ export const StreamingAssistant = memo(function StreamingAssistant({ key={block.key} msg={block.msg} prev={prev} + reasoningActive={block.msg.isLiveReasoning === true} sections={sections} t={ui.theme} {...(block.tools ? { tools: block.tools } : {})} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 47c8d667d8ac0..5295122566252 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -772,6 +772,20 @@ export const ToolTrail = memo(function ToolTrail({ setOpenMeta(visible.activity === 'expanded') }, [visible]) + // `collapsed` is an auto preference: keep the panel open while reasoning + // is live (stream pulses keep `reasoningActive` true) and collapse it the + // moment the reasoning phase ends (`endReasoningPhase` flips it false). + // `expanded` stays fully manual, `hidden` never renders content, and MoA + // reference panels (reasoningAlwaysVisible) are left alone. + const thinkingAuto = visible.thinking === 'collapsed' && !reasoningAlwaysVisible + useEffect(() => { + if (!thinkingAuto) { + return + } + + setOpenThinking(reasoningActive) + }, [thinkingAuto, reasoningActive]) + const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) // Spawn-tree derivations must live above any early return so React's diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 49c4cef4189a5..c976913522d6a 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -125,6 +125,11 @@ export interface Msg { // user-facing mixture-of-agents process the user opted into, so it stays // visible even when `display.sections.thinking` is hidden. isMoaReference?: boolean + // True only while this trail segment's reasoning is being streamed live by + // the current turn (see turnController's syncReasoningSegment). Sealed + // reasoning segments from earlier in the turn carry no flag, so the TUI can + // tell "the reasoning happening right now" apart from finished blocks. + isLiveReasoning?: boolean thinkingTokens?: number toolTokens?: number tools?: string[]