From e5d58c2b5ab2460ffb91e8681fd31b91de909632 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Fri, 6 Mar 2026 20:20:27 -0800 Subject: [PATCH] feat(cli): overhaul thinking UI (#18725) --- .../cli/src/ui/components/Composer.test.tsx | 4 +- packages/cli/src/ui/components/Composer.tsx | 6 +- .../ui/components/HistoryItemDisplay.test.tsx | 20 +++ .../src/ui/components/HistoryItemDisplay.tsx | 20 ++- .../ui/components/LoadingIndicator.test.tsx | 27 +++- .../src/ui/components/LoadingIndicator.tsx | 6 +- .../src/ui/components/MainContent.test.tsx | 81 ++++++++++- .../cli/src/ui/components/MainContent.tsx | 95 +++++++++---- .../src/ui/components/StatusDisplay.test.tsx | 2 +- .../__snapshots__/AskUserDialog.test.tsx.snap | 28 ---- .../HistoryItemDisplay.test.tsx.snap | 13 +- ...g-messages-sequentially-correctly.snap.svg | 42 ++++++ .../__snapshots__/MainContent.test.tsx.snap | 43 ++++++ .../messages/ThinkingMessage.test.tsx | 126 ++++++++++++++---- .../components/messages/ThinkingMessage.tsx | 101 +++++++------- ...normalizes-escaped-newline-tokens.snap.svg | 14 ++ ...ader-when-isFirstThinking-is-true.snap.svg | 14 ++ ...de-with-left-border-and-full-text.snap.svg | 14 ++ ...g-messages-sequentially-correctly.snap.svg | 30 +++++ ...vertical-rule-and-Thinking-header.snap.svg | 14 ++ ...description-when-subject-is-empty.snap.svg | 12 ++ .../ThinkingMessage.test.tsx.snap | 99 ++++++++++++-- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 - packages/cli/src/ui/hooks/useGeminiStream.ts | 13 +- .../src/ui/hooks/useSessionBrowser.test.ts | 31 +++++ packages/cli/src/ui/utils/textUtils.test.ts | 6 +- packages/cli/src/utils/sessionUtils.ts | 13 ++ packages/core/src/utils/sessionUtils.test.ts | 37 +++++ packages/core/src/utils/sessionUtils.ts | 35 +++-- 29 files changed, 763 insertions(+), 184 deletions(-) create mode 100644 packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 999b1531f9..9a6155da00 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -374,7 +374,7 @@ describe('Composer', () => { 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...'); }); 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..d30f52dddf 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -239,7 +239,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -282,7 +282,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -390,7 +390,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { marginTop={ (showApprovalIndicator || uiState.shellModeActive) && - isNarrow + !isNarrow ? 1 : 0 } diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index f8c251fbfa..a574a9f311 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -290,6 +290,26 @@ 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(); + + expect(lastFrame()).toContain(' Thinking...'); + expect(lastFrame()).toMatchSnapshot(); + 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 f40dcf9dc9..9c8d90cd19 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -46,6 +46,8 @@ interface HistoryItemDisplayProps { commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; isExpandable?: boolean; + isFirstThinking?: boolean; + isFirstAfterThinking?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -56,16 +58,30 @@ export const HistoryItemDisplay: React.FC = ({ commands, availableTerminalHeightGemini, isExpandable, + isFirstThinking = false, + isFirstAfterThinking = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); + const needsTopMarginAfterThinking = + isFirstAfterThinking && inlineThinkingMode !== 'off'; + return ( - + {/* 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..4c4e3053ef 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('๐Ÿ’ฌ'); + // Should NOT contain "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('๐Ÿ’ฌ'); + 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()).not.toContain('๐Ÿ’ฌ'); + 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 f9fff9fa9b..eba0a7d8a3 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -58,7 +58,11 @@ export const LoadingIndicator: React.FC = ({ const hasThoughtIndicator = currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE && Boolean(thought?.subject?.trim()); - const thinkingIndicator = hasThoughtIndicator ? '๐Ÿ’ฌ ' : ''; + // 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.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 5ca3cbce31..e0880e624c 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -22,17 +22,19 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core'; import { type IndividualToolCallDisplay } from '../types.js'; // Mock dependencies +const mockUseSettings = vi.fn().mockReturnValue({ + merged: { + ui: { + inlineThinkingMode: 'off', + }, + }, +}); + vi.mock('../contexts/SettingsContext.js', async () => { const actual = await vi.importActual('../contexts/SettingsContext.js'); return { ...actual, - useSettings: () => ({ - merged: { - ui: { - inlineThinkingMode: 'off', - }, - }, - }), + useSettings: () => mockUseSettings(), }; }); @@ -333,6 +335,13 @@ describe('MainContent', () => { beforeEach(() => { vi.mocked(useAlternateBuffer).mockReturnValue(false); + mockUseSettings.mockReturnValue({ + merged: { + ui: { + inlineThinkingMode: 'off', + }, + }, + }); }); afterEach(() => { @@ -570,6 +579,64 @@ describe('MainContent', () => { unmount(); }); + it('renders multiple thinking messages sequentially correctly', async () => { + mockUseSettings.mockReturnValue({ + merged: { + ui: { + inlineThinkingMode: 'expanded', + }, + }, + }); + vi.mocked(useAlternateBuffer).mockReturnValue(true); + + const uiState = { + ...defaultMockUiState, + history: [ + { id: 0, type: 'user' as const, text: 'Plan a solution' }, + { + id: 1, + type: 'thinking' as const, + thought: { + subject: 'Initial analysis', + description: + 'This is a multiple line paragraph for the first thinking message of how the model analyzes the problem.', + }, + }, + { + id: 2, + type: 'thinking' as const, + thought: { + subject: 'Planning execution', + description: + 'This a second multiple line paragraph for the second thinking message explaining the plan in detail so that it wraps around the terminal display.', + }, + }, + { + id: 3, + type: 'thinking' as const, + thought: { + subject: 'Refining approach', + description: + 'And finally a third multiple line paragraph for the third thinking message to refine the solution.', + }, + }, + ], + }; + + const renderResult = renderWithProviders(, { + uiState: uiState as Partial, + }); + await renderResult.waitUntilReady(); + + const output = renderResult.lastFrame(); + expect(output).toContain('Initial analysis'); + expect(output).toContain('Planning execution'); + expect(output).toContain('Refining approach'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); + }); + describe('MainContent Tool Output Height Logic', () => { const testCases = [ { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 7386a246e7..d7e04bd351 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -62,11 +62,31 @@ export const MainContent = () => { return -1; }, [uiState.history]); + const augmentedHistory = useMemo( + () => + uiState.history.map((item, index) => { + const isExpandable = index > lastUserPromptIndex; + const prevType = + index > 0 ? uiState.history[index - 1]?.type : undefined; + const isFirstThinking = + item.type === 'thinking' && prevType !== 'thinking'; + const isFirstAfterThinking = + item.type !== 'thinking' && prevType === 'thinking'; + + return { + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + }; + }), + [uiState.history, lastUserPromptIndex], + ); + const historyItems = useMemo( () => - uiState.history.map((h, index) => { - const isExpandable = index > lastUserPromptIndex; - return ( + augmentedHistory.map( + ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ( { : undefined } availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES} - key={h.id} - item={h} + key={item.id} + item={item} isPending={false} commands={uiState.slashCommands} isExpandable={isExpandable} + isFirstThinking={isFirstThinking} + isFirstAfterThinking={isFirstAfterThinking} /> - ); - }), + ), + ), [ - uiState.history, + augmentedHistory, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, uiState.constrainHeight, - lastUserPromptIndex, ], ); @@ -106,18 +127,31 @@ export const MainContent = () => { const pendingItems = useMemo( () => ( - {pendingHistoryItems.map((item, i) => ( - - ))} + {pendingHistoryItems.map((item, i) => { + const prevType = + i === 0 + ? uiState.history.at(-1)?.type + : pendingHistoryItems[i - 1]?.type; + const isFirstThinking = + item.type === 'thinking' && prevType !== 'thinking'; + const isFirstAfterThinking = + item.type !== 'thinking' && prevType === 'thinking'; + + return ( + + ); + })} {showConfirmationQueue && confirmingTool && ( )} @@ -130,20 +164,25 @@ 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, - })), + ...augmentedHistory.map( + ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({ + type: 'history' as const, + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + }), + ), { type: 'pending' as const }, ], - [uiState.history, lastUserPromptIndex], + [augmentedHistory], ); const renderItem = useCallback( @@ -171,6 +210,8 @@ export const MainContent = () => { isPending={false} commands={uiState.slashCommands} isExpandable={item.isExpandable} + isFirstThinking={item.isFirstThinking} + isFirstAfterThinking={item.isFirstAfterThinking} /> ); } else { diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 4e0402820f..fcb66ea0b2 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render } from '../../test-utils/render.js'; import { Text } from 'ink'; import { StatusDisplay } from './StatusDisplay.js'; diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 2e115ef12c..06f509f1f6 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -115,20 +115,6 @@ Review your answers: Tests โ†’ (not answered) Docs โ†’ (not answered) -Enter to submit ยท / to edit answers ยท Esc to cancel -" -`; - -exports[`AskUserDialog > allows navigating to Review tab and back 2`] = ` -"โ† โ–ก Tests โ”‚ โ–ก Docs โ”‚ โ‰ก Review โ†’ - -Review your answers: - -โš  You have 2 unanswered questions - -Tests โ†’ (not answered) -Docs โ†’ (not answered) - Enter to submit ยท Tab/Shift+Tab to edit answers ยท Esc to cancel " `; @@ -212,20 +198,6 @@ Review your answers: License โ†’ (not answered) README โ†’ (not answered) -Enter to submit ยท / to edit answers ยท Esc to cancel -" -`; - -exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = ` -"โ† โ–ก License โ”‚ โ–ก README โ”‚ โ‰ก Review โ†’ - -Review your answers: - -โš  You have 2 unanswered questions - -License โ†’ (not answered) -README โ†’ (not answered) - Enter to submit ยท Tab/Shift+Tab to edit answers ยท Esc to cancel " `; 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 b1784dc10d..d237b30f99 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -388,8 +388,17 @@ exports[` > renders InfoMessage for "info" type with multi " `; -exports[` > thinking items > renders thinking item when enabled 1`] = ` -" Thinking +exports[` > thinking items > renders "Thinking..." header when isFirstThinking is true 1`] = ` +" Thinking... + โ”‚ + โ”‚ Thinking + โ”‚ test +" +`; + +exports[` > thinking items > renders thinking item when enabled 1`] = ` +" โ”‚ + โ”‚ Thinking โ”‚ test " `; diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg new file mode 100644 index 0000000000..558118cdfb --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg @@ -0,0 +1,42 @@ + + + + + ScrollableList + AppHeader(full) + + โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + + + > + + Plan a solution + + + โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + Thinking... + โ”‚ + โ”‚ + Initial analysis + โ”‚ + This is a multiple line paragraph for the first thinking message of how the model analyzes the + โ”‚ + problem. + โ”‚ + โ”‚ + Planning execution + โ”‚ + This a second multiple line paragraph for the second thinking message explaining the plan in + โ”‚ + detail so that it wraps around the terminal display. + โ”‚ + โ”‚ + Refining approach + โ”‚ + And finally a third multiple line paragraph for the third thinking message to refine the + โ”‚ + solution. + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 5f0c073d7a..74acc6985d 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -151,3 +151,46 @@ AppHeader(full) Gemini message 2 " `; + +exports[`MainContent > renders multiple thinking messages sequentially correctly 1`] = ` +"ScrollableList +AppHeader(full) +โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > Plan a solution +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the model analyzes the + โ”‚ problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message explaining the plan in + โ”‚ detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to refine the + โ”‚ solution. +" +`; + +exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = ` +"ScrollableList +AppHeader(full) +โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > Plan a solution +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the model analyzes the + โ”‚ problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message explaining the plan in + โ”‚ detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to refine the + โ”‚ solution." +`; diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx index a27923c014..1499d285f7 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx @@ -7,84 +7,156 @@ import { describe, it, expect } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; import { ThinkingMessage } from './ThinkingMessage.js'; +import React from 'react'; describe('ThinkingMessage', () => { - it('renders subject line', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + it('renders subject line with vertical rule and "Thinking..." header', async () => { + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain(' Thinking...'); + expect(output).toContain('โ”‚'); + expect(output).toContain('Planning'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('uses description when subject is empty', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain('Processing details'); + expect(output).toContain('โ”‚'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('renders full mode with left border and full text', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain('โ”‚'); + expect(output).toContain('Planning'); + expect(output).toContain('I am planning the solution.'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); - it('indents summary line correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + it('renders "Thinking..." header when isFirstThinking is true', async () => { + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + const output = renderResult.lastFrame(); + expect(output).toContain(' Thinking...'); + expect(output).toContain('Summary line'); + expect(output).toContain('โ”‚'); + expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('normalizes escaped newline tokens', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + expect(renderResult.lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('renders empty state gracefully', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , + const renderResult = renderWithProviders( + , ); - await waitUntilReady(); + await renderResult.waitUntilReady(); - expect(lastFrame({ allowEmpty: true })).toBe(''); - unmount(); + expect(renderResult.lastFrame({ allowEmpty: true })).toBe(''); + renderResult.unmount(); + }); + + it('renders multiple thinking messages sequentially correctly', async () => { + const renderResult = renderWithProviders( + + + + + , + ); + await renderResult.waitUntilReady(); + + expect(renderResult.lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); }); diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx index 86882307e7..9591989774 100644 --- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx +++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx @@ -13,6 +13,30 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js'; interface ThinkingMessageProps { thought: ThoughtSummary; + terminalWidth: number; + isFirstThinking?: boolean; +} + +const THINKING_LEFT_PADDING = 1; + +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'); + } + + if (!description) { + return [subject]; + } + + const bodyLines = description.split('\n'); + return [subject, ...bodyLines]; } /** @@ -21,60 +45,47 @@ interface ThinkingMessageProps { */ export const ThinkingMessage: React.FC = ({ thought, + terminalWidth, + isFirstThinking, }) => { - const { summary, body } = useMemo(() => { - const subject = normalizeEscapedNewlines(thought.subject).trim(); - const description = normalizeEscapedNewlines(thought.description).trim(); + const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]); - 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) { + if (fullLines.length === 0) { return null; } return ( - - {summary && ( - + + {isFirstThinking && ( + + {' '} + Thinking...{' '} + + )} + + + + {fullLines.length > 0 && ( - {summary} + {fullLines[0]} - - )} - {body && ( - - - {body} + )} + {fullLines.slice(1).map((line, index) => ( + + {line} - - )} + ))} + ); }; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg new file mode 100644 index 0000000000..660d8b4fa1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Matching the Blocks + โ”‚ + Some more text + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg new file mode 100644 index 0000000000..38647281df --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Summary line + โ”‚ + First body line + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg new file mode 100644 index 0000000000..0294b63f30 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Planning + โ”‚ + I am planning the solution. + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg new file mode 100644 index 0000000000..b7f8a52358 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg @@ -0,0 +1,30 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Initial analysis + โ”‚ + This is a multiple line paragraph for the first thinking message of how the + โ”‚ + model analyzes the problem. + โ”‚ + โ”‚ + Planning execution + โ”‚ + This a second multiple line paragraph for the second thinking message + โ”‚ + explaining the plan in detail so that it wraps around the terminal display. + โ”‚ + โ”‚ + Refining approach + โ”‚ + And finally a third multiple line paragraph for the third thinking message to + โ”‚ + refine the solution. + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg new file mode 100644 index 0000000000..350a0cc61f --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg @@ -0,0 +1,14 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Planning + โ”‚ + test + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg new file mode 100644 index 0000000000..ce2b2a4686 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg @@ -0,0 +1,12 @@ + + + + + Thinking... + โ”‚ + โ”‚ + Processing details + + \ No newline at end of file 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 365f655d7d..da33a2a14c 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,107 @@ // 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 +" Thinking... + โ”‚ + โ”‚ Matching the Blocks โ”‚ Some more text " `; +exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = ` +" Thinking... + โ”‚ + โ”‚ Matching the Blocks + โ”‚ Some more text" +`; + +exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = ` +" Thinking... + โ”‚ + โ”‚ Summary line + โ”‚ First body line +" +`; + +exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 2`] = ` +" Thinking... + โ”‚ + โ”‚ Summary line + โ”‚ First body line" +`; + exports[`ThinkingMessage > renders full mode with left border and full text 1`] = ` -" Planning +" Thinking... + โ”‚ + โ”‚ Planning โ”‚ I am planning the solution. " `; -exports[`ThinkingMessage > renders subject line 1`] = ` -" Planning +exports[`ThinkingMessage > renders full mode with left border and full text 2`] = ` +" Thinking... + โ”‚ + โ”‚ Planning + โ”‚ I am planning the solution." +`; + +exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = ` +" Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the + โ”‚ model analyzes the problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message + โ”‚ explaining the plan in detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to + โ”‚ refine the solution. +" +`; + +exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 2`] = ` +" Thinking... + โ”‚ + โ”‚ Initial analysis + โ”‚ This is a multiple line paragraph for the first thinking message of how the + โ”‚ model analyzes the problem. + โ”‚ + โ”‚ Planning execution + โ”‚ This a second multiple line paragraph for the second thinking message + โ”‚ explaining the plan in detail so that it wraps around the terminal display. + โ”‚ + โ”‚ Refining approach + โ”‚ And finally a third multiple line paragraph for the third thinking message to + โ”‚ refine the solution." +`; + +exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = ` +" Thinking... + โ”‚ + โ”‚ Planning โ”‚ test " `; +exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 2`] = ` +" Thinking... + โ”‚ + โ”‚ Planning + โ”‚ test" +`; + exports[`ThinkingMessage > uses description when subject is empty 1`] = ` -" Processing details +" Thinking... + โ”‚ + โ”‚ Processing details " `; + +exports[`ThinkingMessage > uses description when subject is empty 2`] = ` +" Thinking... + โ”‚ + โ”‚ Processing details" +`; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index cfffb28196..1f2ef5f90c 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2824,7 +2824,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 b0b4f553a2..d254902a94 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -905,17 +905,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], diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index ceff3e9c8c..d356def6a9 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -190,6 +190,37 @@ describe('convertSessionToHistoryFormats', () => { }); }); + it('should convert thinking tokens (thoughts) to thinking history items', () => { + const messages: MessageRecord[] = [ + { + type: 'gemini', + content: 'Hi there', + thoughts: [ + { + subject: 'Thinking...', + description: 'I should say hello.', + timestamp: new Date().toISOString(), + }, + ], + } as MessageRecord, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(2); + expect(result.uiHistory[0]).toMatchObject({ + type: 'thinking', + thought: { + subject: 'Thinking...', + description: 'I should say hello.', + }, + }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'gemini', + text: 'Hi there', + }); + }); + it('should prioritize displayContent for UI history but use content for client history', () => { const messages: MessageRecord[] = [ { diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index fb0c9786ae..b06fa62f5e 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -48,12 +48,14 @@ describe('textUtils', () => { it('should handle unicode characters that crash string-width', () => { // U+0602 caused string-width to crash (see #16418) const char = 'ุ‚'; - expect(getCachedStringWidth(char)).toBe(0); + expect(() => getCachedStringWidth(char)).not.toThrow(); + expect(typeof getCachedStringWidth(char)).toBe('number'); }); it('should handle unicode characters that crash string-width with ANSI codes', () => { const charWithAnsi = '\u001b[31m' + 'ุ‚' + '\u001b[0m'; - expect(getCachedStringWidth(charWithAnsi)).toBe(0); + expect(() => getCachedStringWidth(charWithAnsi)).not.toThrow(); + expect(typeof getCachedStringWidth(charWithAnsi)).toBe('number'); }); }); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index ac6987f933..3aa0131ac2 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -535,6 +535,19 @@ export function convertSessionToHistoryFormats( const uiHistory: HistoryItemWithoutId[] = []; for (const msg of messages) { + // Add thoughts if present + if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + uiHistory.push({ + type: 'thinking', + thought: { + subject: thought.subject, + description: thought.description, + }, + }); + } + } + // Add the message only if it has content const displayContentString = msg.displayContent ? partListUnionToString(msg.displayContent) diff --git a/packages/core/src/utils/sessionUtils.test.ts b/packages/core/src/utils/sessionUtils.test.ts index 35f9462c11..d132087ee8 100644 --- a/packages/core/src/utils/sessionUtils.test.ts +++ b/packages/core/src/utils/sessionUtils.test.ts @@ -33,6 +33,43 @@ describe('convertSessionToClientHistory', () => { ]); }); + it('should convert thinking tokens (thoughts) to model parts', () => { + const messages: ConversationRecord['messages'] = [ + { + id: '1', + type: 'user', + timestamp: '2024-01-01T10:00:00Z', + content: 'Hello', + }, + { + id: '2', + type: 'gemini', + timestamp: '2024-01-01T10:01:00Z', + content: 'Hi there', + thoughts: [ + { + subject: 'Thinking', + description: 'I should be polite.', + timestamp: '2024-01-01T10:00:50Z', + }, + ], + }, + ]; + + const history = convertSessionToClientHistory(messages); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Hello' }] }, + { + role: 'model', + parts: [ + { text: '**Thinking** I should be polite.', thought: true }, + { text: 'Hi there' }, + ], + }, + ]); + }); + it('should ignore info, error, and slash commands', () => { const messages: ConversationRecord['messages'] = [ { diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index b20c853ff7..4803dd4f07 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -51,15 +51,24 @@ export function convertSessionToClientHistory( parts: ensurePartArray(msg.content), }); } else if (msg.type === 'gemini') { + const modelParts: Part[] = []; + + // Add thoughts if present + if (msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + const thoughtText = thought.subject + ? `**${thought.subject}** ${thought.description}` + : thought.description; + modelParts.push({ + text: thoughtText, + thought: true, + } as Part); + } + } + const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; if (hasToolCalls) { - const modelParts: Part[] = []; - - // TODO: Revisit if we should preserve more than just Part metadata (e.g. thoughtSignatures) - // currently those are only required within an active loop turn which resume clears - // by forcing a new user text prompt. - // Preserve original parts to maintain multimodal integrity if (msg.content) { modelParts.push(...ensurePartArray(msg.content)); @@ -114,14 +123,14 @@ export function convertSessionToClientHistory( } } else { if (msg.content) { - const parts = ensurePartArray(msg.content); + modelParts.push(...ensurePartArray(msg.content)); + } - if (parts.length > 0) { - clientHistory.push({ - role: 'model', - parts, - }); - } + if (modelParts.length > 0) { + clientHistory.push({ + role: 'model', + parts: modelParts, + }); } } }