diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 9c8d90cd19..867754e023 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -48,6 +48,7 @@ interface HistoryItemDisplayProps { isExpandable?: boolean; isFirstThinking?: boolean; isFirstAfterThinking?: boolean; + suppressNarration?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC = ({ isExpandable, isFirstThinking = false, isFirstAfterThinking = false, + suppressNarration = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC = ({ const needsTopMarginAfterThinking = isFirstAfterThinking && inlineThinkingMode !== 'off'; + // If there's a topic update in this turn, we suppress the regular narration + // and thoughts as they are being "replaced" by the update_topic tool. + if ( + suppressNarration && + (itemForDisplay.type === 'thinking' || + itemForDisplay.type === 'gemini' || + itemForDisplay.type === 'gemini_content') + ) { + return null; + } + return ( { return -1; }, [uiState.history]); + const settings = useSettings(); + const topicUpdateNarrationEnabled = + settings.merged.experimental?.topicUpdateNarration === true; + + const suppressNarrationFlags = useMemo(() => { + const combinedHistory = [...uiState.history, ...pendingHistoryItems]; + const flags = new Array(combinedHistory.length).fill(false); + + if (topicUpdateNarrationEnabled) { + let toolGroupInTurn = false; + for (let i = combinedHistory.length - 1; i >= 0; i--) { + const item = combinedHistory[i]; + if (item.type === 'user' || item.type === 'user_shell') { + toolGroupInTurn = false; + } else if (item.type === 'tool_group') { + toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name)); + } else if ( + (item.type === 'thinking' || + item.type === 'gemini' || + item.type === 'gemini_content') && + toolGroupInTurn + ) { + flags[i] = true; + } + } + } + return flags; + }, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]); + const augmentedHistory = useMemo( () => - uiState.history.map((item, index) => { - const isExpandable = index > lastUserPromptIndex; - const prevType = - index > 0 ? uiState.history[index - 1]?.type : undefined; + uiState.history.map((item, i) => { + const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined; const isFirstThinking = item.type === 'thinking' && prevType !== 'thinking'; const isFirstAfterThinking = @@ -76,18 +106,25 @@ export const MainContent = () => { return { item, - isExpandable, + isExpandable: i > lastUserPromptIndex, isFirstThinking, isFirstAfterThinking, + suppressNarration: suppressNarrationFlags[i] ?? false, }; }), - [uiState.history, lastUserPromptIndex], + [uiState.history, lastUserPromptIndex, suppressNarrationFlags], ); const historyItems = useMemo( () => augmentedHistory.map( - ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ( + ({ + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + suppressNarration, + }) => ( { isExpandable={isExpandable} isFirstThinking={isFirstThinking} isFirstAfterThinking={isFirstAfterThinking} + suppressNarration={suppressNarration} /> ), ), @@ -138,6 +176,9 @@ export const MainContent = () => { const isFirstAfterThinking = item.type !== 'thinking' && prevType === 'thinking'; + const suppressNarration = + suppressNarrationFlags[uiState.history.length + i] ?? false; + return ( { isExpandable={true} isFirstThinking={isFirstThinking} isFirstAfterThinking={isFirstAfterThinking} + suppressNarration={suppressNarration} /> ); })} @@ -169,6 +211,7 @@ export const MainContent = () => { showConfirmationQueue, confirmingTool, uiState.history, + suppressNarrationFlags, ], ); @@ -176,12 +219,19 @@ export const MainContent = () => { () => [ { type: 'header' as const }, ...augmentedHistory.map( - ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({ + ({ + item, + isExpandable, + isFirstThinking, + isFirstAfterThinking, + suppressNarration, + }) => ({ type: 'history' as const, item, isExpandable, isFirstThinking, isFirstAfterThinking, + suppressNarration, }), ), { type: 'pending' as const }, @@ -216,6 +266,7 @@ export const MainContent = () => { isExpandable={item.isExpandable} isFirstThinking={item.isFirstThinking} isFirstAfterThinking={item.isFirstAfterThinking} + suppressNarration={item.suppressNarration} /> ); } else { diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 4240bc3b86..bfc19e344f 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -7,13 +7,10 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; -import type { - HistoryItem, - HistoryItemWithoutId, - IndividualToolCallDisplay, -} from '../../types.js'; -import { Scrollable } from '../shared/Scrollable.js'; import { + UPDATE_TOPIC_TOOL_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_STRATEGIC_INTENT, makeFakeConfig, CoreToolCallStatus, ApprovalMode, @@ -23,6 +20,12 @@ import { READ_FILE_DISPLAY_NAME, GLOB_DISPLAY_NAME, } from '@google/gemini-cli-core'; +import type { + HistoryItem, + HistoryItemWithoutId, + IndividualToolCallDisplay, +} from '../../types.js'; +import { Scrollable } from '../shared/Scrollable.js'; import os from 'node:os'; import { createMockSettings } from '../../../test-utils/settings.js'; @@ -36,6 +39,7 @@ describe('', () => { ): IndividualToolCallDisplay => ({ callId: 'tool-123', name: 'test-tool', + args: {}, description: 'A tool for testing', resultDisplay: 'Test result', status: CoreToolCallStatus.Success, @@ -253,8 +257,71 @@ describe('', () => { unmount(); }); - it('renders mixed tool calls including shell command', async () => { + it('renders update_topic tool call using TopicMessage', async () => { const toolCalls = [ + createToolCall({ + callId: 'topic-tool', + name: UPDATE_TOPIC_TOOL_NAME, + args: { + [TOPIC_PARAM_TITLE]: 'Testing Topic', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description', + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + config: baseMockConfig, + settings: fullVerbositySettings, + }, + ); + + const output = lastFrame(); + expect(output).toContain('Testing Topic'); + expect(output).toContain('— This is the description'); + expect(output).toMatchSnapshot('update_topic_tool'); + unmount(); + }); + + it('renders update_topic tool call with summary instead of strategic_intent', async () => { + const toolCalls = [ + createToolCall({ + callId: 'topic-tool-summary', + name: UPDATE_TOPIC_TOOL_NAME, + args: { + [TOPIC_PARAM_TITLE]: 'Testing Topic', + summary: 'This is the summary', + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + config: baseMockConfig, + settings: fullVerbositySettings, + }, + ); + + const output = lastFrame(); + expect(output).toContain('Testing Topic'); + expect(output).toContain('— This is the summary'); + unmount(); + }); + + it('renders mixed tool calls including update_topic', async () => { + const toolCalls = [ + createToolCall({ + callId: 'topic-tool-mixed', + name: UPDATE_TOPIC_TOOL_NAME, + args: { + [TOPIC_PARAM_TITLE]: 'Testing Topic', + [TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description', + }, + }), createToolCall({ callId: 'tool-1', name: 'read_file', diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 6bad49b1b6..29ab48a09c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,6 +15,7 @@ import type { import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; +import { TopicMessage, isTopicTool } from './TopicMessage.js'; import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; @@ -192,7 +193,20 @@ export const ToolGroupMessage: React.FC = ({ paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} > {groupedTools.map((group, index) => { - const isFirst = index === 0; + let isFirst = index === 0; + if (!isFirst) { + // Check if all previous tools were topics + let allPreviousWereTopics = true; + for (let i = 0; i < index; i++) { + const prevGroup = groupedTools[i]; + if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) { + allPreviousWereTopics = false; + break; + } + } + isFirst = allPreviousWereTopics; + } + const resolvedIsFirst = borderTopOverride !== undefined ? borderTopOverride && isFirst @@ -215,6 +229,7 @@ export const ToolGroupMessage: React.FC = ({ const tool = group; const isShellToolCall = isShellTool(tool.name); + const isTopicToolCall = isTopicTool(tool.name); const commonProps = { ...tool, @@ -234,7 +249,9 @@ export const ToolGroupMessage: React.FC = ({ minHeight={1} width={contentWidth} > - {isShellToolCall ? ( + {isTopicToolCall ? ( + + ) : isShellToolCall ? ( ) : ( @@ -262,26 +279,26 @@ export const ToolGroupMessage: React.FC = ({ ); })} - { - /* - We have to keep the bottom border separate so it doesn't get - drawn over by the sticky header directly inside it. - */ - (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && - borderBottomOverride !== false && ( - - ) - } + {/* + We have to keep the bottom border separate so it doesn't get + drawn over by the sticky header directly inside it. + */} + {(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && + borderBottomOverride !== false && + (visibleToolCalls.length === 0 || + !visibleToolCalls.every((tool) => isTopicTool(tool.name))) && ( + + )} ); diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx new file mode 100644 index 0000000000..810628606d --- /dev/null +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, + TOPIC_PARAM_TITLE, + TOPIC_PARAM_SUMMARY, + TOPIC_PARAM_STRATEGIC_INTENT, +} from '@google/gemini-cli-core'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { theme } from '../../semantic-colors.js'; + +interface TopicMessageProps extends IndividualToolCallDisplay { + terminalWidth: number; +} + +export const isTopicTool = (name: string): boolean => + name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME; + +export const TopicMessage: React.FC = ({ args }) => { + const rawTitle = args?.[TOPIC_PARAM_TITLE]; + const title = typeof rawTitle === 'string' ? rawTitle : undefined; + const rawIntent = + args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY]; + const intent = typeof rawIntent === 'string' ? rawIntent : undefined; + + return ( + + + {title || 'Topic'} + + {intent && — {intent}} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 98db513da8..e5a69fb2bf 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -74,8 +74,9 @@ exports[` > Golden Snapshots > renders header when scrolled " `; -exports[` > Golden Snapshots > renders mixed tool calls including shell command 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ +exports[` > Golden Snapshots > renders mixed tool calls including update_topic 1`] = ` +" Testing Topic — This is the description +╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ read_file Read a file │ │ │ │ Test result │ @@ -137,6 +138,11 @@ exports[` > Golden Snapshots > renders two tool groups where " `; +exports[` > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = ` +" Testing Topic — This is the description +" +`; + exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-with-result Tool with output │ diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index e06ebf5bb5..a23b5c3d96 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -50,6 +50,7 @@ export function mapToDisplay( callId: call.request.callId, parentCallId: call.request.parentCallId, name: displayName, + args: call.request.args, description, renderOutputAsMarkdown, }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d571ae445e..5f5c1ab187 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -40,6 +40,8 @@ import { Kind, ACTIVATE_SKILL_TOOL_NAME, shouldHideToolCall, + UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, } from '@google/gemini-cli-core'; import type { Config, @@ -108,6 +110,9 @@ interface BackgroundedToolInfo { initialOutput: string; } +const isTopicTool = (name: string): boolean => + name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME; + enum StreamProcessingStatus { Completed, UserCancelled, @@ -489,7 +494,17 @@ export const useGeminiStream = ( addItem(historyItem); setPushedToolCallIds(newPushed); - setIsFirstToolInGroup(false); + + // If this batch ONLY contains topics, and we were the first in the group, + // the NEXT batch is still effectively the first VISIBLE bordered tool in the group. + if ( + isFirstToolInGroupRef.current && + toolsToPush.every((tc) => isTopicTool(tc.request.name)) + ) { + // Keep it true! + } else { + setIsFirstToolInGroup(false); + } } }, [ toolCalls, @@ -502,7 +517,6 @@ export const useGeminiStream = ( isShellFocused, backgroundTasks, ]); - const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { const remainingTools = toolCalls.filter( (tc) => !pushedToolCallIds.has(tc.request.callId), @@ -519,15 +533,26 @@ export const useGeminiStream = ( ); if (remainingTools.length > 0) { + // Should we draw a top border? Yes if NO previous tools were drawn, + // OR if ALL previously drawn tools were topics (which don't draw top borders). + let needsTopBorder = pushedToolCallIds.size === 0; + if (!needsTopBorder) { + const allPushedWereTopics = toolCalls + .filter((tc) => pushedToolCallIds.has(tc.request.callId)) + .every((tc) => isTopicTool(tc.request.name)); + if (allPushedWereTopics) { + needsTopBorder = true; + } + } + items.push( mapTrackedToolCallsToDisplay(remainingTools, { - borderTop: pushedToolCallIds.size === 0, + borderTop: needsTopBorder, borderBottom: false, // Stay open to connect with the slice below ...appearance, }), ); } - // Always show a bottom border slice if we have ANY tools in the batch // and we haven't finished pushing the whole batch to history yet. // Once all tools are terminal and pushed, the last history item handles the closing border. diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 3760575a6f..18ed1f525c 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -118,6 +118,7 @@ export interface IndividualToolCallDisplay { callId: string; parentCallId?: string; name: string; + args?: Record; description: string; resultDisplay: ToolResultDisplay | undefined; status: CoreToolCallStatus; diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index b4f6732097..08b14ce6cb 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -128,6 +128,7 @@ export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions'; // -- update_topic -- export const UPDATE_TOPIC_TOOL_NAME = 'update_topic'; +export const UPDATE_TOPIC_DISPLAY_NAME = 'Update Topic Context'; export const TOPIC_PARAM_TITLE = 'title'; export const TOPIC_PARAM_SUMMARY = 'summary'; export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index d77cc45a7d..f642d2709f 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -40,6 +40,7 @@ export { EXIT_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index f18680cea0..935c1834e7 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -76,6 +76,7 @@ import { EXIT_PLAN_PARAM_PLAN_FILENAME, SKILL_PARAM_NAME, UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, TOPIC_PARAM_TITLE, TOPIC_PARAM_SUMMARY, TOPIC_PARAM_STRATEGIC_INTENT, @@ -100,6 +101,7 @@ export { EXIT_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, // Shared parameter names PARAM_FILE_PATH, PARAM_DIR_PATH, diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts index abc0e63972..91d1b5abc5 100644 --- a/packages/core/src/tools/topicTool.ts +++ b/packages/core/src/tools/topicTool.ts @@ -6,6 +6,7 @@ import { UPDATE_TOPIC_TOOL_NAME, + UPDATE_TOPIC_DISPLAY_NAME, TOPIC_PARAM_TITLE, TOPIC_PARAM_SUMMARY, TOPIC_PARAM_STRATEGIC_INTENT, @@ -110,7 +111,7 @@ export class UpdateTopicTool extends BaseDeclarativeTool< const declaration = getUpdateTopicDeclaration(); super( UPDATE_TOPIC_TOOL_NAME, - 'Update Topic Context', + UPDATE_TOPIC_DISPLAY_NAME, declaration.description ?? '', Kind.Think, declaration.parametersJsonSchema,