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;
+ }
}
}
}