fix(tui): preserve completed thinking panel

This commit is contained in:
Brooklyn Nicholson 2026-04-26 13:49:41 -05:00
parent 4a21920b5e
commit bb59d3bac2
3 changed files with 31 additions and 25 deletions

View file

@ -82,13 +82,12 @@ describe('createGatewayEventHandler', () => {
type: 'message.complete'
} as any)
expect(appended).toHaveLength(3)
expect(appended).toHaveLength(2)
expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' })
expect(appended[1]).toMatchObject({ kind: 'trail', role: 'system', text: '' })
expect(appended[1]?.tools).toHaveLength(1)
expect(appended[1]?.tools?.[0]).toContain('hero cards')
expect(appended[1]?.toolTokens).toBeGreaterThan(0)
expect(appended[2]).toMatchObject({ role: 'assistant', text: 'final answer' })
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.tools?.[0]).toContain('hero cards')
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('keeps tool tokens across handler recreation mid-turn', () => {
@ -116,10 +115,10 @@ describe('createGatewayEventHandler', () => {
type: 'message.complete'
} as any)
expect(appended).toHaveLength(3)
expect(appended[1]?.tools).toHaveLength(1)
expect(appended[1]?.toolTokens).toBeGreaterThan(0)
expect(appended[2]).toMatchObject({ role: 'assistant', text: 'final answer' })
expect(appended).toHaveLength(2)
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('streams legacy thinking.delta into visible reasoning state', () => {
@ -136,6 +135,21 @@ describe('createGatewayEventHandler', () => {
vi.useRealTimers()
})
it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => {
const appended: Msg[] = []
const streamed = 'first reasoning chunk\nsecond reasoning chunk'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: 'Before edit.' }, type: 'message.delta' } as any)
turnController.flushStreamingSegment()
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended.map(msg => msg.thinking).filter(Boolean)).toEqual([streamed])
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('ignores fallback reasoning.available when streamed reasoning already exists', () => {
const appended: Msg[] = []
const streamed = 'short streamed reasoning'

View file

@ -81,7 +81,6 @@ class TurnController {
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
protocolWarned = false
reasoningText = ''
reasoningSegmentOffset = 0
segmentMessages: Msg[] = []
pendingSegmentTools: string[] = []
statusTimer: Timer = null
@ -107,7 +106,6 @@ class TurnController {
clearReasoning() {
this.reasoningTimer = clear(this.reasoningTimer)
this.reasoningText = ''
this.reasoningSegmentOffset = 0
this.toolTokenAcc = 0
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
}
@ -202,15 +200,10 @@ class TurnController {
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
}
const thinking = this.reasoningText.slice(this.reasoningSegmentOffset).trim()
const msg: Msg = {
role: split.text ? 'assistant' : 'system',
text: split.text,
...(!split.text && { kind: 'trail' as const }),
...(thinking && {
thinking,
thinkingTokens: estimateTokensRough(thinking)
}),
...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools })
}
@ -220,7 +213,6 @@ class TurnController {
this.segmentMessages = [...this.segmentMessages, msg]
}
this.reasoningSegmentOffset = this.reasoningText.length
this.pendingSegmentTools = []
this.bufRef = ''
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
@ -329,7 +321,7 @@ class TurnController {
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
const finalThinking = savedReasoning.slice(this.reasoningSegmentOffset).trim()
const finalThinking = savedReasoning.trim()
const finalDetails: Msg = {
kind: 'trail',
role: 'system',

View file

@ -646,22 +646,22 @@ export const Thinking = memo(function Thinking({
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.dim} dim key={index} wrap="wrap-trim">
<Text color={t.color.cornsilk} key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
<StreamCursor color={t.color.cornsilk} streaming={streaming} visible={active} />
) : null}
</Text>
))
) : (
<Text color={t.color.dim} dim wrap="truncate-end">
<Text color={t.color.cornsilk} wrap="truncate-end">
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
<StreamCursor color={t.color.cornsilk} streaming={streaming} visible={active} />
</Text>
)
) : (
<Text color={t.color.dim} dim>
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
<Text color={t.color.cornsilk}>
<StreamCursor color={t.color.cornsilk} streaming={streaming} visible={active} />
</Text>
)}
</Box>