From 6bc4d993778f36ba68dbb80ee8b93a33d6412321 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Tue, 10 Feb 2026 00:32:19 -0800 Subject: [PATCH] feat(cli): overhaul inline thinking UI to match mock and update status bar indicator --- .../cli/src/ui/components/Composer.test.tsx | 6 +- packages/cli/src/ui/components/Composer.tsx | 4 +- .../ui/components/HistoryItemDisplay.test.tsx | 19 ++ .../src/ui/components/HistoryItemDisplay.tsx | 16 +- .../ui/components/LoadingIndicator.test.tsx | 34 ++- .../src/ui/components/LoadingIndicator.tsx | 2 +- .../cli/src/ui/components/MainContent.tsx | 72 ++++-- .../messages/ThinkingMessage.test.tsx | 35 ++- .../components/messages/ThinkingMessage.tsx | 212 ++++++++++++++---- packages/cli/src/ui/hooks/useGeminiStream.ts | 13 +- 10 files changed, 328 insertions(+), 85 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 999b1531f9..77c8812812 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -370,11 +370,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', }, }); @@ -385,7 +385,7 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); - expect(output).toContain('LoadingIndicator: Thinking ...'); + expect(output).toContain('LoadingIndicator: Thinking about code'); }); it('hides shortcuts hint while loading', async () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 51c879e772..d8cc8d162c 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -238,9 +238,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { ? undefined : uiState.currentLoadingPhrase } - thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined - } + thoughtLabel={undefined} elapsedTime={uiState.elapsedTime} /> )} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index f8c251fbfa..e17afbf0d0 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -290,6 +290,25 @@ describe('', () => { unmount(); }); + it('renders "Thinking..." header when isFirstThinking is true', () => { + const item: HistoryItem = { + ...baseItem, + type: 'thinking', + thought: { subject: 'Thinking', description: 'test' }, + }; + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain(' Thinking...'); + expect(lastFrame()).toContain('Thinking'); + }); + it('does not render thinking item when disabled', async () => { const item: HistoryItem = { ...baseItem, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 458452d795..8567554e26 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -36,7 +36,10 @@ import { HooksList } from './views/HooksList.js'; import { ModelMessage } from './messages/ModelMessage.js'; import { ThinkingMessage } from './messages/ThinkingMessage.js'; import { HintMessage } from './messages/HintMessage.js'; -import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; +import { + getInlineThinkingMode, + type InlineThinkingMode, +} from '../utils/inlineThinkingMode.js'; import { useSettings } from '../contexts/SettingsContext.js'; interface HistoryItemDisplayProps { @@ -47,6 +50,8 @@ interface HistoryItemDisplayProps { commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; isExpandable?: boolean; + isFirstThinking?: boolean; + isLastThinking?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -57,6 +62,8 @@ export const HistoryItemDisplay: React.FC = ({ commands, availableTerminalHeightGemini, isExpandable, + isFirstThinking = false, + isLastThinking = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -66,7 +73,12 @@ export const HistoryItemDisplay: React.FC = ({ {/* Render standard message types */} {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && ( - + )} {itemForDisplay.type === 'hint' && ( diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 61cd64d07a..9df7dec8a0 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -258,14 +258,35 @@ describe('', () => { const output = lastFrame(); expect(output).toBeDefined(); if (output) { - expect(output).toContain('💬'); + expect(output).toContain('Thinking: '); expect(output).toContain('Thinking about something...'); expect(output).not.toContain('and other stuff.'); } unmount(); }); +<<<<<<< HEAD it('should prioritize thought.subject over currentLoadingPhrase', async () => { +======= + it('should use "Thinking: " as the thought indicator', () => { + const props = { + thought: { + subject: 'Thinking with fallback', + description: 'details', + }, + elapsedTime: 5, + }; + const { lastFrame, unmount } = renderWithContext( + , + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).toContain('Thinking: Thinking with fallback'); + unmount(); + }); + + it('should prioritize thought.subject over currentLoadingPhrase', () => { +>>>>>>> 3e1e540d7 (feat(cli): overhaul inline thinking UI to match mock and update status bar indicator) const props = { thought: { subject: 'This should be displayed', @@ -280,22 +301,31 @@ describe('', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('💬'); + expect(output).toContain('Thinking: '); expect(output).toContain('This should be displayed'); expect(output).not.toContain('This should not be displayed'); unmount(); }); +<<<<<<< HEAD it('should not display thought icon for non-thought loading phrases', async () => { const { lastFrame, unmount, waitUntilReady } = renderWithContext( +======= + it('should not display thought indicator for non-thought loading phrases', () => { + const { lastFrame, unmount } = renderWithContext( +>>>>>>> 3e1e540d7 (feat(cli): overhaul inline thinking UI to match mock and update status bar indicator) , StreamingState.Responding, ); +<<<<<<< HEAD await waitUntilReady(); expect(lastFrame()).not.toContain('💬'); +======= + expect(lastFrame()).not.toContain('Thinking: '); +>>>>>>> 3e1e540d7 (feat(cli): overhaul inline thinking UI to match mock and update status bar indicator) unmount(); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 2d603ebbdd..de4db32391 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -58,7 +58,7 @@ export const LoadingIndicator: React.FC = ({ const hasThoughtIndicator = currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE && Boolean(thought?.subject?.trim()); - const thinkingIndicator = hasThoughtIndicator ? '💬 ' : ''; + const thinkingIndicator = hasThoughtIndicator ? 'Thinking: ' : ''; const cancelAndTimerContent = showCancelAndTimer && diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index fbcc962663..e36343161e 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -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 ( { isPending={false} commands={uiState.slashCommands} isExpandable={isExpandable} + isFirstThinking={isFirstThinking} + isLastThinking={isLastThinking} /> ); }), @@ -105,18 +115,32 @@ export const MainContent = () => { const pendingItems = useMemo( () => ( - {pendingHistoryItems.map((item, i) => ( - - ))} + {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 ( + + ); + })} {showConfirmationQueue && 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 { diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index a27923c014..5617a3b336 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -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( , ); 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( , ); 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( , ); 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( - , + , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 86882307e7..010cef46ca 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -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 = ({ 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 = ( + + │ + + ); + return ( - - {summary && ( - - - {summary} + + {isFirstThinking && ( + <> + + {' '} + Thinking... + + + {verticalLine} + + + + )} + + {!isFirstThinking && ( + + + {verticalLine} + )} - {body && ( - - - {body} - + + {fullSummaryDisplayLines.map((line, index) => ( + + + {verticalLine} + + + {line} + + - )} + ))} + {fullBodyDisplayLines.map((line, index) => ( + + + {verticalLine} + + + {line} + + + + ))} ); }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 36374a5e20..5111bc6bb5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -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],