diff --git a/ui-tui/src/__tests__/reasoning.test.ts b/ui-tui/src/__tests__/reasoning.test.ts
new file mode 100644
index 0000000000..c961ea7a0c
--- /dev/null
+++ b/ui-tui/src/__tests__/reasoning.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+
+import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
+
+describe('splitReasoning', () => {
+ it('extracts … and strips it from text', () => {
+ const { reasoning, text } = splitReasoning('plotting\n\nhere is the answer')
+
+ expect(reasoning).toBe('plotting')
+ expect(text).toBe('here is the answer')
+ })
+
+ it('handles multiple tag shapes', () => {
+ const input = 'a b c body'
+ const { reasoning, text } = splitReasoning(input)
+
+ expect(reasoning).toContain('a')
+ expect(reasoning).toContain('b')
+ expect(reasoning).toContain('c')
+ expect(text).toBe('body')
+ })
+
+ it('treats unclosed trailing … as reasoning', () => {
+ const { reasoning, text } = splitReasoning('answer start still deciding')
+
+ expect(reasoning).toBe('still deciding')
+ expect(text).toBe('answer start')
+ })
+
+ it('returns empty reasoning and untouched text when no tags present', () => {
+ const { reasoning, text } = splitReasoning('plain body with no tags')
+
+ expect(reasoning).toBe('')
+ expect(text).toBe('plain body with no tags')
+ })
+
+ it('preserves text when reasoning block is empty', () => {
+ const { reasoning, text } = splitReasoning('only body')
+
+ expect(reasoning).toBe('')
+ expect(text).toBe('only body')
+ })
+
+ it('detects presence of any supported tag', () => {
+ expect(hasReasoningTag('pre x post')).toBe(true)
+ expect(hasReasoningTag('pre x')).toBe(true)
+ expect(hasReasoningTag('x')).toBe(true)
+ expect(hasReasoningTag('no tags at all')).toBe(false)
+ })
+})
diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts
index de57b2dd05..236324ffb9 100644
--- a/ui-tui/src/app/turnController.ts
+++ b/ui-tui/src/app/turnController.ts
@@ -1,5 +1,6 @@
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js'
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
+import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
buildToolTrailLine,
estimateTokensRough,
@@ -121,18 +122,31 @@ class TurnController {
}
flushStreamingSegment() {
- const text = this.bufRef.trimStart()
+ const raw = this.bufRef.trimStart()
- if (!text) {
+ if (!raw) {
return
}
- const tools = this.pendingSegmentTools
+ const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }
+
+ if (split.reasoning && !this.reasoningText.trim()) {
+ this.reasoningText = split.reasoning
+ patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
+ }
+
+ const text = split.text
this.streamTimer = clear(this.streamTimer)
- this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
+
+ if (text) {
+ const tools = this.pendingSegmentTools
+
+ this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
+ this.pendingSegmentTools = []
+ }
+
this.bufRef = ''
- this.pendingSegmentTools = []
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
}
@@ -187,8 +201,11 @@ class TurnController {
}
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
- const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
- const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
+ const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
+ const split = splitReasoning(rawText)
+ const finalText = split.text
+ const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
+ const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
const savedToolTokens = this.toolTokenAcc
const tools = this.pendingSegmentTools
@@ -355,7 +372,9 @@ class TurnController {
this.streamTimer = setTimeout(() => {
this.streamTimer = null
- patchTurnState({ streaming: this.bufRef.trimStart() })
+ const raw = this.bufRef.trimStart()
+ const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
+ patchTurnState({ streaming: visible })
}, STREAM_BATCH_MS)
}
diff --git a/ui-tui/src/lib/reasoning.ts b/ui-tui/src/lib/reasoning.ts
new file mode 100644
index 0000000000..eba63918c4
--- /dev/null
+++ b/ui-tui/src/lib/reasoning.ts
@@ -0,0 +1,50 @@
+const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const
+
+export interface SplitReasoning {
+ reasoning: string
+ text: string
+}
+
+export function splitReasoning(input: string): SplitReasoning {
+ let text = input
+ const reasoning: string[] = []
+
+ for (const tag of TAGS) {
+ const paired = new RegExp(`<${tag}>([\\s\\S]*?)${tag}>\\s*`, 'gi')
+ text = text.replace(paired, (_m, inner: string) => {
+ const trimmed = inner.trim()
+
+ if (trimmed) {
+ reasoning.push(trimmed)
+ }
+
+ return ''
+ })
+
+ const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i')
+ text = text.replace(unclosed, (_m, inner: string) => {
+ const trimmed = inner.trim()
+
+ if (trimmed) {
+ reasoning.push(trimmed)
+ }
+
+ return ''
+ })
+ }
+
+ return {
+ reasoning: reasoning.join('\n\n').trim(),
+ text: text.trim()
+ }
+}
+
+export const hasReasoningTag = (input: string) => {
+ for (const tag of TAGS) {
+ if (input.includes(`<${tag}>`)) {
+ return true
+ }
+ }
+
+ return false
+}