feat(tui): auto-collapse reasoning blocks only when the reasoning phase ends
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)
This commit is contained in:
@@ -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<string, string>) => {
|
||||
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(
|
||||
<ToolTrail
|
||||
reasoning="Live reasoning text."
|
||||
reasoningActive={reasoningActive}
|
||||
sections={sections ?? { thinking: 'collapsed' }}
|
||||
t={DEFAULT_THEME}
|
||||
/>,
|
||||
{
|
||||
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(
|
||||
<ToolTrail
|
||||
reasoning="Live reasoning text."
|
||||
reasoningActive={false}
|
||||
sections={{ thinking: 'collapsed' }}
|
||||
t={DEFAULT_THEME}
|
||||
/>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 } : {})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user