diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index ced4d3497f..f52d887a79 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -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 () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 849187ce64..ed9d09791e 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -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} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index f8c251fbfa..3cc6e06a9f 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -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('', () => { 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( + , + { + 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, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index d3d7ea4596..da53b66659 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -47,6 +47,8 @@ interface HistoryItemDisplayProps { commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; isExpandable?: boolean; + isFirstThinking?: boolean; + isLastThinking?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -57,6 +59,8 @@ export const HistoryItemDisplay: React.FC = ({ commands, availableTerminalHeightGemini, isExpandable, + isFirstThinking = false, + isLastThinking = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -71,7 +75,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 3cb671242b..9f037a572d 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -258,13 +258,32 @@ describe('', () => { 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( + , + 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('', () => { ); 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( ', () => { StreamingState.Responding, ); await waitUntilReady(); - expect(lastFrame()).toContain(''); // Replaced emoji expectation + expect(lastFrame()).not.toContain('Thinking... '); unmount(); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 32e69f6496..baea93b04a 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -67,7 +67,16 @@ export const LoadingIndicator: React.FC = ({ (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 && 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/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index 0a543357d4..6841294eda 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -389,7 +389,8 @@ exports[` > renders InfoMessage for "info" type with multi `; exports[` > thinking items > renders thinking item when enabled 1`] = ` -" Thinking -│ test +" │ + │ Thinking + │ test " `; 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 7ff8c8a646..3cdc3e5bbf 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 = 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 = ({ 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/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap index 9f8ae44a70..0651018957 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap @@ -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 " `; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index df8c17bd23..071352b5ad 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2793,7 +2793,6 @@ describe('useGeminiStream', () => { type: 'thinking', thought: expect.objectContaining({ subject: 'Full thought' }), }), - expect.any(Number), ); }); 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],