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);