diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx
index fdf149c1186..14c273547ef 100644
--- a/packages/cli/src/ui/components/Composer.test.tsx
+++ b/packages/cli/src/ui/components/Composer.test.tsx
@@ -36,9 +36,16 @@ import { StreamingState } from '../types.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
- LoadingIndicator: ({ thought }: { thought?: string }) => (
- LoadingIndicator{thought ? `: ${thought}` : ''}
- ),
+ LoadingIndicator: ({
+ currentLoadingPhrase,
+ }: {
+ currentLoadingPhrase?: string;
+ }) => (
+
+ LoadingIndicator
+ {currentLoadingPhrase ? `: ${currentLoadingPhrase}` : ''}
+
+ ),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -103,7 +110,7 @@ const createMockUIState = (overrides: Partial = {}): UIState =>
commandContext: null,
shellModeActive: false,
isFocused: true,
- thought: '',
+ thought: null,
currentLoadingPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
@@ -180,13 +187,9 @@ describe('Composer', () => {
});
describe('Loading Indicator', () => {
- it('renders LoadingIndicator with thought when streaming', () => {
+ it('renders LoadingIndicator with phrase when streaming', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
- thought: {
- subject: 'Processing',
- description: 'Processing your request...',
- },
currentLoadingPhrase: 'Analyzing',
elapsedTime: 1500,
});
@@ -195,22 +198,7 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
- });
-
- it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
- const uiState = createMockUIState({
- streamingState: StreamingState.Responding,
- thought: { subject: 'Hidden', description: 'Should not show' },
- });
- const config = createMockConfig({
- getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
- });
-
- const { lastFrame } = renderComposer(uiState, config);
-
- const output = lastFrame();
- expect(output).toContain('LoadingIndicator');
- expect(output).not.toContain('Should not show');
+ expect(output).toContain('LoadingIndicator: Analyzing');
});
// ─── Narrow-terminal suppression (suppressBottomLoadingIndicator) ───
@@ -293,20 +281,14 @@ describe('Composer', () => {
expect(lastFrame()).toContain('LoadingIndicator');
});
- it('suppresses thought when waiting for confirmation', () => {
+ it('renders LoadingIndicator during WaitingForConfirmation', () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
- thought: {
- subject: 'Confirmation',
- description: 'Should not show during confirmation',
- },
});
const { lastFrame } = renderComposer(uiState);
- const output = lastFrame();
- expect(output).toContain('LoadingIndicator');
- expect(output).not.toContain('Should not show during confirmation');
+ expect(lastFrame()).toContain('LoadingIndicator');
});
});
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 7b6933b76c4..1bd2a155e8d 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -97,12 +97,6 @@ export const Composer = () => {
= ({
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
)}
- {!compactMode && itemForDisplay.type === 'gemini_thought' && (
+ {/* TODO(follow-up): wire expanded={compactMode} once Ctrl+O is decoupled */}
+ {itemForDisplay.type === 'gemini_thought' && (
)}
- {!compactMode && itemForDisplay.type === 'gemini_thought_content' && (
+ {itemForDisplay.type === 'gemini_thought_content' && (
', () => {
expect(lastFrame()).toBe('');
});
- it('should display fallback phrase if thought is empty', () => {
- const props = {
- thought: null,
- currentLoadingPhrase: 'Loading...',
- elapsedTime: 5,
- };
- const { lastFrame } = renderWithContext(
- ,
- StreamingState.Responding,
- );
- const output = lastFrame();
- expect(output).toContain('Loading...');
- });
-
- it('should display the subject of a thought', () => {
- const props = {
- thought: {
- subject: 'Thinking about something...',
- description: 'and other stuff.',
- },
- elapsedTime: 5,
- };
- const { lastFrame } = renderWithContext(
- ,
- StreamingState.Responding,
- );
- const output = lastFrame();
- expect(output).toBeDefined();
- if (output) {
- expect(output).toContain('Thinking about something...');
- expect(output).not.toContain('and other stuff.');
- }
- });
-
- it('should prioritize thought.subject over currentLoadingPhrase', () => {
- const props = {
- thought: {
- subject: 'This should be displayed',
- description: 'A description',
- },
- currentLoadingPhrase: 'This should not be displayed',
- elapsedTime: 5,
- };
- const { lastFrame } = renderWithContext(
- ,
- StreamingState.Responding,
- );
- const output = lastFrame();
- expect(output).toContain('This should be displayed');
- expect(output).not.toContain('This should not be displayed');
- });
-
it('should truncate long primary text instead of wrapping', () => {
const { lastFrame } = renderWithContext(
= ({
currentLoadingPhrase,
elapsedTime,
rightContent,
- thought,
candidatesTokens,
streamingCharsRef,
isStreaming,
@@ -69,7 +66,10 @@ export const LoadingIndicator: React.FC = ({
return null;
}
- const primaryText = thought?.subject || currentLoadingPhrase;
+ // The spinner row shows status only: phrase, timer, token estimate, and the
+ // cancel affordance. Model reasoning lives in the collapsible thinking block
+ // in history, not here.
+ const primaryText = currentLoadingPhrase;
const streamingTokens = streamingCharsRef ? Math.round(animatedChars / 4) : 0;
const outputTokens = (candidatesTokens ?? 0) + streamingTokens;
diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx
new file mode 100644
index 00000000000..86ffaa167fe
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { ThinkMessage, ThinkMessageContent } from './ConversationMessages.js';
+
+describe('', () => {
+ const defaultProps = {
+ text: 'Analyzing the code structure',
+ contentWidth: 80,
+ };
+
+ it('should render content when pending (streaming)', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Thinking');
+ expect(output).not.toContain('ctrl+o to expand');
+ });
+
+ it('should render collapsed line when committed and not expanded', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Thinking');
+ expect(output).not.toContain('ctrl+o to expand');
+ expect(output).not.toContain('Analyzing the code structure');
+ });
+
+ it('should render full text when committed and expanded', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Analyzing the code structure');
+ });
+
+ it('should default to collapsed when expanded is omitted', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).not.toContain('ctrl+o to expand');
+ expect(output).not.toContain('Analyzing the code structure');
+ });
+
+ it('should show past-tense duration when collapsed', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Thought for');
+ expect(output).toContain('15s');
+ expect(output).not.toContain('ctrl+o to expand');
+ });
+
+ it('should show present-tense duration while pending (streaming)', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Thinking');
+ expect(output).toContain('8s');
+ expect(output).not.toContain('Thought for');
+ });
+
+ it('should format minutes and seconds for long durations', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Thought for');
+ expect(output).toContain('2m 5s');
+ });
+});
+
+describe('', () => {
+ const defaultProps = {
+ text: 'Continuation of the reasoning',
+ contentWidth: 80,
+ };
+
+ it('should render when pending (streaming)', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).not.toBe('');
+ });
+
+ it('should render nothing when committed and not expanded', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ it('should render when committed and expanded', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('Continuation of the reasoning');
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx
index d81cbde4b6d..6d221955776 100644
--- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx
+++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx
@@ -21,6 +21,8 @@ import {
subtleBandColor,
supportsTrueColor,
} from '../../themes/color-utils.js';
+import { t } from '../../../i18n/index.js';
+import { getCachedStringWidth } from '../../utils/textUtils.js';
interface UserMessageProps {
text: string;
@@ -50,13 +52,17 @@ interface AssistantMessageContentProps {
interface ThinkMessageProps {
text: string;
isPending: boolean;
+ /** When committed (not pending), whether to show the full reasoning. */
+ expanded?: boolean;
availableTerminalHeight?: number;
contentWidth: number;
+ durationMs?: number;
}
interface ThinkMessageContentProps {
text: string;
isPending: boolean;
+ expanded?: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
@@ -303,35 +309,183 @@ export const AssistantMessageContent: React.FC<
/>
);
+const MAX_STREAMING_THINKING_VISUAL_LINES = 4;
+
+function wrapToVisualLines(text: string, width: number): string[] {
+ if (width <= 0) {
+ return [''];
+ }
+ const visualLines: string[] = [];
+ for (const logicalLine of text.split('\n')) {
+ if (logicalLine === '') {
+ visualLines.push('');
+ continue;
+ }
+ let currentLine = '';
+ let currentWidth = 0;
+ for (const char of logicalLine) {
+ const charWidth = getCachedStringWidth(char);
+ if (currentWidth + charWidth > width && currentWidth > 0) {
+ visualLines.push(currentLine);
+ currentLine = '';
+ currentWidth = 0;
+ }
+ currentLine += char;
+ currentWidth += charWidth;
+ }
+ if (currentLine) {
+ visualLines.push(currentLine);
+ }
+ }
+ if (visualLines.length === 0) {
+ visualLines.push('');
+ }
+ return visualLines;
+}
+
+function tailVisualLines(
+ text: string,
+ width: number,
+ maxLines: number,
+): string {
+ const charBudget = maxLines * width * 2;
+ let sliceStart = Math.max(0, text.length - charBudget);
+ if (sliceStart > 0) {
+ const nl = text.indexOf('\n', sliceStart);
+ if (nl !== -1 && nl < text.length - 1) {
+ sliceStart = nl + 1;
+ }
+ }
+ const lines = wrapToVisualLines(text.slice(sliceStart), width);
+ return lines.slice(-maxLines).join('\n');
+}
+
+function formatDuration(ms: number): string {
+ const totalSeconds = Math.round(ms / 1000);
+ if (totalSeconds < 60) {
+ return `${totalSeconds}s`;
+ }
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
+}
+
export const ThinkMessage: React.FC = ({
text,
isPending,
+ expanded = false,
availableTerminalHeight,
contentWidth,
-}) => (
-
-);
+ durationMs,
+}) => {
+ const durationSuffix =
+ durationMs != null ? ` ${formatDuration(durationMs)}` : '';
+
+ if (!isPending && !expanded) {
+ const label =
+ durationMs != null
+ ? `${t('Thought for')} ${formatDuration(durationMs)}`
+ : t('Thinking');
+ // TODO(follow-up): restore "(ctrl+o to expand)" hint once Ctrl+O is
+ // decoupled from compactMode so it can toggle thinking blocks independently.
+ return (
+
+ {label}
+
+ );
+ }
+
+ if (isPending) {
+ const innerWidth = Math.max(contentWidth - 2, 20);
+ const maxLines =
+ availableTerminalHeight != null
+ ? Math.max(
+ 1,
+ Math.min(
+ MAX_STREAMING_THINKING_VISUAL_LINES,
+ Math.floor(availableTerminalHeight / 3),
+ ),
+ )
+ : MAX_STREAMING_THINKING_VISUAL_LINES;
+ const display = tailVisualLines(text, innerWidth, maxLines);
+ return (
+
+
+ ⟡ {t('Thinking')}…{durationSuffix}
+
+
+
+ {display}
+
+
+
+ );
+ }
+
+ const expandedLabel =
+ durationMs != null
+ ? `${t('Thought for')} ${formatDuration(durationMs)}`
+ : `${t('Thinking')}…`;
+ return (
+
+
+ {expandedLabel}
+
+
+
+
+
+ );
+};
export const ThinkMessageContent: React.FC = ({
text,
isPending,
+ expanded = false,
availableTerminalHeight,
contentWidth,
-}) => (
-
-);
+}) => {
+ if (!isPending && !expanded) {
+ return null;
+ }
+
+ if (isPending) {
+ const innerWidth = Math.max(contentWidth - 2, 20);
+ const maxLines =
+ availableTerminalHeight != null
+ ? Math.max(
+ 1,
+ Math.min(
+ MAX_STREAMING_THINKING_VISUAL_LINES,
+ Math.floor(availableTerminalHeight / 3),
+ ),
+ )
+ : MAX_STREAMING_THINKING_VISUAL_LINES;
+ const display = tailVisualLines(text, innerWidth, maxLines);
+ return (
+
+
+ {display}
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts b/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts
index 098d606999c..7029c2d97df 100644
--- a/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts
+++ b/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts
@@ -122,7 +122,7 @@ async function waitFor(assertion: () => void): Promise {
}
describe('reduceDaemonEventToTuiUpdates', () => {
- it('maps assistant, thought, tool, model, and disconnect daemon events', () => {
+ it('maps assistant, tool, model, and disconnect daemon events while suppressing thought history', () => {
expect(
reduceDaemonEventToTuiUpdates({
id: 0,
@@ -172,13 +172,7 @@ describe('reduceDaemonEventToTuiUpdates', () => {
},
},
}),
- ).toEqual([
- {
- type: 'history',
- item: { type: 'gemini_thought_content', text: 'thinking' },
- daemonEventId: 2,
- },
- ]);
+ ).toEqual([]);
expect(
reduceDaemonEventToTuiUpdates({
diff --git a/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts b/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts
index d5aa3ddd3cc..1abc46cbf6e 100644
--- a/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts
+++ b/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts
@@ -487,17 +487,8 @@ export function reduceDaemonEventToTuiUpdates(
];
}
- if (sessionUpdate === 'agent_thought_chunk' && text) {
- return [
- {
- type: 'history',
- item: {
- type: 'gemini_thought_content',
- text: sanitizeDisplayText(text),
- },
- daemonEventId: event.id,
- },
- ];
+ if (sessionUpdate === 'agent_thought_chunk') {
+ return [];
}
if (
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 43a762bbd92..676f6c980f7 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -2321,7 +2321,7 @@ describe('useGeminiStream', () => {
expect(result.current.pendingHistoryItems).toEqual([
expect.objectContaining({
type: 'gemini_thought',
- text: 'Thinking',
+ durationMs: expect.any(Number),
}),
]);
expect(result.current.thought).toEqual({ description: 'Thinking' });
@@ -2392,7 +2392,7 @@ describe('useGeminiStream', () => {
expect(result.current.pendingHistoryItems).toEqual([
expect.objectContaining({
type: 'gemini_thought',
- text: 'Thinking',
+ durationMs: expect.any(Number),
}),
]);
expect(result.current.thought).toEqual({ description: 'Thinking' });
@@ -4255,6 +4255,11 @@ describe('useGeminiStream', () => {
});
it('should accumulate streamed thought descriptions', async () => {
+ let releaseStream!: () => void;
+ const holdStream = new Promise((resolve) => {
+ releaseStream = resolve;
+ });
+
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
@@ -4265,6 +4270,7 @@ describe('useGeminiStream', () => {
type: ServerGeminiEventType.Thought,
value: { subject: '', description: 'more' },
};
+ await holdStream;
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
@@ -4296,15 +4302,35 @@ describe('useGeminiStream', () => {
);
await act(async () => {
- await result.current.submitQuery('Streamed thought');
+ void result.current.submitQuery('Streamed thought');
+ await Promise.resolve();
+ await Promise.resolve();
});
await waitFor(() => {
expect(result.current.thought?.description).toBe('thinking more');
});
+ expect(result.current.pendingHistoryItems).toEqual([
+ expect.objectContaining({
+ type: 'gemini_thought',
+ durationMs: expect.any(Number),
+ }),
+ ]);
+
+ await act(async () => {
+ releaseStream();
+ await Promise.resolve();
+ });
+
+ await waitFor(() => expect(result.current.thought).toBeNull());
});
it('should render descriptions from subject-bearing thought chunks', async () => {
+ let releaseStream!: () => void;
+ const holdStream = new Promise((resolve) => {
+ releaseStream = resolve;
+ });
+
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
@@ -4321,6 +4347,7 @@ describe('useGeminiStream', () => {
description: ' user mentioned globally installed qwen,',
},
};
+ await holdStream;
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
@@ -4331,22 +4358,298 @@ describe('useGeminiStream', () => {
const { result } = renderTestHook();
await act(async () => {
- await result.current.submitQuery('Streamed thought');
+ void result.current.submitQuery('Streamed thought');
+ await Promise.resolve();
+ await Promise.resolve();
});
await waitFor(() => {
+ expect(result.current.thought).toEqual({
+ subject: 'Evaluating installation approach',
+ description: 'The user mentioned globally installed qwen,',
+ });
+ });
+
+ expect(mockAddItem).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: expect.stringMatching(/^gemini_thought/),
+ }),
+ expect.any(Number),
+ );
+ expect(result.current.pendingHistoryItems).toEqual([
+ expect.objectContaining({
+ type: 'gemini_thought',
+ durationMs: expect.any(Number),
+ }),
+ ]);
+
+ await act(async () => {
+ releaseStream();
+ await Promise.resolve();
+ });
+
+ await waitFor(() => expect(result.current.thought).toBeNull());
+ });
+
+ it('should commit thought to history with durationMs on Finished', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { subject: '', description: 'reasoning about the problem' },
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: { reason: 'STOP', usageMetadata: undefined },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ void result.current.submitQuery('think then finish');
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ await waitFor(() => expect(result.current.thought).toBeNull());
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'gemini_thought',
+ text: expect.stringContaining('reasoning about the problem'),
+ durationMs: expect.any(Number),
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should commit thought to history when Content arrives', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { subject: '', description: 'analyzing the question' },
+ };
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'The answer is 42',
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: { reason: 'STOP', usageMetadata: undefined },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ void result.current.submitQuery('think then answer');
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ await waitFor(() =>
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini_thought',
- text: 'The user mentioned globally installed qwen,',
+ text: expect.stringContaining('analyzing the question'),
+ durationMs: expect.any(Number),
}),
expect.any(Number),
- );
+ ),
+ );
+
+ // Content should also be committed
+ await waitFor(() =>
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'gemini',
+ text: expect.stringContaining('The answer is 42'),
+ }),
+ expect.any(Number),
+ ),
+ );
+ });
+
+ it('should commit thought to history on UserCancelled', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { subject: '', description: 'deep thinking' },
+ };
+ yield { type: ServerGeminiEventType.UserCancelled };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ await result.current.submitQuery('think then cancel');
+ });
+
+ await waitFor(() =>
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'gemini_thought',
+ text: expect.stringContaining('deep thinking'),
+ durationMs: expect.any(Number),
+ }),
+ expect.any(Number),
+ ),
+ );
+ });
+
+ it('should commit thought to history on Error', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { subject: '', description: 'thinking before error' },
+ };
+ yield {
+ type: ServerGeminiEventType.Error,
+ value: { message: 'Something went wrong', retryable: false },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ void result.current.submitQuery('think then error');
+ await Promise.resolve();
+ await Promise.resolve();
});
- expect(result.current.thought).toEqual({
- subject: 'Evaluating installation approach',
- description: 'The user mentioned globally installed qwen,',
+
+ await waitFor(() =>
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'gemini_thought',
+ text: expect.stringContaining('thinking before error'),
+ durationMs: expect.any(Number),
+ }),
+ expect.any(Number),
+ ),
+ );
+ });
+
+ it('should commit thought to history when ToolCallRequest arrives', async () => {
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { subject: '', description: 'planning tool usage' },
+ };
+ yield {
+ type: ServerGeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'tc1',
+ name: 'read_file',
+ args: { path: '/foo' },
+ isClientInitiated: false,
+ prompt_id: 'p1',
+ },
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: { reason: 'STOP', usageMetadata: undefined },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ void result.current.submitQuery('think then tool call');
+ await Promise.resolve();
+ await Promise.resolve();
});
+
+ await waitFor(() => expect(result.current.thought).toBeNull());
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'gemini_thought',
+ text: expect.stringContaining('planning tool usage'),
+ durationMs: expect.any(Number),
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should commit thought to history on non-continuation Retry', async () => {
+ vi.useFakeTimers();
+ try {
+ let emitRetry: (() => void) | undefined;
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.Thought,
+ value: { subject: '', description: 'reasoning before retry' },
+ };
+ // Wait for the buffered thought to be flushed to state before
+ // the Retry event discards remaining buffered events.
+ await new Promise((resolve) => {
+ emitRetry = resolve;
+ });
+ yield {
+ type: ServerGeminiEventType.Retry,
+ isContinuation: false,
+ };
+ yield {
+ type: ServerGeminiEventType.Content,
+ value: 'retried response',
+ };
+ yield {
+ type: ServerGeminiEventType.Finished,
+ value: { reason: 'STOP', usageMetadata: undefined },
+ };
+ })(),
+ );
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ void result.current.submitQuery('think then retry');
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ // Advance past STREAM_UPDATE_THROTTLE_MS (60ms) so the thought
+ // buffer flushes and populates pendingThoughtItem state.
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ await Promise.resolve();
+ });
+
+ // Now emit the Retry event; commitPendingThought should find the
+ // flushed thought in pendingThoughtItemRef.
+ await act(async () => {
+ emitRetry?.();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ await Promise.resolve();
+ });
+
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'gemini_thought',
+ text: expect.stringContaining('reasoning before retry'),
+ durationMs: expect.any(Number),
+ }),
+ expect.any(Number),
+ );
+ } finally {
+ vi.useRealTimers();
+ }
});
it('should show a retry countdown and update pending history over time', async () => {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 5a65db7f4e4..918d331e1c2 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -366,6 +366,12 @@ export const useGeminiStream = (
const summaryAbortRefsRef = useRef>(new Set());
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef(null);
+ // Streamed model reasoning for the current turn. Rendered (height-limited)
+ // above the answer while thinking, then committed to history as a
+ // collapsible `gemini_thought` block when the answer/tool/turn begins.
+ const [pendingThoughtItem, pendingThoughtItemRef, setPendingThoughtItem] =
+ useStateAndRef(null);
+ const thoughtStartTimeRef = useRef(null);
const [
pendingRetryErrorItem,
pendingRetryErrorItemRef,
@@ -979,93 +985,67 @@ export const useGeminiStream = (
);
const handleThoughtEvent = useCallback(
- (
- eventValue: ThoughtSummary,
- currentThoughtBuffer: string,
- userMessageTimestamp: number,
- ): string => {
+ (eventValue: ThoughtSummary, currentThoughtBuffer: string): string => {
if (turnCancelledRef.current) {
return '';
}
- // Extract the description text from the thought summary
const thoughtText = eventValue.description ?? '';
if (!thoughtText) {
return currentThoughtBuffer;
}
- let newThoughtBuffer = currentThoughtBuffer + thoughtText;
-
- if (debugLogger.isEnabled()) {
- debugLogger.debug(
- `[THOUGHT_BUFFER] Buffer growing: ` +
- `current=${currentThoughtBuffer.length}, ` +
- `incoming=${thoughtText.length}, ` +
- `total=${newThoughtBuffer.length}`,
- );
+ const newThoughtBuffer = currentThoughtBuffer + thoughtText;
+ if (newThoughtBuffer.trim().length === 0) {
+ return newThoughtBuffer;
}
- const pendingType = pendingHistoryItemRef.current?.type;
- const isPendingThought =
- pendingType === 'gemini_thought' ||
- pendingType === 'gemini_thought_content';
- let thoughtToMerge = eventValue;
+ const startingNewThought = currentThoughtBuffer.trim().length === 0;
+ const description = startingNewThought
+ ? stripLeadingBlankLines(newThoughtBuffer)
+ : thoughtText;
- // If we're not already showing a thought, start a new one
- if (!isPendingThought) {
- if (newThoughtBuffer.trim().length === 0) {
- return newThoughtBuffer;
- }
- // If there's a pending non-thought item, finalize it first
- if (pendingHistoryItemRef.current) {
- addItem(pendingHistoryItemRef.current, userMessageTimestamp);
- }
- newThoughtBuffer = stripLeadingBlankLines(newThoughtBuffer);
- thoughtToMerge = {
- ...eventValue,
- description: newThoughtBuffer,
- };
- setPendingHistoryItem({ type: 'gemini_thought', text: '' });
+ if (startingNewThought) {
+ thoughtStartTimeRef.current = Date.now();
}
- // Split large thought messages for better rendering performance (same rationale
- // as regular content streaming). This helps avoid terminal flicker caused by
- // constantly re-rendering an ever-growing "pending" block.
- const splitPoint = findLastSafeSplitPoint(newThoughtBuffer);
- const nextPendingType: 'gemini_thought' | 'gemini_thought_content' =
- isPendingThought && pendingType === 'gemini_thought_content'
- ? 'gemini_thought_content'
- : 'gemini_thought';
-
- if (splitPoint === newThoughtBuffer.length) {
- // Update the existing thought message with accumulated content
- setPendingHistoryItem({
- type: nextPendingType,
- text: newThoughtBuffer,
- });
- } else {
- const beforeText = newThoughtBuffer.substring(0, splitPoint);
- const afterText = newThoughtBuffer.substring(splitPoint);
- addItem(
- {
- type: nextPendingType,
- text: beforeText,
- },
- userMessageTimestamp,
- );
- setPendingHistoryItem({
- type: 'gemini_thought_content',
- text: afterText,
- });
- newThoughtBuffer = afterText;
- }
+ // Keep the transient `thought` (subject) in sync for the window title.
+ mergeThought({
+ ...eventValue,
+ description,
+ });
- // Also update the thought state for the loading indicator
- mergeThought(thoughtToMerge);
+ // Stream the accumulated reasoning into a pending history item so it
+ // renders height-limited above the answer and can later be committed as
+ // a collapsible block.
+ setPendingThoughtItem({
+ type: 'gemini_thought',
+ text: stripLeadingBlankLines(newThoughtBuffer),
+ durationMs: thoughtStartTimeRef.current
+ ? Date.now() - thoughtStartTimeRef.current
+ : 0,
+ });
- return newThoughtBuffer;
+ return startingNewThought ? description : newThoughtBuffer;
},
- [addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought],
+ [mergeThought, setPendingThoughtItem],
+ );
+
+ // Commit the streamed reasoning to history as a collapsible block (or drop
+ // it). Called when the answer/tool/turn begins, or on cancel/error.
+ const commitPendingThought = useCallback(
+ (userMessageTimestamp: number) => {
+ if (pendingThoughtItemRef.current) {
+ const item = { ...pendingThoughtItemRef.current };
+ if (item.type === 'gemini_thought' && thoughtStartTimeRef.current) {
+ item.durationMs = Date.now() - thoughtStartTimeRef.current;
+ }
+ addItem(item, userMessageTimestamp);
+ }
+ setPendingThoughtItem(null);
+ thoughtStartTimeRef.current = null;
+ },
+ [addItem, pendingThoughtItemRef, setPendingThoughtItem],
);
const handleUserCancelledEvent = useCallback(
@@ -1075,6 +1055,8 @@ export const useGeminiStream = (
}
lastPromptErroredRef.current = false;
+ // Persist any streamed reasoning (collapsed) above the cancelled answer.
+ commitPendingThought(userMessageTimestamp);
if (pendingHistoryItemRef.current) {
if (pendingHistoryItemRef.current.type === 'tool_group') {
const updatedTools = pendingHistoryItemRef.current.tools.map(
@@ -1105,6 +1087,7 @@ export const useGeminiStream = (
},
[
addItem,
+ commitPendingThought,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
@@ -1115,6 +1098,8 @@ export const useGeminiStream = (
const handleErrorEvent = useCallback(
(eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
lastPromptErroredRef.current = true;
+ // Persist any streamed reasoning (collapsed) above the error.
+ commitPendingThought(userMessageTimestamp);
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
@@ -1157,6 +1142,7 @@ export const useGeminiStream = (
},
[
addItem,
+ commitPendingThought,
pendingHistoryItemRef,
setPendingHistoryItem,
setPendingRetryErrorItem,
@@ -1478,11 +1464,7 @@ export const useGeminiStream = (
};
}
- thoughtBuffer = handleThoughtEvent(
- mergedThought,
- thoughtBuffer,
- userMessageTimestamp,
- );
+ thoughtBuffer = handleThoughtEvent(mergedThought, thoughtBuffer);
}
};
@@ -1517,11 +1499,31 @@ export const useGeminiStream = (
}
break;
case ServerGeminiEventType.Content:
+ // Thinking is done once the answer starts streaming; reset the
+ // title status. On the thinking→answer transition, flush any
+ // buffered reasoning so the full thought is captured, then commit
+ // it to history (collapsed) above the answer. After that the
+ // condition is false, so normal content batching resumes.
+ if (
+ pendingThoughtItemRef.current ||
+ bufferedEvents.some((e) => e.kind === 'thought')
+ ) {
+ flushBufferedStreamEvents();
+ commitPendingThought(userMessageTimestamp);
+ thoughtBuffer = '';
+ }
+ setThought((prev) => (prev ? null : prev));
bufferedEvents.push({ kind: 'content', value: event.value });
scheduleBufferedStreamFlush();
break;
case ServerGeminiEventType.ToolCallRequest:
+ // Thinking is done once a tool call is issued; flush buffered
+ // reasoning then commit it to history (collapsed) above the tool
+ // output.
flushBufferedStreamEvents();
+ commitPendingThought(userMessageTimestamp);
+ thoughtBuffer = '';
+ setThought((prev) => (prev ? null : prev));
toolCallRequests.push(event.value);
// Count tool call args JSON toward token estimation.
try {
@@ -1557,6 +1559,9 @@ export const useGeminiStream = (
break;
case ServerGeminiEventType.Finished:
flushBufferedStreamEvents();
+ // A thinking-only turn (no content/tool) still commits its
+ // reasoning so it persists collapsed in history.
+ commitPendingThought(userMessageTimestamp);
handleFinishedEvent(
event as ServerGeminiFinishedEvent,
userMessageTimestamp,
@@ -1575,6 +1580,7 @@ export const useGeminiStream = (
}
geminiMessageBuffer = '';
thoughtBuffer = '';
+ setThought(null);
break;
case ServerGeminiEventType.Citation:
flushBufferedStreamEvents();
@@ -1599,8 +1605,10 @@ export const useGeminiStream = (
if (pendingHistoryItemRef.current) {
setPendingHistoryItem(null);
}
- geminiMessageBuffer = '';
+ commitPendingThought(userMessageTimestamp);
thoughtBuffer = '';
+ setThought(null);
+ geminiMessageBuffer = '';
} else {
flushBufferedStreamEvents();
}
@@ -1657,6 +1665,7 @@ export const useGeminiStream = (
}
} finally {
flushBufferedStreamEvents();
+ commitPendingThought(userMessageTimestamp);
discardBufferedStreamEvents();
flushBufferedStreamEventsRef.current.delete(flushBufferedStreamEvents);
}
@@ -1680,7 +1689,9 @@ export const useGeminiStream = (
startRetryCountdown,
clearRetryCountdown,
setThought,
+ commitPendingThought,
pendingHistoryItemRef,
+ pendingThoughtItemRef,
setPendingHistoryItem,
handleUserPromptSubmitBlockedEvent,
handleStopHookLoopEvent,
@@ -1849,6 +1860,7 @@ export const useGeminiStream = (
// Reset thought when starting a new prompt
setThought(null);
+ setPendingThoughtItem(null);
}
if (submitType === SendMessageType.Retry) {
@@ -1993,6 +2005,7 @@ export const useGeminiStream = (
pendingRetryCountdownItemRef,
pendingRetryErrorItemRef,
setPendingRetryErrorItem,
+ setPendingThoughtItem,
dualOutput,
],
);
@@ -2425,12 +2438,15 @@ export const useGeminiStream = (
const pendingHistoryItems = useMemo(
() =>
[
+ // Reasoning renders above the streaming answer.
+ pendingThoughtItem,
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
pendingToolCallGroupDisplay,
].filter((i) => i !== undefined && i !== null),
[
+ pendingThoughtItem,
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 0f5fe8a772e..d11d52f83c0 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -122,6 +122,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
export type HistoryItemGeminiThought = HistoryItemBase & {
type: 'gemini_thought';
text: string;
+ durationMs?: number;
};
export type HistoryItemGeminiThoughtContent = HistoryItemBase & {
diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
index ab7241c7a44..1b136368a1a 100644
--- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
+++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
@@ -20,11 +20,6 @@ const makeConfig = (tools: Record) =>
getToolRegistry: () => ({
getTool: (name: string) => tools[name],
}),
- getContentGenerator: () => ({
- // Default to showing full thinking content during resume unless explicitly
- // summarized; tests don't care about summarized thinking behavior.
- useSummarizedThinking: () => false,
- }),
}) as unknown as Config;
describe('resumeHistoryUtils', () => {
@@ -150,7 +145,7 @@ describe('resumeHistoryUtils', () => {
});
});
- it('marks tool results as error, captures thought text, and falls back when tool is missing', () => {
+ it('marks tool results as error, omits thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
{
@@ -190,11 +185,6 @@ describe('resumeHistoryUtils', () => {
const items = buildResumedHistoryItems(session, makeConfig({}));
expect(items).toEqual([
- {
- id: expect.any(Number),
- type: 'gemini_thought',
- text: 'should be skipped',
- },
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
{
id: expect.any(Number),
@@ -213,6 +203,40 @@ describe('resumeHistoryUtils', () => {
]);
});
+ it('keeps thought text in standalone previews without config', () => {
+ const conversation = {
+ messages: [
+ {
+ type: 'assistant',
+ message: {
+ parts: [
+ {
+ text: 'preview thought',
+ thought: true,
+ } as unknown as Part,
+ { text: 'visible text' } as Part,
+ ],
+ },
+ },
+ ],
+ } as unknown as ConversationRecord;
+
+ const session: ResumedSessionData = {
+ conversation,
+ } as ResumedSessionData;
+
+ const items = buildResumedHistoryItems(session, null);
+
+ expect(items).toEqual([
+ {
+ id: expect.any(Number),
+ type: 'gemini_thought',
+ text: 'preview thought',
+ },
+ { id: expect.any(Number), type: 'gemini', text: 'visible text' },
+ ]);
+ });
+
it('flushes pending tool groups before subsequent user messages', () => {
const conversation = {
messages: [
diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts
index e2c5029aed2..015150de36a 100644
--- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts
+++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts
@@ -345,13 +345,11 @@ function convertToHistoryItems(
case 'assistant': {
const parts = record.message?.parts as Part[] | undefined;
- // Extract thought content. With no config (standalone picker preview),
- // default to showing thoughts verbatim (same path as
- // `!useSummarizedThinking()`).
- const thoughtText =
- !config || !config.getContentGenerator().useSummarizedThinking()
- ? extractThoughtTextFromParts(parts)
- : '';
+ // The interactive TUI treats thinking as transient live state, so
+ // resumed history should not reintroduce thought rows into scrollback.
+ // With no config (standalone picker preview), keep showing thoughts
+ // verbatim because there is no live loading area in that view.
+ const thoughtText = !config ? extractThoughtTextFromParts(parts) : '';
// Extract text content (non-function-call, non-thought)
const text = extractTextFromParts(parts);