Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions ui-tui/src/__tests__/thinkingLiveCollapse.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
13 changes: 10 additions & 3 deletions ui-tui/src/app/turnController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down Expand Up @@ -359,7 +365,7 @@ class TurnController {
})
}

private syncReasoningSegment() {
private syncReasoningSegment(live = true) {
const thinking = this.activeReasoningText.trim()

if (!thinking) {
Expand All @@ -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) {
Expand All @@ -386,7 +393,7 @@ class TurnController {
}

private closeReasoningSegment() {
this.syncReasoningSegment()
this.syncReasoningSegment(false)
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
}
Expand Down
4 changes: 4 additions & 0 deletions ui-tui/src/components/messageLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const MessageLine = memo(function MessageLine({
isStreaming = false,
msg,
prev,
reasoningActive = false,
sections,
t,
tools = []
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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[]
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/components/streamingAssistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {})}
Expand Down
14 changes: 14 additions & 0 deletions ui-tui/src/components/thinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions ui-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down