diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 2bc6ee27bc..ec75573d75 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -6,7 +6,11 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; -import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + makeFakeConfig, + CoreToolCallStatus, + UPDATE_TOPIC_TOOL_NAME, +} from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { MainContent } from './MainContent.js'; import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; @@ -728,6 +732,158 @@ describe('MainContent', () => { unmount(); }); + describe('Narration Suppression', () => { + const settingsWithNarration = createMockSettings({ + merged: { + ui: { inlineThinkingMode: 'expanded' }, + experimental: { topicUpdateNarration: true }, + }, + }); + + it('suppresses thinking ALWAYS when narration is enabled', async () => { + mockUseSettings.mockReturnValue(settingsWithNarration); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 1, type: 'user' as const, text: 'Hello' }, + { + id: 2, + type: 'thinking' as const, + thought: { + subject: 'Thinking...', + description: 'Thinking about hello', + }, + }, + { id: 3, type: 'gemini' as const, text: 'I am helping.' }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: uiState as Partial, + settings: settingsWithNarration, + }, + ); + + const output = lastFrame(); + expect(output).not.toContain('Thinking...'); + expect(output).toContain('I am helping.'); + unmount(); + }); + + it('suppresses text in intermediate turns (contains non-topic tools)', async () => { + mockUseSettings.mockReturnValue(settingsWithNarration); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 100, type: 'user' as const, text: 'Search' }, + { + id: 101, + type: 'gemini' as const, + text: 'I will now search the files.', + }, + { + id: 102, + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: 'ls', + args: { path: '.' }, + status: CoreToolCallStatus.Success, + }, + ], + }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: uiState as Partial, + settings: settingsWithNarration, + }, + ); + + const output = lastFrame(); + expect(output).not.toContain('I will now search the files.'); + unmount(); + }); + + it('suppresses text that precedes a topic tool in the same turn', async () => { + mockUseSettings.mockReturnValue(settingsWithNarration); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 200, type: 'user' as const, text: 'Hello' }, + { id: 201, type: 'gemini' as const, text: 'I will now help you.' }, + { + id: 202, + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: UPDATE_TOPIC_TOOL_NAME, + args: { title: 'Helping', summary: 'Helping the user' }, + status: CoreToolCallStatus.Success, + }, + ], + }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: uiState as Partial, + settings: settingsWithNarration, + }, + ); + + const output = lastFrame(); + expect(output).not.toContain('I will now help you.'); + expect(output).toContain('Helping'); + expect(output).toContain('Helping the user'); + unmount(); + }); + + it('shows text in the final turn if it comes AFTER the topic tool', async () => { + mockUseSettings.mockReturnValue(settingsWithNarration); + const uiState = { + ...defaultMockUiState, + history: [ + { id: 300, type: 'user' as const, text: 'Hello' }, + { + id: 301, + type: 'tool_group' as const, + tools: [ + { + callId: '1', + name: UPDATE_TOPIC_TOOL_NAME, + args: { title: 'Final Answer', summary: 'I have finished' }, + status: CoreToolCallStatus.Success, + }, + ], + }, + { id: 302, type: 'gemini' as const, text: 'Here is your answer.' }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: uiState as Partial, + settings: settingsWithNarration, + }, + ); + + const output = lastFrame(); + expect(output).toContain('Here is your answer.'); + unmount(); + }); + }); + it('renders multiple thinking messages sequentially correctly', async () => { mockUseSettings.mockReturnValue({ merged: { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index b46af4965b..527462be28 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -91,20 +91,47 @@ export const MainContent = () => { const flags = new Array(combinedHistory.length).fill(false); if (topicUpdateNarrationEnabled) { - let toolGroupInTurn = false; + let turnIsIntermediate = false; + let hasTopicToolInTurn = false; + for (let i = combinedHistory.length - 1; i >= 0; i--) { const item = combinedHistory[i]; if (item.type === 'user' || item.type === 'user_shell') { - toolGroupInTurn = false; + turnIsIntermediate = false; + hasTopicToolInTurn = false; } else if (item.type === 'tool_group') { - toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name)); + const hasTopic = item.tools.some((t) => isTopicTool(t.name)); + const hasNonTopic = item.tools.some((t) => !isTopicTool(t.name)); + if (hasTopic) { + hasTopicToolInTurn = true; + } + if (hasNonTopic) { + turnIsIntermediate = true; + } } else if ( - (item.type === 'thinking' || - item.type === 'gemini' || - item.type === 'gemini_content') && - toolGroupInTurn + item.type === 'thinking' || + item.type === 'gemini' || + item.type === 'gemini_content' ) { - flags[i] = true; + // Rule 1: Always suppress thinking when narration is enabled to avoid + // "flashing" as the model starts its response, and because the Topic + // UI provides the necessary high-level intent. + if (item.type === 'thinking') { + flags[i] = true; + continue; + } + + // Rule 2: Suppress text in intermediate turns (turns containing non-topic + // tools) to hide mechanical narration. + if (turnIsIntermediate) { + flags[i] = true; + } + + // Rule 3: Suppress text that precedes a topic tool in the same turn, + // as the topic tool "replaces" it. + if (hasTopicToolInTurn) { + flags[i] = true; + } } } }