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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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,
+ });
}
}
}