mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-10 21:30:40 -07:00
feat(cli): overhaul inline thinking UI to match mock and update status bar indicator
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user