Code review cleanup for thinking display (#18720)

This commit is contained in:
Jacob Richman
2026-02-10 11:12:40 -08:00
committed by GitHub
parent 9813531f81
commit f9fc9335f5
16 changed files with 125 additions and 346 deletions
@@ -13,84 +13,66 @@ describe('ThinkingMessage', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: 'Planning', description: 'test' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Planning');
expect(lastFrame()).toMatchSnapshot();
});
it('uses description when subject is empty', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: 'Processing details' }}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Processing details');
expect(lastFrame()).toMatchSnapshot();
});
it('renders full mode with left vertical rule and full text', () => {
it('renders full mode with left border and full text', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Planning',
description: 'I am planning the solution.',
}}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('│');
expect(lastFrame()).not.toContain('┌');
expect(lastFrame()).not.toContain('┐');
expect(lastFrame()).not.toContain('└');
expect(lastFrame()).not.toContain('┘');
expect(lastFrame()).toContain('Planning');
expect(lastFrame()).toContain('I am planning the solution.');
expect(lastFrame()).toMatchSnapshot();
});
it('starts left rule below the bold summary line in full mode', () => {
it('indents summary line correctly', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Summary line',
description: 'First body line',
}}
terminalWidth={80}
/>,
);
const lines = (lastFrame() ?? '').split('\n');
expect(lines[0] ?? '').toContain('Summary line');
expect(lines[0] ?? '').not.toContain('│');
expect(lines.slice(1).join('\n')).toContain('│');
expect(lastFrame()).toMatchSnapshot();
});
it('normalizes escaped newline tokens so literal \\n\\n is not shown', () => {
it('normalizes escaped newline tokens', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Matching the Blocks',
description: '\\n\\n',
description: '\\n\\nSome more text',
}}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Matching the Blocks');
expect(lastFrame()).not.toContain('\\n\\n');
expect(lastFrame()).toMatchSnapshot();
});
it('renders empty state gracefully', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: '' }}
terminalWidth={80}
/>,
<ThinkingMessage thought={{ subject: '', description: '' }} />,
);
expect(lastFrame()).not.toContain('Planning');
expect(lastFrame()).toBe('');
});
});
@@ -9,163 +9,72 @@ import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { ThoughtSummary } from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
terminalWidth: number;
}
const THINKING_LEFT_PADDING = 1;
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 normalizeEscapedNewlines(value: string): string {
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
}
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;
}
/**
* Renders a model's thought as a distinct bubble.
* Leverages Ink layout for wrapping and borders.
*/
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
thought,
terminalWidth,
}) => {
const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
const fullSummaryDisplayLines = useMemo(() => {
const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
return fullLines.length > 0
? wrapLineToWidth(fullLines[0], contentWidth)
: [];
}, [fullLines, terminalWidth]);
const fullBodyDisplayLines = useMemo(() => {
const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
return fullLines
.slice(1)
.flatMap((line) => wrapLineToWidth(line, contentWidth));
}, [fullLines, terminalWidth]);
const { summary, body } = useMemo(() => {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
if (
fullSummaryDisplayLines.length === 0 &&
fullBodyDisplayLines.length === 0
) {
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) {
return null;
}
return (
<Box
width={terminalWidth}
marginBottom={1}
paddingLeft={THINKING_LEFT_PADDING}
flexDirection="column"
>
{fullSummaryDisplayLines.map((line, index) => (
<Box key={`summary-line-row-${index}`} flexDirection="row">
<Box width={2}>
<Text> </Text>
</Box>
<Text color={theme.text.primary} bold italic wrap="truncate-end">
{line}
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
{summary && (
<Box paddingLeft={2}>
<Text color={theme.text.primary} bold italic>
{summary}
</Text>
</Box>
))}
{fullBodyDisplayLines.map((line, index) => (
<Box key={`body-line-row-${index}`} flexDirection="row">
<Box width={2}>
<Text color={theme.border.default}> </Text>
</Box>
<Text color={theme.text.secondary} italic wrap="truncate-end">
{line}
)}
{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>
</Box>
))}
)}
</Box>
);
};
@@ -247,7 +247,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={1}
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
@@ -0,0 +1,30 @@
// 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
│ Some more text
"
`;
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
" Planning
│ I am planning the solution.
"
`;
exports[`ThinkingMessage > renders subject line 1`] = `
" Planning
│ test
"
`;
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
" Processing details
"
`;