diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index e572bd5b8c..e83fe2d311 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -128,6 +128,81 @@ describe('MessageLine', () => { expect(renderedLine).toContain('Ψ > Okay') }) + + it('keeps historical thinking blocks collapsed by default', () => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + React.createElement(MessageLine, { + cols: 80, + msg: { kind: 'trail', role: 'system', text: '', thinking: 'step one\nstep two' }, + t: DEFAULT_THEME + }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + const rendered = stripAnsi(output) + + expect(rendered).toContain('Thinking') + expect(rendered).not.toContain('step one') + expect(rendered).not.toContain('step two') + }) + + it('keeps live thinking blocks expanded while streaming', () => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + React.createElement(MessageLine, { + cols: 80, + liveDetails: true, + msg: { kind: 'trail', role: 'system', text: '', thinking: 'step one\nstep two' }, + t: DEFAULT_THEME + }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + const rendered = stripAnsi(output) + + expect(rendered).toContain('Thinking') + expect(rendered).toContain('step one') + expect(rendered).toContain('step two') + }) }) describe('upsert', () => { diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 17cd32fec8..6440154771 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -82,6 +82,28 @@ describe('virtual height estimates', () => { ).toBe(estimatedMsgHeight(toolsOnly, 80, { compact: false, details: false })) }) + it('treats historical thinking blocks as collapsed unless explicitly expanded', () => { + const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2\nline 3' } + + expect( + estimatedMsgHeight(msg, 80, { + compact: false, + details: true, + thinkingExpanded: false, + thinkingVisible: true, + toolsVisible: false + }) + ).toBeLessThan( + estimatedMsgHeight(msg, 80, { + compact: false, + details: true, + thinkingExpanded: true, + thinkingVisible: true, + toolsVisible: false + }) + ) + }) + it('reserves two extra rows for the inter-turn separator on non-first user messages', () => { const msg: Msg = { role: 'user', text: 'follow-up question' } const base = estimatedMsgHeight(msg, 80, { compact: false, details: false }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 0756c2fd0e..555d66f073 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -353,6 +353,10 @@ export function useMainApp(gw: GatewayClient) { const [thinkingDetailsMode, toolsDetailsMode] = detailsLayoutKey.split(':') const thinkingDetailsVisible = thinkingDetailsMode !== 'hidden' const toolsDetailsVisible = toolsDetailsMode !== 'hidden' + + const historyThinkingExpanded = + thinkingDetailsVisible && (ui.detailsModeCommandOverride || ui.sections.thinking === 'expanded') + const detailsVisible = thinkingDetailsVisible || toolsDetailsVisible const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt) const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` @@ -390,6 +394,7 @@ export function useMainApp(gw: GatewayClient) { }), virtualRows[index]!.msg ), + thinkingExpanded: historyThinkingExpanded, thinkingVisible: thinkingDetailsVisible, toolsVisible: toolsDetailsVisible, userPrompt: ui.theme.brand.prompt, @@ -399,6 +404,7 @@ export function useMainApp(gw: GatewayClient) { cols, detailsVisible, firstUserIdx, + historyThinkingExpanded, thinkingDetailsVisible, toolsDetailsVisible, ui.compact, diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 09b1c78a1a..1e7c273a2b 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -34,6 +34,7 @@ export const MessageLine = memo(function MessageLine({ detailsMode = 'collapsed', detailsModeCommandOverride = false, isStreaming = false, + liveDetails = false, msg, prev, sections, @@ -81,6 +82,7 @@ export const MessageLine = memo(function MessageLine({ Date.now()) // Local toggles own the open state once mounted. Init from the resolved // section visibility so default-expanded sections (thinking/tools) render @@ -735,7 +740,7 @@ export const ToolTrail = memo(function ToolTrail({ // label. This only affects the initial mount value; the re-sync effect // below deliberately does NOT re-apply it, so a manual collapse still // sticks (see the no-OR-at-effect-time warning above, #14968). - const [openThinking, setOpenThinking] = useState(visible.thinking === 'expanded' || reasoningAlwaysVisible) + const [openThinking, setOpenThinking] = useState(thinkingDefaultExpanded || reasoningAlwaysVisible) const [openTools, setOpenTools] = useState(visible.tools === 'expanded') const [openSubagents, setOpenSubagents] = useState(visible.subagents === 'expanded') const [deepSubagents, setDeepSubagents] = useState(visible.subagents === 'expanded') @@ -766,11 +771,11 @@ export const ToolTrail = memo(function ToolTrail({ return } - setOpenThinking(visible.thinking === 'expanded') + setOpenThinking(thinkingDefaultExpanded) setOpenTools(visible.tools === 'expanded') setOpenSubagents(visible.subagents === 'expanded') setOpenMeta(visible.activity === 'expanded') - }, [visible]) + }, [thinkingDefaultExpanded, visible]) const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index bb470da892..cf1ebd95d7 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -74,6 +74,7 @@ export const estimatedMsgHeight = ( details, leadGap = false, thinkingVisible = details, + thinkingExpanded = thinkingVisible, toolsVisible = details, userPrompt = '', withSeparator = false @@ -81,6 +82,7 @@ export const estimatedMsgHeight = ( compact: boolean details: boolean leadGap?: boolean + thinkingExpanded?: boolean thinkingVisible?: boolean toolsVisible?: boolean userPrompt?: string @@ -124,7 +126,7 @@ export const estimatedMsgHeight = ( if (hasVisibleDetails) { h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + - (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0) + (hasVisibleThinking ? (thinkingExpanded ? wrappedLines(msg.thinking ?? '', bodyWidth) : 1) : 0) if (msg.role === 'assistant' && /\S/.test(msg.text)) { h += 2