feat(cli): overhaul inline thinking UI to match mock and update status bar indicator

This commit is contained in:
Keith Guerin
2026-02-10 00:32:19 -08:00
parent a1367e9cdd
commit 6bc4d99377
10 changed files with 328 additions and 85 deletions

View File

@@ -9,15 +9,20 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js';
describe('ThinkingMessage', () => {
it('renders subject line', async () => {
it('renders subject line with vertical rule and "Thinking..." header', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThinkingMessage
thought={{ subject: 'Planning', description: 'test' }}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
const output = lastFrame();
expect(output).toContain(' Thinking...');
expect(output).toContain('│');
expect(output).toContain('Planning');
unmount();
});
@@ -25,11 +30,14 @@ describe('ThinkingMessage', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: 'Processing details' }}
terminalWidth={80}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
const output = lastFrame();
expect(output).toContain('Processing details');
expect(output).toContain('│');
unmount();
});
@@ -40,26 +48,35 @@ describe('ThinkingMessage', () => {
subject: 'Planning',
description: 'I am planning the solution.',
}}
terminalWidth={80}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
const output = lastFrame();
expect(output).toContain('│');
expect(output).toContain('Planning');
expect(output).toContain('I am planning the solution.');
unmount();
});
it('indents summary line correctly', async () => {
it('renders "Thinking..." header when isFirstThinking is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Summary line',
description: 'First body line',
}}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
const output = lastFrame();
expect(output).toContain(' Thinking...');
expect(output).toContain('Summary line');
expect(output).toContain('│');
unmount();
});
@@ -70,6 +87,7 @@ describe('ThinkingMessage', () => {
subject: 'Matching the Blocks',
description: '\\n\\nSome more text',
}}
terminalWidth={80}
/>,
);
await waitUntilReady();
@@ -80,7 +98,10 @@ describe('ThinkingMessage', () => {
it('renders empty state gracefully', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThinkingMessage thought={{ subject: '', description: '' }} />,
<ThinkingMessage
thought={{ subject: '', description: '' }}
terminalWidth={80}
/>,
);
await waitUntilReady();

View File

@@ -13,6 +13,105 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
terminalWidth: number;
isFirstThinking?: boolean;
isLastThinking?: boolean;
}
const THINKING_LEFT_PADDING = 1;
const VERTICAL_LINE_WIDTH = 2;
function splitGraphemes(value: string): string[] {
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
const segmenter = new Intl.Segmenter(undefined, {
granularity: 'grapheme',
});
return Array.from(segmenter.segment(value), (segment) => segment.segment);
}
return Array.from(value);
}
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')
.map((line) => line.trim())
.filter(Boolean);
}
const bodyLines = description
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return [subject, ...bodyLines];
}
function graphemeLength(value: string): number {
return splitGraphemes(value).length;
}
function chunkToWidth(value: string, width: number): string[] {
if (width <= 0) {
return [''];
}
const graphemes = splitGraphemes(value);
if (graphemes.length === 0) {
return [''];
}
const chunks: string[] = [];
for (let index = 0; index < graphemes.length; index += width) {
chunks.push(graphemes.slice(index, index + width).join(''));
}
return chunks;
}
function wrapLineToWidth(line: string, width: number): string[] {
if (width <= 0) {
return [''];
}
const normalized = line.trim();
if (!normalized) {
return [''];
}
const words = normalized.split(/\s+/);
const wrapped: string[] = [];
let current = '';
for (const word of words) {
const wordChunks = chunkToWidth(word, width);
for (const wordChunk of wordChunks) {
if (!current) {
current = wordChunk;
continue;
}
if (graphemeLength(current) + 1 + graphemeLength(wordChunk) <= width) {
current = `${current} ${wordChunk}`;
} else {
wrapped.push(current);
current = wordChunk;
}
}
}
if (current) {
wrapped.push(current);
}
return wrapped;
}
/**
@@ -21,60 +120,89 @@ interface ThinkingMessageProps {
*/
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
thought,
terminalWidth,
isFirstThinking,
isLastThinking,
}) => {
const { summary, body } = useMemo(() => {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
const contentWidth = Math.max(
terminalWidth - THINKING_LEFT_PADDING - VERTICAL_LINE_WIDTH - 2,
1,
);
if (!subject && !description) {
return { summary: '', body: '' };
}
const fullSummaryDisplayLines = useMemo(
() => (fullLines.length > 0 ? wrapLineToWidth(fullLines[0], contentWidth) : []),
[fullLines, contentWidth],
);
if (!subject) {
const lines = description
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
return {
summary: lines[0] || '',
body: lines.slice(1).join('\n'),
};
}
const fullBodyDisplayLines = useMemo(
() =>
fullLines
.slice(1)
.flatMap((line) => wrapLineToWidth(line, contentWidth)),
[fullLines, contentWidth],
);
return {
summary: subject,
body: description,
};
}, [thought]);
if (!summary && !body) {
if (fullLines.length === 0) {
return null;
}
const verticalLine = (
<Box width={VERTICAL_LINE_WIDTH}>
<Text color={theme.text.secondary}> </Text>
</Box>
);
return (
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
{summary && (
<Box paddingLeft={2}>
<Text color={theme.text.primary} bold italic>
{summary}
<Box
width={terminalWidth}
flexDirection="column"
marginBottom={isLastThinking ? 1 : 0}
>
{isFirstThinking && (
<>
<Text color={theme.text.primary} italic>
{' '}
Thinking...
</Text>
<Box flexDirection="row">
<Box width={THINKING_LEFT_PADDING} />
{verticalLine}
<Text> </Text>
</Box>
</>
)}
{!isFirstThinking && (
<Box flexDirection="row">
<Box width={THINKING_LEFT_PADDING} />
{verticalLine}
<Text> </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}
</Text>
{fullSummaryDisplayLines.map((line, index) => (
<Box key={`summary-line-row-${index}`} flexDirection="row">
<Box width={THINKING_LEFT_PADDING} />
{verticalLine}
<Box marginLeft={1}>
<Text color={theme.text.primary} bold italic wrap="truncate-end">
{line}
</Text>
</Box>
</Box>
)}
))}
{fullBodyDisplayLines.map((line, index) => (
<Box key={`body-line-row-${index}`} flexDirection="row">
<Box width={THINKING_LEFT_PADDING} />
{verticalLine}
<Box marginLeft={1}>
<Text color={theme.text.secondary} italic wrap="truncate-end">
{line}
</Text>
</Box>
</Box>
))}
</Box>
);
};