feat(cli): overhaul thinking UI (#18725)

This commit is contained in:
Keith Guerin
2026-03-06 20:20:27 -08:00
committed by GitHub
parent 9455ecd78c
commit e5d58c2b5a
29 changed files with 763 additions and 184 deletions
@@ -7,84 +7,156 @@
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js';
import React from 'react';
describe('ThinkingMessage', () => {
it('renders subject line', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
it('renders subject line with vertical rule and "Thinking..." header', async () => {
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{ subject: 'Planning', description: 'test' }}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
await renderResult.waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
const output = renderResult.lastFrame();
expect(output).toContain(' Thinking...');
expect(output).toContain('│');
expect(output).toContain('Planning');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('uses description when subject is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: 'Processing details' }}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
await renderResult.waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
const output = renderResult.lastFrame();
expect(output).toContain('Processing details');
expect(output).toContain('│');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders full mode with left border and full text', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Planning',
description: 'I am planning the solution.',
}}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
await renderResult.waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
const output = renderResult.lastFrame();
expect(output).toContain('│');
expect(output).toContain('Planning');
expect(output).toContain('I am planning the solution.');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('indents summary line correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
it('renders "Thinking..." header when isFirstThinking is true', async () => {
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Summary line',
description: 'First body line',
}}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
await renderResult.waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
const output = renderResult.lastFrame();
expect(output).toContain(' Thinking...');
expect(output).toContain('Summary line');
expect(output).toContain('│');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('normalizes escaped newline tokens', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Matching the Blocks',
description: '\\n\\nSome more text',
}}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
await renderResult.waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders empty state gracefully', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThinkingMessage thought={{ subject: '', description: '' }} />,
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: '' }}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
await renderResult.waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
expect(renderResult.lastFrame({ allowEmpty: true })).toBe('');
renderResult.unmount();
});
it('renders multiple thinking messages sequentially correctly', async () => {
const renderResult = renderWithProviders(
<React.Fragment>
<ThinkingMessage
thought={{
subject: 'Initial analysis',
description:
'This is a multiple line paragraph for the first thinking message of how the model analyzes the problem.',
}}
terminalWidth={80}
isFirstThinking={true}
/>
<ThinkingMessage
thought={{
subject: 'Planning execution',
description:
'This a second multiple line paragraph for the second thinking message explaining the plan in detail so that it wraps around the terminal display.',
}}
terminalWidth={80}
/>
<ThinkingMessage
thought={{
subject: 'Refining approach',
description:
'And finally a third multiple line paragraph for the third thinking message to refine the solution.',
}}
terminalWidth={80}
/>
</React.Fragment>,
);
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
@@ -13,6 +13,30 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
terminalWidth: number;
isFirstThinking?: boolean;
}
const THINKING_LEFT_PADDING = 1;
function normalizeThoughtLines(thought: ThoughtSummary): string[] {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
if (!subject && !description) {
return [];
}
if (!subject) {
return description.split('\n');
}
if (!description) {
return [subject];
}
const bodyLines = description.split('\n');
return [subject, ...bodyLines];
}
/**
@@ -21,60 +45,47 @@ interface ThinkingMessageProps {
*/
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
thought,
terminalWidth,
isFirstThinking,
}) => {
const { summary, body } = useMemo(() => {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
if (!subject && !description) {
return { summary: '', body: '' };
}
if (!subject) {
const lines = description
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
return {
summary: lines[0] || '',
body: lines.slice(1).join('\n'),
};
}
return {
summary: subject,
body: description,
};
}, [thought]);
if (!summary && !body) {
if (fullLines.length === 0) {
return null;
}
return (
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
{summary && (
<Box paddingLeft={2}>
<Box width={terminalWidth} flexDirection="column">
{isFirstThinking && (
<Text color={theme.text.primary} italic>
{' '}
Thinking...{' '}
</Text>
)}
<Box
marginLeft={THINKING_LEFT_PADDING}
paddingLeft={1}
borderStyle="single"
borderLeft={true}
borderRight={false}
borderTop={false}
borderBottom={false}
borderColor={theme.text.secondary}
flexDirection="column"
>
<Text> </Text>
{fullLines.length > 0 && (
<Text color={theme.text.primary} bold italic>
{summary}
{fullLines[0]}
</Text>
</Box>
)}
{body && (
<Box
borderStyle="single"
borderLeft
borderRight={false}
borderTop={false}
borderBottom={false}
borderColor={theme.border.default}
paddingLeft={1}
>
<Text color={theme.text.secondary} italic>
{body}
)}
{fullLines.slice(1).map((line, index) => (
<Text key={`body-line-${index}`} color={theme.text.secondary} italic>
{line}
</Text>
</Box>
)}
))}
</Box>
</Box>
);
};
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Matching the Blocks</text>
<text x="9" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs" font-style="italic">Some more text</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Summary line</text>
<text x="9" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#afafaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-style="italic">First body line</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Planning</text>
<text x="9" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#afafaf" textLength="243" lengthAdjust="spacingAndGlyphs" font-style="italic">I am planning the solution.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="241" viewBox="0 0 920 241">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="241" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Initial analysis</text>
<text x="9" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#afafaf" textLength="675" lengthAdjust="spacingAndGlyphs" font-style="italic">This is a multiple line paragraph for the first thinking message of how the</text>
<text x="9" y="70" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#afafaf" textLength="243" lengthAdjust="spacingAndGlyphs" font-style="italic">model analyzes the problem.</text>
<text x="9" y="87" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="104" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="104" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Planning execution</text>
<text x="9" y="121" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="121" fill="#afafaf" textLength="621" lengthAdjust="spacingAndGlyphs" font-style="italic">This a second multiple line paragraph for the second thinking message</text>
<text x="9" y="138" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="138" fill="#afafaf" textLength="675" lengthAdjust="spacingAndGlyphs" font-style="italic">explaining the plan in detail so that it wraps around the terminal display.</text>
<text x="9" y="155" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="172" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="172" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Refining approach</text>
<text x="9" y="189" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="189" fill="#afafaf" textLength="693" lengthAdjust="spacingAndGlyphs" font-style="italic">And finally a third multiple line paragraph for the third thinking message to</text>
<text x="9" y="206" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="206" fill="#afafaf" textLength="180" lengthAdjust="spacingAndGlyphs" font-style="italic">refine the solution.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Planning</text>
<text x="9" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs" font-style="italic">test</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Processing details</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 812 B

@@ -1,30 +1,107 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ThinkingMessage > indents summary line correctly 1`] = `
" Summary line
│ First body line
"
`;
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
" Matching the Blocks
" Thinking...
│ Matching the Blocks
│ Some more text
"
`;
exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = `
" Thinking...
│ Matching the Blocks
│ Some more text"
`;
exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = `
" Thinking...
│ Summary line
│ First body line
"
`;
exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 2`] = `
" Thinking...
│ Summary line
│ First body line"
`;
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
" Planning
" Thinking...
│ Planning
│ I am planning the solution.
"
`;
exports[`ThinkingMessage > renders subject line 1`] = `
" Planning
exports[`ThinkingMessage > renders full mode with left border and full text 2`] = `
" Thinking...
│ Planning
│ I am planning the solution."
`;
exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = `
" Thinking...
│ Initial analysis
│ This is a multiple line paragraph for the first thinking message of how the
│ model analyzes the problem.
│ Planning execution
│ This a second multiple line paragraph for the second thinking message
│ explaining the plan in detail so that it wraps around the terminal display.
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution.
"
`;
exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 2`] = `
" Thinking...
│ Initial analysis
│ This is a multiple line paragraph for the first thinking message of how the
│ model analyzes the problem.
│ Planning execution
│ This a second multiple line paragraph for the second thinking message
│ explaining the plan in detail so that it wraps around the terminal display.
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution."
`;
exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = `
" Thinking...
│ Planning
│ test
"
`;
exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 2`] = `
" Thinking...
│ Planning
│ test"
`;
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
" Processing details
" Thinking...
│ Processing details
"
`;
exports[`ThinkingMessage > uses description when subject is empty 2`] = `
" Thinking...
│ Processing details"
`;