From 2b0b4a219195e9203e83efb9f1b87cdaabf45f76 Mon Sep 17 00:00:00 2001 From: Jordy Elfferich Date: Sat, 8 Aug 2026 23:50:13 +0200 Subject: [PATCH] feat(tui): auto-collapse reasoning blocks only when the reasoning phase ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under display.sections.thinking: collapsed, the TUI now keeps the LIVE reasoning panel open while reasoning streams and collapses it the moment the reasoning phase ends (first tool call, final answer, or new turn). Previously 'collapsed' meant the panel was always collapsed — including the currently-streaming reasoning — and there was no way to get 'expanded while live, collapsed when done'. This makes 'collapsed' an auto preference: - turnController tags the open reasoning segment isLiveReasoning and seals the tag in endReasoningPhase/closeReasoningSegment - streamingAssistant passes reasoningActive only to the live segment, so sealed reasoning segments from earlier phases stay collapsed - ToolTrail auto-opens while reasoningActive under collapsed mode; expanded/hidden/MoA-reference semantics are unchanged Adds thinkingLiveCollapse.test.tsx covering open-on-stream, close-on- finish (including mid-turn rerender), and the expanded-mode no-op. (cherry picked from commit 6ef4ef77d3a0837c8ef5c5dac3de74f352bd637d) --- .../__tests__/thinkingLiveCollapse.test.tsx | 118 ++++++++++++++++++ ui-tui/src/app/turnController.ts | 13 +- ui-tui/src/components/messageLine.tsx | 4 + ui-tui/src/components/streamingAssistant.tsx | 1 + ui-tui/src/components/thinking.tsx | 14 +++ ui-tui/src/types.ts | 5 + 6 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx diff --git a/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx b/ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx new file mode 100644 index 0000000000..0209f02fe3 --- /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 9138046648..3314c35545 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 1e7c273a2b..ba59f6a343 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -37,6 +37,7 @@ export const MessageLine = memo(function MessageLine({ liveDetails = false, msg, prev, + reasoningActive = false, sections, t, tools = [] @@ -84,6 +85,7 @@ export const MessageLine = memo(function MessageLine({ detailsMode={detailsMode} preferExpandedThinking={liveDetails} reasoning={thinking} + reasoningActive={reasoningActive} reasoningAlwaysVisible={msg.isMoaReference} reasoningTokens={msg.thinkingTokens} sections={sections} @@ -249,6 +251,7 @@ export const MessageLine = memo(function MessageLine({ detailsMode={detailsMode} preferExpandedThinking={liveDetails} reasoning={thinking} + reasoningActive={reasoningActive} reasoningTokens={msg.thinkingTokens} sections={sections} t={t} @@ -309,6 +312,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 9c60bd5077..2cf259a959 100644 --- a/ui-tui/src/components/streamingAssistant.tsx +++ b/ui-tui/src/components/streamingAssistant.tsx @@ -79,6 +79,7 @@ export const StreamingAssistant = memo(function StreamingAssistant({ liveDetails 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 fdf8ac11d9..d3225bee6a 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -777,6 +777,20 @@ export const ToolTrail = memo(function ToolTrail({ setOpenMeta(visible.activity === 'expanded') }, [thinkingDefaultExpanded, 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 49c4cef418..c976913522 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[]