UX for topic narration tool (#24079)

This commit is contained in:
Christian Gunderman
2026-03-28 21:33:38 +00:00
committed by GitHub
parent 3eebb75b7a
commit b7c86b5497
13 changed files with 271 additions and 44 deletions

View File

@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean;
isFirstThinking?: boolean;
isFirstAfterThinking?: boolean;
suppressNarration?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable,
isFirstThinking = false,
isFirstAfterThinking = false,
suppressNarration = false,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -68,6 +70,17 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
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 (
<Box
flexDirection="column"

View File

@@ -7,8 +7,10 @@
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
@@ -19,6 +21,7 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -63,12 +66,39 @@ export const MainContent = () => {
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<boolean>(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,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
@@ -103,6 +140,7 @@ export const MainContent = () => {
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 (
<HistoryItemDisplay
key={`pending-${i}`}
@@ -150,6 +191,7 @@ export const MainContent = () => {
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 {

View File

@@ -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('<ToolGroupMessage />', () => {
): IndividualToolCallDisplay => ({
callId: 'tool-123',
name: 'test-tool',
args: {},
description: 'A tool for testing',
resultDisplay: 'Test result',
status: CoreToolCallStatus.Success,
@@ -253,8 +257,71 @@ describe('<ToolGroupMessage />', () => {
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(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
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(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
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',

View File

@@ -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<ToolGroupMessageProps> = ({
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<ToolGroupMessageProps> = ({
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<ToolGroupMessageProps> = ({
minHeight={1}
width={contentWidth}
>
{isShellToolCall ? (
{isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
@@ -262,26 +279,26 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
</Box>
);
})}
{
/*
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 && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
{/*
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))) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
</Box>
);

View File

@@ -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<TopicMessageProps> = ({ 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 (
<Box flexDirection="row" marginLeft={2}>
<Text color={theme.text.primary} bold>
{title || 'Topic'}
</Text>
{intent && <Text color={theme.text.secondary}> {intent}</Text>}
</Box>
);
};

View File

@@ -74,8 +74,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
exports[`<ToolGroupMessage /> > 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[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
" Testing Topic — This is the description
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │

View File

@@ -50,6 +50,7 @@ export function mapToDisplay(
callId: call.request.callId,
parentCallId: call.request.parentCallId,
name: displayName,
args: call.request.args,
description,
renderOutputAsMarkdown,
};

View File

@@ -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.

View File

@@ -118,6 +118,7 @@ export interface IndividualToolCallDisplay {
callId: string;
parentCallId?: string;
name: string;
args?: Record<string, unknown>;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,