Merge pull request #18725 from feat/thinking-ux-improvements

This commit is contained in:
Keith Guerin
2026-03-01 23:19:54 -08:00
13 changed files with 334 additions and 114 deletions

View File

@@ -372,11 +372,11 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator: Processing');
});
it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => {
it('renders actual thought subject in loading indicator even when full inline thinking is enabled', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
subject: 'Detailed in-history thought',
subject: 'Thinking about code',
description: 'Full text is already in history',
},
});
@@ -387,7 +387,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking ...');
expect(output).toContain('LoadingIndicator: Thinking...');
});
it('hides shortcuts hint while loading', async () => {

View File

@@ -343,7 +343,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
}
thoughtLabel={
!isExperimentalLayout && inlineThinkingMode === 'full'
? 'Thinking ...'
? 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}
@@ -417,7 +417,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
@@ -461,7 +461,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
}
thoughtLabel={
inlineThinkingMode === 'full'
? 'Thinking ...'
? 'Thinking...'
: undefined
}
elapsedTime={uiState.elapsedTime}

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { type HistoryItem } from '../types.js';
import { MessageType } from '../types.js';
@@ -290,6 +291,27 @@ describe('<HistoryItemDisplay />', () => {
unmount();
});
it('renders "Thinking..." header when isFirstThinking is true', async () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} isFirstThinking={true} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
},
);
await waitUntilReady();
const output = stripAnsi(lastFrame());
expect(output).toContain(' Thinking...');
expect(output).toContain('Thinking');
unmount();
});
it('does not render thinking item when disabled', async () => {
const item: HistoryItem = {
...baseItem,

View File

@@ -47,6 +47,8 @@ interface HistoryItemDisplayProps {
commands?: readonly SlashCommand[];
availableTerminalHeightGemini?: number;
isExpandable?: boolean;
isFirstThinking?: boolean;
isLastThinking?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -57,6 +59,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
commands,
availableTerminalHeightGemini,
isExpandable,
isFirstThinking = false,
isLastThinking = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -71,7 +75,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
>
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage thought={itemForDisplay.thought} />
<ThinkingMessage
thought={itemForDisplay.thought}
terminalWidth={terminalWidth}
isFirstThinking={isFirstThinking}
isLastThinking={isLastThinking}
/>
)}
{itemForDisplay.type === 'hint' && (
<HintMessage text={itemForDisplay.text} />

View File

@@ -258,13 +258,32 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
expect(output).toContain(''); // Replaced emoji expectation
// Should NOT contain "Thinking... Thinking" prefix because the subject already starts with "Thinking"
expect(output).not.toContain('Thinking... Thinking');
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
unmount();
});
it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => {
const props = {
thought: {
subject: 'Planning the response...',
description: 'details',
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... Planning the response...');
unmount();
});
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
const props = {
thought: {
@@ -280,13 +299,13 @@ describe('<LoadingIndicator />', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(''); // Replaced emoji expectation
expect(output).toContain('Thinking... ');
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
unmount();
});
it('should not display thought icon for non-thought loading phrases', async () => {
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
@@ -295,7 +314,7 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain(''); // Replaced emoji expectation
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});

View File

@@ -67,7 +67,16 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
(streamingState === StreamingState.Responding
? GENERIC_WORKING_LABEL
: undefined);
const thinkingIndicator = '';
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
// Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
const thinkingIndicator =
hasThoughtIndicator && !primaryText?.startsWith('Thinking')
? 'Thinking... '
: '';
const cancelAndTimerContent =
showCancelAndTimer &&

View File

@@ -65,6 +65,14 @@ export const MainContent = () => {
() =>
uiState.history.map((h, index) => {
const isExpandable = index > lastUserPromptIndex;
const isFirstThinking =
h.type === 'thinking' &&
(index === 0 || uiState.history[index - 1]?.type !== 'thinking');
const isLastThinking =
h.type === 'thinking' &&
(index === uiState.history.length - 1 ||
uiState.history[index + 1]?.type !== 'thinking');
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
@@ -79,6 +87,8 @@ export const MainContent = () => {
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isLastThinking={isLastThinking}
/>
);
}),
@@ -105,18 +115,32 @@ export const MainContent = () => {
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isExpandable={true}
/>
))}
{pendingHistoryItems.map((item, i) => {
const isFirstThinking =
item.type === 'thinking' &&
(i === 0 || pendingHistoryItems[i - 1]?.type !== 'thinking') &&
(uiState.history.length === 0 ||
uiState.history.at(-1)?.type !== 'thinking');
const isLastThinking =
item.type === 'thinking' &&
(i === pendingHistoryItems.length - 1 ||
pendingHistoryItems[i + 1]?.type !== 'thinking');
return (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
isLastThinking={isLastThinking}
/>
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
)}
@@ -129,17 +153,29 @@ export const MainContent = () => {
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
uiState.history,
],
);
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...uiState.history.map((item, index) => ({
type: 'history' as const,
item,
isExpandable: index > lastUserPromptIndex,
})),
...uiState.history.map((item, index) => {
const isFirstThinking =
item.type === 'thinking' &&
(index === 0 || uiState.history[index - 1]?.type !== 'thinking');
const isLastThinking =
item.type === 'thinking' &&
(index === uiState.history.length - 1 ||
uiState.history[index + 1]?.type !== 'thinking');
return {
type: 'history' as const,
item,
isExpandable: index > lastUserPromptIndex,
isFirstThinking,
isLastThinking,
};
}),
{ type: 'pending' as const },
],
[uiState.history, lastUserPromptIndex],
@@ -170,6 +206,8 @@ export const MainContent = () => {
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isLastThinking={item.isLastThinking}
/>
);
} else {

View File

@@ -389,7 +389,8 @@ exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi
`;
exports[`<HistoryItemDisplay /> > thinking items > renders thinking item when enabled 1`] = `
" Thinking
test
"
Thinking
│ test
"
`;

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 = 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 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,88 @@ 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} flexDirection="column">
{summary && (
<Box paddingLeft={1}>
<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>
);
};

View File

@@ -1,30 +1,8 @@
// 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
"
Matching the Blocks
│ Some more text
"
`;

View File

@@ -2793,7 +2793,6 @@ describe('useGeminiStream', () => {
type: 'thinking',
thought: expect.objectContaining({ subject: 'Full thought' }),
}),
expect.any(Number),
);
});

View File

@@ -903,17 +903,14 @@ export const useGeminiStream = (
);
const handleThoughtEvent = useCallback(
(eventValue: ThoughtSummary, userMessageTimestamp: number) => {
(eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
setThought(eventValue);
if (getInlineThinkingMode(settings) === 'full') {
addItem(
{
type: 'thinking',
thought: eventValue,
} as HistoryItemThinking,
userMessageTimestamp,
);
addItem({
type: 'thinking',
thought: eventValue,
} as HistoryItemThinking);
}
},
[addItem, settings, setThought],