From 90304b279c76fddc4ed3e91fb3643261f6883721 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 6 May 2026 14:23:26 -0700 Subject: [PATCH] refactor(cli): migrate core tools to native ToolDisplay property and fix UI rendering (#25186) --- .../src/ui/components/HistoryItemDisplay.tsx | 7 + .../messages/ToolGroupDisplay.test.tsx | 304 ++++++++++++++++++ .../components/messages/ToolGroupDisplay.tsx | 266 +++++++++++++++ .../ToolGroupDisplay.test.tsx.snap | 79 +++++ packages/cli/src/ui/hooks/useAgentStream.ts | 87 +++-- packages/cli/src/ui/types.ts | 15 + packages/core/src/agent/event-translator.ts | 18 +- .../src/agent/legacy-agent-session.test.ts | 11 +- .../core/src/agent/legacy-agent-session.ts | 1 + packages/core/src/agent/tool-display-utils.ts | 7 +- packages/core/src/agent/types.ts | 42 ++- packages/core/src/config/config.ts | 10 +- packages/core/src/core/geminiChat.ts | 4 + packages/core/src/core/turn.test.ts | 10 + packages/core/src/core/turn.ts | 26 +- packages/core/src/scheduler/scheduler.test.ts | 2 +- packages/core/src/scheduler/scheduler.ts | 11 + packages/core/src/scheduler/state-manager.ts | 8 +- packages/core/src/scheduler/tool-executor.ts | 8 + packages/core/src/scheduler/types.ts | 5 + packages/core/src/tools/edit.test.ts | 11 + packages/core/src/tools/edit.ts | 23 ++ packages/core/src/tools/grep.ts | 14 +- packages/core/src/tools/ls.ts | 5 + packages/core/src/tools/read-file.test.ts | 32 +- packages/core/src/tools/read-file.ts | 12 + packages/core/src/tools/ripGrep.ts | 14 +- packages/core/src/tools/shell.test.ts | 7 + packages/core/src/tools/shell.ts | 16 + packages/core/src/tools/tools.ts | 7 + packages/core/src/tools/topicTool.ts | 5 + packages/core/src/tools/write-file.test.ts | 10 + packages/core/src/tools/write-file.ts | 13 + 33 files changed, 1033 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 081a206272..1ae783fe75 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -14,6 +14,7 @@ import { GeminiMessage } from './messages/GeminiMessage.js'; import { InfoMessage } from './messages/InfoMessage.js'; import { ErrorMessage } from './messages/ErrorMessage.js'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; +import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js'; import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; import { WarningMessage } from './messages/WarningMessage.js'; @@ -195,6 +196,12 @@ export const HistoryItemDisplay: React.FC = ({ isExpandable={isExpandable} /> )} + {itemForDisplay.type === 'tool_display_group' && ( + + )} {itemForDisplay.type === 'subagent' && ( ', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createToolItem = ( + overrides: Partial = {}, + ): ToolDisplayItem => ({ + status: CoreToolCallStatus.Success, + name: 'test-tool', + description: 'Test description', + ...overrides, + }); + + const createHistoryItem = ( + tools: ToolDisplayItem[], + overrides: Partial = {}, + ): HistoryItemToolDisplayGroup => ({ + type: 'tool_display_group', + tools, + borderColor: 'gray', + borderDimColor: true, + borderTop: true, + borderBottom: true, + ...overrides, + }); + + const fullVerbositySettings = createMockSettings({ + ui: { errorVerbosity: 'full', compactToolOutput: false }, + }); + const compactSettings = createMockSettings({ + ui: { compactToolOutput: true }, + }); + + describe('Golden Snapshots', () => { + it('renders notices at the top (hoisting)', async () => { + const tools = [ + createToolItem({ name: 'Tool A', format: 'box' }), + createToolItem({ + name: UPDATE_TOPIC_DISPLAY_NAME, + description: 'New Topic', + format: 'notice', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + // Notice should be before Tool A + expect(output.indexOf(UPDATE_TOPIC_DISPLAY_NAME)).toBeLessThan( + output.indexOf('Tool A'), + ); + expect(output).toMatchSnapshot(); + }); + + it('renders in compact mode (no box borders)', async () => { + const tools = [ + createToolItem({ name: 'Tool A' }), + createToolItem({ name: 'Tool B' }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: compactSettings }, + ); + + const output = lastFrame(); + // Should not contain box drawing characters for the outer box + expect(output).not.toContain('╭'); + expect(output).not.toContain('╰'); + expect(output).toMatchSnapshot(); + }); + + it('renders in boxed mode (full verbosity)', async () => { + const tools = [createToolItem({ name: 'Tool A' })]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('╭'); + expect(output).toContain('╰'); + expect(output).toMatchSnapshot(); + }); + + it('renders standalone notices without a box', async () => { + const tools = [ + createToolItem({ + name: 'Notice Only', + format: 'notice', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).not.toContain('╭'); + expect(output).toMatchSnapshot(); + }); + + it('renders error message when display info is missing', async () => { + // Create an item that effectively has no display properties + const tools = [ + { + status: CoreToolCallStatus.Executing, + originalRequestName: 'missing-tool', + } as ToolDisplayItem, + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('Error: Tool display missing'); + expect(output).toMatchSnapshot(); + }); + + it('hides tools awaiting approval (confirming)', async () => { + const tools = [ + createToolItem({ + name: 'Confirming Tool', + status: CoreToolCallStatus.AwaitingApproval, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + ); + + // Should render nothing (null) + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + }); + + describe('Result Formatting', () => { + it('renders text results with summary below', async () => { + const tools = [ + createToolItem({ + result: { type: 'text', text: 'Detailed output' }, + resultSummary: 'Short summary', + format: 'box', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('Detailed output'); + expect(output).toContain('Short summary'); + // Summary should be below detailed output + expect(output.indexOf('Detailed output')).toBeLessThan( + output.indexOf('Short summary'), + ); + expect(output).toMatchSnapshot(); + }); + + it('renders compact tools with summary on same line', async () => { + const tools = [ + createToolItem({ + resultSummary: 'Success summary', + format: 'compact', + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('→ Success summary'); + expect(output).toMatchSnapshot(); + }); + + it('renders placeholder for diff results', async () => { + const tools = [ + createToolItem({ + result: { + type: 'diff', + beforeText: 'old', + afterText: 'new', + path: 'file.ts', + }, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('[Diff Display: 3 -> 3 chars]'); + expect(output).toMatchSnapshot(); + }); + + it('renders placeholder for terminal results', async () => { + const tools = [ + createToolItem({ + result: { type: 'terminal' }, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + expect(lastFrame()).toContain('[Terminal Output]'); + }); + + it('renders placeholder for agent results', async () => { + const tools = [ + createToolItem({ + result: { type: 'agent', threadId: 'thread-123' }, + }), + ]; + const item = createHistoryItem(tools); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + expect(lastFrame()).toContain('[Subagent: thread-123]'); + }); + }); + + describe('Border & Margin Logic', () => { + it('forces top border on box when it follows a notice', async () => { + const tools = [ + createToolItem({ name: 'Notice', format: 'notice' }), + createToolItem({ name: 'Tool in Box', format: 'box' }), + ]; + // Even if item.borderTop is false (continuing a group), + // the box should have a top border because it follows a notice. + const item = createHistoryItem(tools, { borderTop: false }); + + const { lastFrame } = await renderWithProviders( + , + { settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('Notice'); + expect(output).toContain('╭'); // Top border for the box + expect(output).toMatchSnapshot(); + }); + + it('applies bottom margin in compact mode when group is at boundary', async () => { + const tools = [createToolItem({ name: 'Compact Tool' })]; + const item = createHistoryItem(tools, { borderBottom: true }); + + const { lastFrame } = await renderWithProviders( + , + { settings: compactSettings }, + ); + + // This is hard to assert via string check, but ensure match snapshot + // captures the vertical spacing. + expect(lastFrame()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx new file mode 100644 index 0000000000..f747a9ac9d --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -0,0 +1,266 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import type { + HistoryItem, + HistoryItemWithoutId, + HistoryItemToolDisplayGroup, + ToolDisplayItem, +} from '../../types.js'; +import { theme } from '../../semantic-colors.js'; +import { ToolStatusIndicator } from './ToolShared.js'; +import { useSettings } from '../../contexts/SettingsContext.js'; + +interface ToolGroupDisplayProps { + item: HistoryItem | HistoryItemWithoutId; + isToolGroupBoundary?: boolean; +} + +export const ToolGroupDisplay: React.FC = ({ + item, + isToolGroupBoundary, +}) => { + const settings = useSettings(); + const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true; + + if (item.type !== 'tool_display_group') { + return null; + } + + const { tools, borderColor, borderDimColor, borderTop, borderBottom } = + item as HistoryItemToolDisplayGroup; + + const visibleTools = tools.filter( + (t) => t.status !== CoreToolCallStatus.AwaitingApproval, + ); + + const noticeTools = visibleTools.filter((t) => t.format === 'notice'); + const otherTools = visibleTools.filter( + (t) => t.format !== 'notice' && t.format !== 'hidden', + ); + + const hasOtherTools = otherTools.length > 0; + const isClosingSlice = tools.length === 0 && borderBottom; + + // If no tools are visible and it's not an explicit closing slice, hide the group + if (visibleTools.length === 0 && !isClosingSlice) { + return null; + } + + // Standard view behavior: If compact mode is enabled, non-notice tools + // are typically rendered without an outer box. + const shouldShowBox = + (hasOtherTools || isClosingSlice) && !isCompactModeEnabled; + + const boxBorderTop = borderTop || noticeTools.length > 0; + + return ( + + {noticeTools.map((tool, index) => { + const isFirstInGroup = index === 0 && borderTop; + const isLastElementInGroup = + index === noticeTools.length - 1 && !shouldShowBox && borderBottom; + + return ( + 0 ? 1 : 0} + marginBottom={isLastElementInGroup ? 1 : 0} + > + + + ); + })} + {shouldShowBox ? ( + + {otherTools.map((tool, index) => ( + + ))} + + ) : otherTools.length > 0 ? ( + // Compact mode or no tools to box + 0 ? 1 : 0} + marginBottom={borderBottom ? 1 : 0} + > + {otherTools.map((tool, index) => ( + + ))} + + ) : null} + + ); +}; + +interface ToolDisplayMessageProps { + tool: ToolDisplayItem; +} + +const ToolDisplayMessage: React.FC = ({ tool }) => { + const settings = useSettings(); + const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true; + + // Since ToolDisplayItem is ToolDisplay & { status, ... }, we check for identifying properties + // of ToolDisplay. If name or description is missing and there's no result, it might be "empty". + if (!tool.name && !tool.description && !tool.result && !tool.resultSummary) { + return ( + + + Error: Tool display missing + + ); + } + + const { + status, + format: preferredFormat, + name, + description, + resultSummary, + result, + } = tool; + const format = preferredFormat || 'auto'; + + if (format === 'hidden') { + return null; + } + + if (format === 'notice') { + // If the name is part of the description (typical for topic updates), + // suppress the bold name to avoid redundancy and match legacy UI. + const isRedundant = !!(name && description?.includes(`"${name}"`)); + + return ( + + {name && !isRedundant && ( + + {name}: + + )} + {description && ( + + {description} + + )} + + ); + } + + const isCompact = + format === 'compact' || (format === 'auto' && isCompactModeEnabled); + + if (isCompact) { + return ( + + + + {' '} + {name || tool.originalRequestName}{' '} + + {description && {description}} + {resultSummary && ( + + {' '} + → {resultSummary.replace(/\n/g, ' ')} + + )} + + ); + } + + // Box format (full) + return ( + + + + + {' '} + {name || tool.originalRequestName}{' '} + + {description && {description}} + + {resultSummary && !result && ( + + {resultSummary} + + )} + {result && ( + + + + )} + + ); +}; + +interface ToolResultDisplayContentProps { + content: ToolDisplayItem['result']; + summary?: string | null; +} + +const ToolResultDisplayContent: React.FC = ({ + content, + summary, +}) => { + if (!content) return null; + + switch (content.type) { + case 'text': + return ( + + {content.text} + {summary && ( + + {summary} + + )} + + ); + case 'diff': + // Simplified diff display for now + return ( + + {summary && {summary}} + + {`[Diff Display: ${content.beforeText.length} -> ${content.afterText.length} chars]`} + + + ); + case 'terminal': + return [Terminal Output]; + case 'agent': + return ( + [Subagent: {content.threadId}] + ); + default: + return null; + } +}; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap new file mode 100644 index 0000000000..9e80fae63e --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap @@ -0,0 +1,79 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > Border & Margin Logic > applies bottom margin in compact mode when group is at boundary 1`] = ` +" ✓ Compact Tool Test description +" +`; + +exports[` > Border & Margin Logic > forces top border on box when it follows a notice 1`] = ` +" Notice: + Test description + +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Tool in Box Test description │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[` > Golden Snapshots > renders error message when display info is missing 1`] = ` +" ⊶ Error: Tool display missing +" +`; + +exports[` > Golden Snapshots > renders in boxed mode (full verbosity) 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Tool A Test description │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[` > Golden Snapshots > renders in compact mode (no box borders) 1`] = ` +" ✓ Tool A Test description + ✓ Tool B Test description +" +`; + +exports[` > Golden Snapshots > renders notices at the top (hoisting) 1`] = ` +" + Update Topic Context: + New Topic + +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Tool A Test description │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[` > Golden Snapshots > renders standalone notices without a box 1`] = ` +" + Notice Only: + Test description +" +`; + +exports[` > Result Formatting > renders compact tools with summary on same line 1`] = ` +" ✓ test-tool Test description → Success summary +" +`; + +exports[` > Result Formatting > renders placeholder for diff results 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool Test description │ +│ │ +│ [Diff Display: 3 -> 3 chars] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[` > Result Formatting > renders text results with summary below 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool Test description │ +│ │ +│ Detailed output │ +│ Short summary │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index aea7b76ba5..982391a437 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -26,7 +26,7 @@ import type { HistoryItemWithoutId, LoopDetectionConfirmationRequest, IndividualToolCallDisplay, - HistoryItemToolGroup, + HistoryItemToolDisplayGroup, } from '../types.js'; import { StreamingState, MessageType } from '../types.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; @@ -81,6 +81,8 @@ export const useAgentStream = ({ useStateAndRef>(new Set()); const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] = useStateAndRef(true); + const [_hasEmittedBoxInTurn, hasEmittedBoxInTurnRef, setHasEmittedBoxInTurn] = + useStateAndRef(false); const { startNewPrompt } = useSessionStats(); @@ -408,32 +410,27 @@ export const useAgentStream = ({ // Push completed tools to history useEffect(() => { - const toolsToPush: IndividualToolCallDisplay[] = []; - for (let i = 0; i < trackedTools.length; i++) { - const tc = trackedTools[i]; - if (pushedToolCallIdsRef.current.has(tc.callId)) continue; + if (trackedTools.length === 0) return; - if ( + // We only push to history once all currently known tools in the turn are terminal. + // This allows ToolGroupDisplay to correctly hoist ALL notices (topics) for the turn. + const allTerminal = trackedTools.every( + (tc) => tc.status === 'success' || tc.status === 'error' || - tc.status === 'cancelled' - ) { - toolsToPush.push(tc); - } else { - break; - } - } + tc.status === 'cancelled', + ); - if (toolsToPush.length > 0) { + const toolsToPush = trackedTools.filter( + (tc) => !pushedToolCallIdsRef.current.has(tc.callId), + ); + + if (allTerminal && toolsToPush.length > 0) { const newPushed = new Set(pushedToolCallIdsRef.current); for (const tc of toolsToPush) { newPushed.add(tc.callId); } - const isLastInBatch = - toolsToPush[toolsToPush.length - 1] === - trackedTools[trackedTools.length - 1]; - const appearance = getToolGroupBorderAppearance( { type: 'tool_group', tools: trackedTools }, activePtyId, @@ -442,24 +439,43 @@ export const useAgentStream = ({ backgroundTasks, ); - const historyItem: HistoryItemToolGroup = { - type: 'tool_group', - tools: toolsToPush, - borderTop: isFirstToolInGroupRef.current, - borderBottom: isLastInBatch, + const hasBoxInBatch = toolsToPush.some( + (tc) => tc.display?.format !== 'notice', + ); + const shouldStartNewBlock = + isFirstToolInGroupRef.current || + (!hasEmittedBoxInTurnRef.current && hasBoxInBatch); + + const historyItem: HistoryItemToolDisplayGroup = { + type: 'tool_display_group', + tools: toolsToPush.map((tc) => ({ + name: tc.name, + description: tc.description, + ...tc.display, + status: tc.status, + originalRequestName: tc.originalRequestName, + })), + borderTop: shouldStartNewBlock, + borderBottom: true, ...appearance, }; addItem(historyItem); setPushedToolCallIds(newPushed); + + if (hasBoxInBatch) { + setHasEmittedBoxInTurn(true); + } setIsFirstToolInGroup(false); } }, [ trackedTools, pushedToolCallIdsRef, isFirstToolInGroupRef, + hasEmittedBoxInTurnRef, setPushedToolCallIds, setIsFirstToolInGroup, + setHasEmittedBoxInTurn, addItem, activePtyId, isShellFocused, @@ -468,7 +484,7 @@ export const useAgentStream = ({ const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { const remainingTools = trackedTools.filter( - (tc) => !pushedToolCallIds.has(tc.callId), + (tc) => !pushedToolCallIdsRef.current.has(tc.callId), ); const items: HistoryItemWithoutId[] = []; @@ -482,10 +498,23 @@ export const useAgentStream = ({ ); if (remainingTools.length > 0) { + const hasBoxInPending = remainingTools.some( + (tc) => tc.display?.format !== 'notice', + ); + const shouldStartNewBlock = + pushedToolCallIds.size === 0 || + (!hasEmittedBoxInTurnRef.current && hasBoxInPending); + items.push({ - type: 'tool_group', - tools: remainingTools, - borderTop: pushedToolCallIds.size === 0, + type: 'tool_display_group', + tools: remainingTools.map((tc) => ({ + name: tc.name, + description: tc.description, + ...tc.display, + status: tc.status, + originalRequestName: tc.originalRequestName, + })), + borderTop: shouldStartNewBlock, borderBottom: false, ...appearance, }); @@ -513,7 +542,7 @@ export const useAgentStream = ({ (anyVisibleInHistory || anyVisibleInPending) ) { items.push({ - type: 'tool_group' as const, + type: 'tool_display_group', tools: [], borderTop: false, borderBottom: true, @@ -525,6 +554,8 @@ export const useAgentStream = ({ }, [ trackedTools, pushedToolCallIds, + pushedToolCallIdsRef, + hasEmittedBoxInTurnRef, activePtyId, isShellFocused, backgroundTasks, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index cdaf37e342..7cb204a339 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -260,6 +260,20 @@ export type HistoryItemToolGroup = HistoryItemBase & { borderDimColor?: boolean; }; +export type ToolDisplayItem = ToolDisplay & { + status: CoreToolCallStatus; + originalRequestName?: string; +}; + +export type HistoryItemToolDisplayGroup = HistoryItemBase & { + type: 'tool_display_group'; + tools: ToolDisplayItem[]; + borderTop?: boolean; + borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; +}; + export type HistoryItemUserShell = HistoryItemBase & { type: 'user_shell'; text: string; @@ -406,6 +420,7 @@ export type HistoryItemWithoutId = | HistoryItemAbout | HistoryItemHelp | HistoryItemToolGroup + | HistoryItemToolDisplayGroup | HistoryItemStats | HistoryItemModelStats | HistoryItemToolStats diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index fe8a73a31d..f60822a8e6 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -236,6 +236,7 @@ export function translateEvent( requestId: event.value.callId, name: event.value.name, args: event.value.args, + display: event.value.display, }), ); break; @@ -243,13 +244,15 @@ export function translateEvent( case GeminiEventType.ToolCallResponse: { ensureStreamStart(state, out); const data = buildToolResponseData(event.value); - const display: ToolDisplay | undefined = event.value.resultDisplay - ? { - result: toolResultDisplayToDisplayContent( - event.value.resultDisplay, - ), - } - : undefined; + const display: ToolDisplay | undefined = + event.value.display ?? + (event.value.resultDisplay + ? { + result: toolResultDisplayToDisplayContent( + event.value.resultDisplay, + ), + } + : undefined); out.push( makeEvent('tool_response', state, { requestId: event.value.callId, @@ -279,7 +282,6 @@ export function translateEvent( ((x: never) => { throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); })(event); - break; } return out; diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 1f24e06c6c..db3c173983 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -102,7 +102,10 @@ function makeCompletedToolCall( response: { callId, responseParts: [{ text: responseText }], - resultDisplay: undefined, + resultDisplay: responseText, + display: { + result: { type: 'text', text: responseText }, + }, error: undefined, errorType: undefined, }, @@ -426,6 +429,12 @@ describe('LegacyAgentSession', () => { (e): e is AgentEvent<'tool_response'> => e.type === 'tool_response', ); expect(toolResp?.name).toBe('read_file'); + expect(toolResp?.display).toEqual( + expect.objectContaining({ + name: 'read_file', + result: { type: 'text', text: 'file contents' }, + }), + ); expect(toolResp?.content).toEqual([ { type: 'text', text: 'file contents' }, ]); diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 4cf2e4d7f6..d65c583b0b 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -266,6 +266,7 @@ export class LegacyAgentProtocol implements AgentProtocol { invocation: 'invocation' in tc ? tc.invocation : undefined, resultDisplay: response.resultDisplay, displayName: 'tool' in tc ? tc.tool?.displayName : undefined, + display: response.display, }); const data = buildToolResponseData(response); diff --git a/packages/core/src/agent/tool-display-utils.ts b/packages/core/src/agent/tool-display-utils.ts index efdf2aa35e..070cd254d0 100644 --- a/packages/core/src/agent/tool-display-utils.ts +++ b/packages/core/src/agent/tool-display-utils.ts @@ -21,18 +21,21 @@ export function populateToolDisplay({ invocation, resultDisplay, displayName, + display: prevDisplay, }: { name: string; invocation?: ToolInvocation; resultDisplay?: ToolResultDisplay; displayName?: string; + display?: ToolDisplay; }): ToolDisplay { const display: ToolDisplay = { name: displayName || name, description: invocation?.getDescription?.(), + ...prevDisplay, }; - if (resultDisplay) { + if (resultDisplay !== undefined && display.result === undefined) { display.result = toolResultDisplayToDisplayContent(resultDisplay); } @@ -91,7 +94,7 @@ export function renderDisplayDiff(diff: DisplayDiff): string { * Useful for fallback displays or non-interactive environments. */ export function displayContentToString( - display: DisplayContent | undefined, + display: DisplayContent | undefined | null, ): string | undefined { if (!display) { return undefined; diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index af48973f8f..0d41c46602 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Kind } from '../tools/tools.js'; export type WithMeta = { _meta?: Record }; @@ -182,13 +183,48 @@ export type DisplayDiff = { beforeText: string; afterText: string; }; -export type DisplayContent = DisplayText | DisplayDiff; +export type DisplayTerminal = { + type: 'terminal'; + pid?: string; + exitCode?: number; + ansi?: AnsiOutput; +}; +export type DisplayAgent = { + type: 'agent'; + threadId: string; +}; + +export type DisplayContent = + | DisplayText + | DisplayDiff + | DisplayTerminal + | DisplayAgent; + +export type ToolDisplayFormat = + /** + * Displays as compact when user has enabled compact tools, box otherwise. + * This is the default format if none is selected. + **/ + | 'auto' + /** Always display this tool in compact format. */ + | 'compact' + /** Always display this tool in full box format. */ + | 'box' + /** Hide this tool from the event history. */ + | 'hidden' + /** Display this tool as a message-like notice. */ + | 'notice'; export interface ToolDisplay { + /** A display name for the tool. */ name?: string; + /** A short description of what the tool is doing. */ description?: string; - resultSummary?: string; - result?: DisplayContent; + /** A short, one-line summary of the tool's results. */ + resultSummary?: string | null; + result?: DisplayContent | null; + /** A tool may specify its preferred display format. */ + format?: ToolDisplayFormat; } export interface ToolRequest { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ec69d00518..f74ae4d7f5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3694,11 +3694,17 @@ export class Config implements McpContext, AgentLoopContext { } getAgentSessionNoninteractiveEnabled(): boolean { - return this.agentSessionNoninteractiveEnabled; + return ( + process.env['GEMINI_CLI_EXP_AGENT'] === 'true' || + this.agentSessionNoninteractiveEnabled + ); } getAgentSessionInteractiveEnabled(): boolean { - return this.agentSessionInteractiveEnabled; + return ( + process.env['GEMINI_CLI_EXP_AGENT'] === 'true' || + this.agentSessionInteractiveEnabled + ); } /** diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index f973988ad1..398214a028 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -284,6 +284,10 @@ export class GeminiChat { ); } + get loopContext(): AgentLoopContext { + return this.context; + } + async initialize( resumedSessionData?: ResumedSessionData, kind: 'main' | 'subagent' = 'main', diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 6cc904c7d7..9a9fce2f75 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -49,6 +49,11 @@ describe('Turn', () => { getHistory: typeof mockGetHistory; maybeIncludeSchemaDepthContext: typeof mockMaybeIncludeSchemaDepthContext; context: { config: { isContextManagementEnabled: () => boolean } }; + loopContext?: { + toolRegistry: { + getTool: (name: string) => unknown; + }; + }; }; let mockChatInstance: MockedChatInstance; @@ -63,6 +68,11 @@ describe('Turn', () => { isContextManagementEnabled: () => false, }, }, + loopContext: { + toolRegistry: { + getTool: vi.fn().mockReturnValue(undefined), + }, + }, }; turn = new Turn(mockChatInstance as unknown as GeminiChat, 'prompt-id-1'); mockGetHistory.mockReturnValue([]); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 2c5f894a33..13ea724569 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -29,6 +29,7 @@ import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { getCitations } from '../utils/generateContentResponseUtilities.js'; import { LlmRole } from '../telemetry/types.js'; +import { populateToolDisplay } from '../agent/tool-display-utils.js'; import { type ToolCallRequestInfo, @@ -408,17 +409,40 @@ export class Turn { traceId?: string, ): ServerGeminiStreamEvent | null { const name = fnCall.name || 'undefined_tool_name'; - const args = fnCall.args || {}; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const args = (fnCall.args as Record) || {}; const callId = fnCall.id ?? (this.chat.context.config.isContextManagementEnabled() ? `synth_${this.prompt_id}_${Date.now()}_${this.callCounter++}` : `${name}_${Date.now()}_${this.callCounter++}`); + const tool = this.chat.loopContext.toolRegistry.getTool(name); + let display; + if (tool) { + let invocation; + try { + invocation = tool.build(args); + } catch { + // Ignore build errors for request display purposes + } + display = populateToolDisplay({ + name, + invocation, + displayName: tool.displayName, + }); + + // Fallback to static description if invocation failed or didn't provide one + if (!display.description) { + display.description = tool.description; + } + } + const toolCallRequest: ToolCallRequestInfo = { callId, name, args, + display, isClientInitiated: false, prompt_id: this.prompt_id, traceId, diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index b7b6bbf96a..9b5935cdbe 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -407,7 +407,7 @@ describe('Scheduler (Orchestrator)', () => { expect.arrayContaining([ expect.objectContaining({ status: CoreToolCallStatus.Validating, - request: req1, + request: expect.objectContaining(req1), tool: mockTool, invocation: mockInvocation, schedulerId: ROOT_SCHEDULER_ID, diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index 709bdc2bf5..801d1e2b2d 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -36,6 +36,7 @@ import { getToolSuggestion } from '../utils/tool-utils.js'; import { runInDevTraceSpan } from '../telemetry/trace.js'; import { logToolCall } from '../telemetry/loggers.js'; import { ToolCallEvent } from '../telemetry/types.js'; +import { populateToolDisplay } from '../agent/tool-display-utils.js'; import type { EditorType } from '../utils/editor.js'; import { MessageBusType, @@ -381,6 +382,16 @@ export class Scheduler { () => { try { const invocation = tool.build(request.args); + if (!request.display) { + request.display = populateToolDisplay({ + name: tool.name, + invocation, + displayName: tool.displayName, + }); + if (!request.display.description) { + request.display.description = tool.description; + } + } return { status: CoreToolCallStatus.Validating, request, diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index c524a139bd..6183be031c 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -23,6 +23,7 @@ import type { ToolConfirmationOutcome, ToolResultDisplay, AnyToolInvocation, + ToolDisplay, ToolCallConfirmationDetails, AnyDeclarativeTool, } from '../tools/tools.js'; @@ -172,10 +173,15 @@ export class SchedulerStateManager { const call = this.activeCalls.get(callId); if (!call || call.status === CoreToolCallStatus.Error) return; + const display: ToolDisplay = call.request.display + ? { ...call.request.display } + : { name: call.request.name }; + display.description = newInvocation.getDescription(); + this.activeCalls.set( callId, this.patchCall(call, { - request: { ...call.request, args: newArgs }, + request: { ...call.request, args: newArgs, display }, invocation: newInvocation, }), ); diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 3d9ad1e063..c79f0acd14 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -12,6 +12,7 @@ import { type ToolCallRequestInfo, type ToolCallResponseInfo, type ToolResult, + type ToolDisplay, type Config, type AgentLoopContext, type ToolLiveOutput, @@ -160,6 +161,7 @@ export class ToolExecutor { toolResult.error.type, displayText, toolResult.tailToolCallRequest, + toolResult.display, ); } } catch (executionError: unknown) { @@ -350,6 +352,7 @@ export class ToolExecutor { response: { callId: call.request.callId, responseParts, + display: toolResult?.display, resultDisplay: toolResult?.returnDisplay, error: undefined, errorType: undefined, @@ -386,6 +389,7 @@ export class ToolExecutor { const successResponse: ToolCallResponseInfo = { callId, responseParts: response, + display: toolResult.display, resultDisplay: toolResult.returnDisplay, error: undefined, errorType: undefined, @@ -420,12 +424,14 @@ export class ToolExecutor { errorType?: ToolErrorType, returnDisplay?: string, tailToolCallRequest?: { name: string; args: Record }, + display?: ToolDisplay, ): ErroredToolCall { const response = this.createErrorResponse( call.request, error, errorType, returnDisplay, + display, ); const startTime = 'startTime' in call ? call.startTime : undefined; @@ -447,11 +453,13 @@ export class ToolExecutor { error: Error, errorType: ToolErrorType | undefined, returnDisplay?: string, + display?: ToolDisplay, ): ToolCallResponseInfo { const displayText = returnDisplay ?? error.message; return { callId: request.callId, error, + display, responseParts: [ { functionResponse: { diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 170aab67ca..3173b76f8d 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -12,6 +12,7 @@ import type { ToolConfirmationOutcome, ToolResultDisplay, ToolLiveOutput, + ToolDisplay, } from '../tools/tools.js'; import type { ToolErrorType } from '../tools/tool-error.js'; import type { SerializableConfirmationDetails } from '../confirmation-bus/types.js'; @@ -36,6 +37,8 @@ export interface ToolCallRequestInfo { callId: string; name: string; args: Record; + /** Tool-controlled display information. */ + display?: ToolDisplay; /** * The original name and arguments of the tool requested by the model. * This is used for tail calls to ensure the final response and log retains @@ -56,6 +59,8 @@ export interface ToolCallRequestInfo { export interface ToolCallResponseInfo { callId: string; responseParts: Part[]; + /** Tool-controlled display information. */ + display?: ToolDisplay; resultDisplay: ToolResultDisplay | undefined; error: Error | undefined; errorType: ToolErrorType | undefined; diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index c05300f571..4077a6cd41 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -720,6 +720,17 @@ function doIt() { }); expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.display).toEqual( + expect.objectContaining({ + name: 'Edit', + resultSummary: expect.stringContaining('added'), + result: expect.objectContaining({ + type: 'diff', + beforeText: initialContent, + afterText: newContent, + }), + }), + ); expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); const display = result.returnDisplay as FileDiff; expect(display.fileDiff).toMatch(initialContent); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index e1820cb3f6..3f6d5d9f62 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -22,6 +22,7 @@ import { type ToolResultDisplay, type PolicyUpdateOptions, type ExecuteOptions, + type FileDiff, } from './tools.js'; import { buildFilePathArgsPattern } from '../policy/utils.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -431,6 +432,12 @@ export function isEditToolParams(args: unknown): args is EditToolParams { ); } +function fileDiffToSummary(diff: FileDiff, editData: CalculatedEdit) { + return diff.diffStat + ? `${diff.diffStat.model_added_lines} added, ${diff.diffStat.model_removed_lines} removed` + : `${editData.occurrences} replacements`; +} + interface CalculatedEdit { currentContent: string | null; newContent: string; @@ -995,8 +1002,24 @@ ${snippet}`); llmContent = appendJitContext(llmContent, jitContext); } + const resultSummary = + typeof displayResult === 'string' + ? displayResult + : fileDiffToSummary(displayResult, editData); + return { llmContent, + display: { + name: this._toolDisplayName, + description: this.getDescription(), + resultSummary, + result: { + type: 'diff', + path: this.resolvedPath, + beforeText: editData.currentContent ?? '', + afterText: editData.newContent, + }, + }, returnDisplay: displayResult, }; } catch (error) { diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index d89da94aab..8d1804886f 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -284,12 +284,24 @@ class GrepToolInvocation extends BaseToolInvocation< searchLocationDescription = `in path "${searchDirDisplay}"`; } - return await formatGrepResults( + const result = await formatGrepResults( allMatches, this.params, searchLocationDescription, totalMaxMatches, ); + return { + ...result, + display: { + name: this._toolDisplayName, + description: this.getDescription(), + resultSummary: result.returnDisplay.summary, + result: { + type: 'text', + text: result.llmContent.split('\n---\n').slice(1).join('\n---\n'), + }, + }, + }; } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index ea66028071..c2e1a593bc 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -284,6 +284,11 @@ class LSToolInvocation extends BaseToolInvocation { return { llmContent: resultMessage, + display: { + name: LS_DISPLAY_NAME, + description: this.getDescription(), + resultSummary: displayMessage, + }, returnDisplay: { summary: displayMessage, files: entries.map( diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 78563b94f3..bc58397a93 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -237,10 +237,18 @@ describe('ReadFileTool', () => { const params: ReadFileToolParams = { file_path: 'textfile.txt' }; const invocation = tool.build(params); - expect(await invocation.execute({ abortSignal })).toEqual({ - llmContent: fileContent, - returnDisplay: '', - }); + const result = await invocation.execute({ abortSignal }); + expect(result).toEqual( + expect.objectContaining({ + llmContent: fileContent, + returnDisplay: '', + display: expect.objectContaining({ + name: 'ReadFile', + description: expect.stringContaining('textfile.txt'), + resultSummary: '1 lines', + }), + }), + ); }); it('should return error if file does not exist', async () => { @@ -267,10 +275,18 @@ describe('ReadFileTool', () => { const params: ReadFileToolParams = { file_path: filePath }; const invocation = tool.build(params); - expect(await invocation.execute({ abortSignal })).toEqual({ - llmContent: fileContent, - returnDisplay: '', - }); + const result = await invocation.execute({ abortSignal }); + expect(result).toEqual( + expect.objectContaining({ + llmContent: fileContent, + returnDisplay: '', + display: expect.objectContaining({ + name: 'ReadFile', + description: expect.stringContaining('textfile.txt'), + resultSummary: '1 lines', + }), + }), + ); }); it('should return error if path is a directory', async () => { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index ae48f2387a..ee50cff97e 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -186,8 +186,20 @@ ${result.llmContent}`; } } + const displayResultSummary = result.isTruncated + ? `${result.linesShown![0]}-${result.linesShown![1]} of ${result.originalLineCount}` + : lines !== undefined + ? `${lines} lines` + : undefined; + return { llmContent, + display: { + name: READ_FILE_DISPLAY_NAME, + description: this.getDescription(), + resultSummary: displayResultSummary, + result: { type: 'text', text: result.returnDisplay || '' }, + }, returnDisplay: result.returnDisplay || '', }; } diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index c2ae482289..861b4b0b84 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -301,12 +301,24 @@ class GrepToolInvocation extends BaseToolInvocation< const searchLocationDescription = `in path "${searchDirDisplay}"`; - return await formatGrepResults( + const result = await formatGrepResults( allMatches, this.params, searchLocationDescription, totalMaxMatches, ); + return { + ...result, + display: { + name: this._toolDisplayName, + description: this.getDescription(), + resultSummary: result.returnDisplay.summary, + result: { + type: 'text', + text: result.llmContent.split('\n---\n').slice(1).join('\n---\n'), + }, + }, + }; } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index dd49a9c800..3adf9ea6d1 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -504,6 +504,13 @@ EOF`; const result = await promise; expect(result.llmContent).toContain('Error: wrapped command failed'); expect(result.llmContent).not.toContain('pgrep'); + expect(result.display).toEqual( + expect.objectContaining({ + name: 'Shell', + description: 'user-command', + resultSummary: 'Exit Code: 1', + }), + ); }); it('should return a SHELL_EXECUTE_ERROR for a command failure', async () => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 7be9a4f26f..4c695ab5ee 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -942,8 +942,24 @@ export class ShellToolInvocation extends BaseToolInvocation< }; } + const displayResultSummary = result.backgrounded + ? `PID: ${result.pid}` + : result.exitCode !== null && result.exitCode !== 0 + ? `Exit Code: ${result.exitCode}` + : undefined; + return { llmContent, + display: { + name: 'Shell', + description: this.getDescription(), + resultSummary: displayResultSummary, + result: + typeof returnDisplay === 'string' + ? { type: 'text', text: returnDisplay } + : // TODO: Add support for terminal display type (AnsiOutput) + undefined, + }, returnDisplay, data, ...executionError, diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index cd6209079c..42bc2c2738 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -740,6 +740,10 @@ export function isTool(obj: unknown): obj is AnyDeclarativeTool { } export interface ToolResult { + /** + * Tool-controlled display information. + */ + display?: ToolDisplay; /** * Content meant to be included in LLM history. * This should represent the factual outcome of the tool execution. @@ -1084,6 +1088,9 @@ export type ToolCallConfirmationDetails = | ToolAskUserConfirmationDetails | ToolExitPlanModeConfirmationDetails; +import type { ToolDisplay } from '../agent/types.js'; +export type { ToolDisplay }; + export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', ProceedAlways = 'proceed_always', diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts index 2b298159d1..f0cb328b0a 100644 --- a/packages/core/src/tools/topicTool.ts +++ b/packages/core/src/tools/topicTool.ts @@ -93,6 +93,11 @@ class UpdateTopicInvocation extends BaseToolInvocation< return { llmContent, + display: { + format: 'notice', + name: title || UPDATE_TOPIC_DISPLAY_NAME, + description: this.getDescription(), + }, returnDisplay, }; } diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 68dbe533b1..b35d26fe20 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -678,6 +678,16 @@ describe('WriteFileTool', () => { expect(result.llmContent).toMatch( /Successfully created and wrote to new file/, ); + expect(result.display).toEqual( + expect.objectContaining({ + name: 'WriteFile', + resultSummary: expect.stringContaining('added'), + result: expect.objectContaining({ + type: 'diff', + afterText: content, + }), + }), + ); expect(fs.existsSync(filePath)).toBe(true); const writtenContent = await fsService.readTextFile(filePath); expect(writtenContent).toBe(content); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 34cef70772..9ec19b879f 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -430,6 +430,19 @@ class WriteFileToolInvocation extends BaseToolInvocation< return { llmContent, + display: { + name: WRITE_FILE_DISPLAY_NAME, + description: this.getDescription(), + resultSummary: diffStat + ? `${diffStat.model_added_lines} added, ${diffStat.model_removed_lines} removed` + : 'Written', + result: { + type: 'diff', + path: this.resolvedPath, + beforeText: correctedContentResult.originalContent ?? '', + afterText: correctedContentResult.correctedContent, + }, + }, returnDisplay: displayResult, }; } catch (error) {