From 7f7e69e6c6e3a895bdaccfaa7c4e520c9a40d72c Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 10 Apr 2026 10:51:21 -0700 Subject: [PATCH 01/21] feat(agent): implement tool-controlled display protocol (Steps 2-3) --- .../cli/src/nonInteractiveCliAgentSession.ts | 10 ++- packages/cli/src/ui/hooks/useAgentStream.ts | 11 +-- packages/cli/src/ui/types.ts | 2 + packages/core/src/agent/content-utils.test.ts | 22 ------ packages/core/src/agent/content-utils.ts | 18 ----- .../core/src/agent/event-translator.test.ts | 23 +++--- packages/core/src/agent/event-translator.ts | 15 ++-- .../src/agent/legacy-agent-session.test.ts | 7 +- .../core/src/agent/legacy-agent-session.ts | 13 ++-- .../core/src/agent/tool-display-utils.test.ts | 71 ++++++++++++++++++ packages/core/src/agent/tool-display-utils.ts | 72 +++++++++++++++++++ packages/core/src/agent/types.ts | 29 ++++++-- packages/core/src/index.ts | 9 +++ 13 files changed, 229 insertions(+), 73 deletions(-) create mode 100644 packages/core/src/agent/tool-display-utils.test.ts create mode 100644 packages/core/src/agent/tool-display-utils.ts diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index 7f36ce6cf59..29830b8e96c 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -470,7 +470,10 @@ export async function runNonInteractive({ case 'tool_response': { textOutput.ensureTrailingNewline(); if (streamFormatter) { - const displayText = getTextContent(event.displayContent); + const displayText = + event.display?.result?.type === 'text' + ? event.display.result.text + : undefined; const errorMsg = getTextContent(event.content) ?? 'Tool error'; streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_RESULT, @@ -490,7 +493,10 @@ export async function runNonInteractive({ }); } if (event.isError) { - const displayText = getTextContent(event.displayContent); + const displayText = + event.display?.result?.type === 'text' + ? event.display.result.text + : undefined; const errorMsg = getTextContent(event.content) ?? 'Tool error'; if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) { diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 81dbb1e9e9f..e978cead6ee 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -197,6 +197,7 @@ export const useAgentStream = ({ name: displayName, originalRequestName: event.name, description: desc, + display: event.display, status: CoreToolCallStatus.Scheduled, isClientInitiated: false, renderOutputAsMarkdown: isOutputMarkdown, @@ -223,8 +224,8 @@ export const useAgentStream = ({ status = CoreToolCallStatus.Success; const liveOutput = - event.displayContent?.[0]?.type === 'text' - ? event.displayContent[0].text + event.display?.result?.type === 'text' + ? event.display.result.text : tc.resultDisplay; const progressMessage = legacyState?.progressMessage ?? tc.progressMessage; @@ -237,6 +238,7 @@ export const useAgentStream = ({ return { ...tc, status, + display: event.display ?? tc.display, resultDisplay: liveOutput, progressMessage, progress, @@ -256,8 +258,8 @@ export const useAgentStream = ({ const legacyState = event._meta?.legacyState; const outputFile = legacyState?.outputFile; const resultDisplay = - event.displayContent?.[0]?.type === 'text' - ? event.displayContent[0].text + event.display?.result?.type === 'text' + ? event.display.result.text : tc.resultDisplay; return { @@ -265,6 +267,7 @@ export const useAgentStream = ({ status: event.isError ? CoreToolCallStatus.Error : CoreToolCallStatus.Success, + display: event.display ?? tc.display, resultDisplay, outputFile, }; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 6fbc3151d8e..1ded2ae643e 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -11,6 +11,7 @@ import { type ThoughtSummary, type SerializableConfirmationDetails, type ToolResultDisplay, + type ToolDisplay, type RetrieveUserQuotaResponse, type SkillDefinition, type AgentDefinition, @@ -121,6 +122,7 @@ export interface IndividualToolCallDisplay { name: string; args?: Record; description: string; + display?: ToolDisplay; resultDisplay: ToolResultDisplay | undefined; status: CoreToolCallStatus; // True when the tool was initiated directly by the user (slash/@/shell flows). diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 96608c82275..7de54c56fa6 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -8,7 +8,6 @@ import { describe, expect, it } from 'vitest'; import { geminiPartsToContentParts, contentPartsToGeminiParts, - toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; import type { Part } from '@google/genai'; @@ -200,27 +199,6 @@ describe('contentPartsToGeminiParts', () => { }); }); -describe('toolResultDisplayToContentParts', () => { - it('returns undefined for undefined', () => { - expect(toolResultDisplayToContentParts(undefined)).toBeUndefined(); - }); - - it('returns undefined for null', () => { - expect(toolResultDisplayToContentParts(null)).toBeUndefined(); - }); - - it('handles string resultDisplay as-is', () => { - const result = toolResultDisplayToContentParts('File written'); - expect(result).toEqual([{ type: 'text', text: 'File written' }]); - }); - - it('stringifies object resultDisplay', () => { - const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' }; - const result = toolResultDisplayToContentParts(display); - expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]); - }); -}); - describe('buildToolResponseData', () => { it('preserves outputFile and contentLength', () => { const result = buildToolResponseData({ diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index b117ab69fca..aaf191fe8e6 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -101,24 +101,6 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { return result; } -/** - * Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[]. - * Handles string, object-valued (FileDiff, SubagentProgress, etc.), - * and undefined resultDisplay consistently. - */ -export function toolResultDisplayToContentParts( - resultDisplay: unknown, -): ContentPart[] | undefined { - if (resultDisplay === undefined || resultDisplay === null) { - return undefined; - } - const text = - typeof resultDisplay === 'string' - ? resultDisplay - : JSON.stringify(resultDisplay); - return [{ type: 'text', text }]; -} - /** * Builds the data record for a tool_response AgentEvent, preserving * all available metadata from the ToolCallResponseInfo. diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts index be9d8ea40eb..cfb5cfe3000 100644 --- a/packages/core/src/agent/event-translator.test.ts +++ b/packages/core/src/agent/event-translator.test.ts @@ -155,9 +155,10 @@ describe('translateEvent', () => { expect(resp.content).toEqual([ { type: 'text', text: 'Permission denied to write' }, ]); - expect(resp.displayContent).toEqual([ - { type: 'text', text: 'Permission denied' }, - ]); + expect(resp.display?.result).toEqual({ + type: 'text', + text: 'Permission denied', + }); expect(resp.data).toEqual({ errorType: 'permission_denied' }); }); @@ -200,9 +201,12 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const resp = result[0] as AgentEvent<'tool_response'>; - expect(resp.displayContent).toEqual([ - { type: 'text', text: JSON.stringify(objectDisplay) }, - ]); + expect(resp.display?.result).toEqual({ + type: 'diff', + path: '/tmp/test.txt', + beforeText: 'a', + afterText: 'b', + }); }); it('passes through string resultDisplay as-is', () => { @@ -220,9 +224,10 @@ describe('translateEvent', () => { }; const result = translateEvent(event, state); const resp = result[0] as AgentEvent<'tool_response'>; - expect(resp.displayContent).toEqual([ - { type: 'text', text: 'Command output text' }, - ]); + expect(resp.display?.result).toEqual({ + type: 'text', + text: 'Command output text', + }); }); it('preserves outputFile and contentLength in data', () => { diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index cb299b494c2..dee56adbd0f 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -25,12 +25,13 @@ import type { ErrorData, Usage, AgentEventType, + ToolDisplay, } from './types.js'; import { geminiPartsToContentParts, - toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; +import { toolResultDisplayToDisplayContent } from './tool-display-utils.js'; // --------------------------------------------------------------------------- // Translation State @@ -241,10 +242,14 @@ export function translateEvent( case GeminiEventType.ToolCallResponse: { ensureStreamStart(state, out); - const displayContent = toolResultDisplayToContentParts( - event.value.resultDisplay, - ); const data = buildToolResponseData(event.value); + const display: ToolDisplay | undefined = event.value.resultDisplay + ? { + result: toolResultDisplayToDisplayContent( + event.value.resultDisplay, + ), + } + : undefined; out.push( makeEvent('tool_response', state, { requestId: event.value.callId, @@ -253,7 +258,7 @@ export function translateEvent( ? [{ type: 'text', text: event.value.error.message }] : geminiPartsToContentParts(event.value.responseParts), isError: event.value.error !== undefined, - ...(displayContent ? { displayContent } : {}), + ...(display ? { display } : {}), ...(data ? { data } : {}), }), ); diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 1de5d90e20a..9ee8b032ad2 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -489,9 +489,10 @@ describe('LegacyAgentSession', () => { expect(toolResp?.content).toEqual([ { type: 'text', text: 'Permission denied' }, ]); - expect(toolResp?.displayContent).toEqual([ - { type: 'text', text: 'Error display' }, - ]); + expect(toolResp?.display?.result).toEqual({ + type: 'text', + text: 'Error display', + }); }); it('stops on STOP_EXECUTION tool error', async () => { diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 94763c7d402..5fb024378e1 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -23,8 +23,8 @@ import { buildToolResponseData, contentPartsToGeminiParts, geminiPartsToContentParts, - toolResultDisplayToContentParts, } from './content-utils.js'; +import { populateToolDisplay } from './tool-display-utils.js'; import { AgentSession } from './agent-session.js'; import { createTranslationState, @@ -262,9 +262,12 @@ export class LegacyAgentProtocol implements AgentProtocol { const content: ContentPart[] = response.error ? [{ type: 'text', text: response.error.message }] : geminiPartsToContentParts(response.responseParts); - const displayContent = toolResultDisplayToContentParts( - response.resultDisplay, - ); + const display = populateToolDisplay({ + name: request.name, + invocation: 'invocation' in tc ? tc.invocation : undefined, + resultDisplay: response.resultDisplay, + displayName: 'tool' in tc ? tc.tool?.displayName : undefined, + }); const data = buildToolResponseData(response); this._emit([ @@ -273,7 +276,7 @@ export class LegacyAgentProtocol implements AgentProtocol { name: request.name, content, isError: response.error !== undefined, - ...(displayContent ? { displayContent } : {}), + ...(display ? { display } : {}), ...(data ? { data } : {}), }), ]); diff --git a/packages/core/src/agent/tool-display-utils.test.ts b/packages/core/src/agent/tool-display-utils.test.ts new file mode 100644 index 00000000000..de88870a7c7 --- /dev/null +++ b/packages/core/src/agent/tool-display-utils.test.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + ToolInvocation, + ToolResult, + ToolResultDisplay, +} from '../tools/tools.js'; +import { populateToolDisplay } from './tool-display-utils.js'; + +describe('tool-display-utils', () => { + describe('populateToolDisplay', () => { + it('uses displayName if provided', () => { + const mockInvocation = { + getDescription: () => 'Doing something...', + } as unknown as ToolInvocation; + + const display = populateToolDisplay({ + name: 'raw-name', + invocation: mockInvocation, + displayName: 'Custom Display Name', + }); + expect(display.name).toBe('Custom Display Name'); + expect(display.description).toBe('Doing something...'); + }); + + it('falls back to raw name if no displayName provided', () => { + const mockInvocation = { + getDescription: () => 'Doing something...', + } as unknown as ToolInvocation; + + const display = populateToolDisplay({ + name: 'raw-name', + invocation: mockInvocation, + }); + expect(display.name).toBe('raw-name'); + }); + + it('populates result from resultDisplay', () => { + const display = populateToolDisplay({ + name: 'test', + resultDisplay: 'hello world', + }); + expect(display.result).toEqual({ type: 'text', text: 'hello world' }); + }); + + it('translates FileDiff to DisplayDiff', () => { + const fileDiff = { + fileDiff: '@@ ...', + fileName: 'test.ts', + filePath: 'src/test.ts', + originalContent: 'old', + newContent: 'new', + } as unknown as ToolResultDisplay; + const display = populateToolDisplay({ + name: 'test', + resultDisplay: fileDiff, + }); + expect(display.result).toEqual({ + type: 'diff', + path: 'src/test.ts', + beforeText: 'old', + afterText: 'new', + }); + }); + }); +}); diff --git a/packages/core/src/agent/tool-display-utils.ts b/packages/core/src/agent/tool-display-utils.ts new file mode 100644 index 00000000000..f5327d6a1b3 --- /dev/null +++ b/packages/core/src/agent/tool-display-utils.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ToolInvocation, + ToolResult, + ToolResultDisplay, +} from '../tools/tools.js'; +import type { ToolDisplay, DisplayContent } from './types.js'; + +/** + * Populates a ToolDisplay object from a tool invocation and its result. + * This serves as a centralized bridge during the migration to tool-controlled display. + */ +export function populateToolDisplay({ + name, + invocation, + resultDisplay, + displayName, +}: { + name: string; + invocation?: ToolInvocation; + resultDisplay?: ToolResultDisplay; + displayName?: string; +}): ToolDisplay { + const display: ToolDisplay = { + name: displayName || name, + description: invocation?.getDescription?.(), + }; + + if (resultDisplay) { + display.result = toolResultDisplayToDisplayContent(resultDisplay); + } + + return display; +} + +/** + * Converts a legacy ToolResultDisplay into the new DisplayContent format. + */ +export function toolResultDisplayToDisplayContent( + resultDisplay: ToolResultDisplay, +): DisplayContent { + if (typeof resultDisplay === 'string') { + return { type: 'text', text: resultDisplay }; + } + + // Handle FileDiff -> DisplayDiff + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'fileDiff' in resultDisplay && + 'newContent' in resultDisplay + ) { + return { + type: 'diff', + path: resultDisplay.filePath || resultDisplay.fileName, + beforeText: resultDisplay.originalContent ?? '', + afterText: resultDisplay.newContent, + }; + } + + // Fallback for other structured types (LsTool, GrepTool, etc.) + // These will be fully migrated in Step 5. + return { + type: 'text', + text: JSON.stringify(resultDisplay), + }; +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 19837c138e0..af48973f8fb 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -106,7 +106,7 @@ export interface AgentEvents { /** Updates configuration about the current session/agent. */ session_update: SessionUpdate; /** Message content provided by user, agent, or developer. */ - message: Message; + message: AgentMessage; /** Event indicating the start of agent activity on a stream. */ agent_start: AgentStart; /** Event indicating the end of agent activity on a stream. */ @@ -170,17 +170,35 @@ export type ContentPart = ) & WithMeta; -export interface Message { +export interface AgentMessage { role: 'user' | 'agent' | 'developer'; content: ContentPart[]; } +export type DisplayText = { type: 'text'; text: string }; +export type DisplayDiff = { + type: 'diff'; + path?: string; + beforeText: string; + afterText: string; +}; +export type DisplayContent = DisplayText | DisplayDiff; + +export interface ToolDisplay { + name?: string; + description?: string; + resultSummary?: string; + result?: DisplayContent; +} + export interface ToolRequest { /** A unique identifier for this tool request to be correlated by the response. */ requestId: string; /** The name of the tool being requested. */ name: string; /** The arguments for the tool. */ + /** Tool-controlled display information. */ + display?: ToolDisplay; args: Record; /** UI specific metadata */ _meta?: { @@ -201,7 +219,8 @@ export interface ToolRequest { */ export interface ToolUpdate { requestId: string; - displayContent?: ContentPart[]; + /** Tool-controlled display information. */ + display?: ToolDisplay; content?: ContentPart[]; data?: Record; /** UI specific metadata */ @@ -221,8 +240,8 @@ export interface ToolUpdate { export interface ToolResponse { requestId: string; name: string; - /** Content representing the tool call's outcome to be presented to the user. */ - displayContent?: ContentPart[]; + /** Tool-controlled display information. */ + display?: ToolDisplay; /** Multi-part content to be sent to the model. */ content?: ContentPart[]; /** Structured data to be sent to the model. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04456a2964d..6faf3f0a605 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -193,6 +193,7 @@ export * from './agent/agent-session.js'; export * from './agent/legacy-agent-session.js'; export * from './agent/event-translator.js'; export * from './agent/content-utils.js'; +export * from './agent/tool-display-utils.js'; // Agent event types — namespaced to avoid collisions with existing exports export type { AgentEvent, @@ -204,6 +205,7 @@ export type { AgentProtocol, AgentSend, AgentStart, + AgentMessage, ContentPart, ErrorData, StreamEndReason, @@ -211,6 +213,13 @@ export type { Unsubscribe, Usage as AgentUsage, WithMeta, + ToolRequest, + ToolResponse, + ToolUpdate, + ToolDisplay, + DisplayText, + DisplayDiff, + DisplayContent, } from './agent/types.js'; // Export specific tool logic From fae963ff0efb7a7cd6cf79ec99979333ee7e1546 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 10 Apr 2026 11:42:37 -0700 Subject: [PATCH 02/21] fix(core,cli): handle structured tool display properly and prevent metadata overwrite This addresses PR feedback by: - Creating a `renderDisplayDiff` utility to render `DisplayDiff` objects. - Creating a `displayContentToString` utility to safely extract text from any `DisplayContent`. - Updating non-interactive CLI to use `displayContentToString` to avoid data loss on non-text output. - Updating `useAgentStream` to use `displayContentToString` to avoid stale UI state for non-text output. - Shallow merging the `display` object in `useAgentStream` rather than replacing it, preventing loss of display metadata. --- .../cli/src/nonInteractiveCliAgentSession.ts | 13 ++--- packages/cli/src/ui/hooks/useAgentStream.ts | 19 ++++--- .../core/src/agent/tool-display-utils.test.ts | 55 ++++++++++++++++++- packages/core/src/agent/tool-display-utils.ts | 36 +++++++++++- 4 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index 29830b8e96c..4fee7eb6102 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -37,6 +37,7 @@ import { LegacyAgentSession, ToolErrorType, geminiPartsToContentParts, + displayContentToString, debugLogger, } from '@google/gemini-cli-core'; @@ -470,10 +471,8 @@ export async function runNonInteractive({ case 'tool_response': { textOutput.ensureTrailingNewline(); if (streamFormatter) { - const displayText = - event.display?.result?.type === 'text' - ? event.display.result.text - : undefined; + const display = event.display?.result; + const displayText = displayContentToString(display); const errorMsg = getTextContent(event.content) ?? 'Tool error'; streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_RESULT, @@ -493,10 +492,8 @@ export async function runNonInteractive({ }); } if (event.isError) { - const displayText = - event.display?.result?.type === 'text' - ? event.display.result.text - : undefined; + const display = event.display?.result; + const displayText = displayContentToString(display); const errorMsg = getTextContent(event.content) ?? 'Tool error'; if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) { diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index e978cead6ee..4fb4a9c94fb 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -10,6 +10,7 @@ import { MessageSenderType, debugLogger, geminiPartsToContentParts, + displayContentToString, parseThought, CoreToolCallStatus, type ApprovalMode, @@ -223,10 +224,9 @@ export const useAgentStream = ({ else if (evtStatus === 'success') status = CoreToolCallStatus.Success; + const display = event.display?.result; const liveOutput = - event.display?.result?.type === 'text' - ? event.display.result.text - : tc.resultDisplay; + displayContentToString(display) ?? tc.resultDisplay; const progressMessage = legacyState?.progressMessage ?? tc.progressMessage; const progress = legacyState?.progress ?? tc.progress; @@ -238,7 +238,9 @@ export const useAgentStream = ({ return { ...tc, status, - display: event.display ?? tc.display, + display: event.display + ? { ...tc.display, ...event.display } + : tc.display, resultDisplay: liveOutput, progressMessage, progress, @@ -257,17 +259,18 @@ export const useAgentStream = ({ const legacyState = event._meta?.legacyState; const outputFile = legacyState?.outputFile; + const display = event.display?.result; const resultDisplay = - event.display?.result?.type === 'text' - ? event.display.result.text - : tc.resultDisplay; + displayContentToString(display) ?? tc.resultDisplay; return { ...tc, status: event.isError ? CoreToolCallStatus.Error : CoreToolCallStatus.Success, - display: event.display ?? tc.display, + display: event.display + ? { ...tc.display, ...event.display } + : tc.display, resultDisplay, outputFile, }; diff --git a/packages/core/src/agent/tool-display-utils.test.ts b/packages/core/src/agent/tool-display-utils.test.ts index de88870a7c7..ac583c000e6 100644 --- a/packages/core/src/agent/tool-display-utils.test.ts +++ b/packages/core/src/agent/tool-display-utils.test.ts @@ -10,7 +10,12 @@ import type { ToolResult, ToolResultDisplay, } from '../tools/tools.js'; -import { populateToolDisplay } from './tool-display-utils.js'; +import type { DisplayContent } from './types.js'; +import { + populateToolDisplay, + renderDisplayDiff, + displayContentToString, +} from './tool-display-utils.js'; describe('tool-display-utils', () => { describe('populateToolDisplay', () => { @@ -68,4 +73,52 @@ describe('tool-display-utils', () => { }); }); }); + + describe('renderDisplayDiff', () => { + it('renders a universal diff', () => { + const diff = { + type: 'diff' as const, + path: 'test.ts', + beforeText: 'line 1\nline 2', + afterText: 'line 1\nline 2 modified', + }; + const rendered = renderDisplayDiff(diff); + expect(rendered).toContain('--- test.ts\tOriginal'); + expect(rendered).toContain('+++ test.ts\tModified'); + expect(rendered).toContain('-line 2'); + expect(rendered).toContain('+line 2 modified'); + }); + }); + + describe('displayContentToString', () => { + it('returns undefined for undefined input', () => { + expect(displayContentToString(undefined)).toBeUndefined(); + }); + + it('returns text for text input', () => { + expect(displayContentToString({ type: 'text', text: 'hello' })).toBe( + 'hello', + ); + }); + + it('renders a diff for diff input', () => { + const diff = { + type: 'diff' as const, + path: 'test.ts', + beforeText: 'old', + afterText: 'new', + }; + const rendered = displayContentToString(diff); + expect(rendered).toContain('--- test.ts\tOriginal'); + expect(rendered).toContain('+++ test.ts\tModified'); + }); + + it('stringifies unknown structured objects', () => { + const unknown = { + type: 'something_else', + data: 123, + } as unknown as DisplayContent; + expect(displayContentToString(unknown)).toBe(JSON.stringify(unknown)); + }); + }); }); diff --git a/packages/core/src/agent/tool-display-utils.ts b/packages/core/src/agent/tool-display-utils.ts index f5327d6a1b3..efdf2aa35ee 100644 --- a/packages/core/src/agent/tool-display-utils.ts +++ b/packages/core/src/agent/tool-display-utils.ts @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as Diff from 'diff'; import type { ToolInvocation, ToolResult, ToolResultDisplay, } from '../tools/tools.js'; -import type { ToolDisplay, DisplayContent } from './types.js'; +import type { ToolDisplay, DisplayContent, DisplayDiff } from './types.js'; /** * Populates a ToolDisplay object from a tool invocation and its result. @@ -70,3 +71,36 @@ export function toolResultDisplayToDisplayContent( text: JSON.stringify(resultDisplay), }; } + +/** + * Renders a universal diff string from a DisplayDiff object. + */ +export function renderDisplayDiff(diff: DisplayDiff): string { + return Diff.createPatch( + diff.path || 'file', + diff.beforeText, + diff.afterText, + 'Original', + 'Modified', + { context: 3 }, + ); +} + +/** + * Converts a DisplayContent object into a string representation. + * Useful for fallback displays or non-interactive environments. + */ +export function displayContentToString( + display: DisplayContent | undefined, +): string | undefined { + if (!display) { + return undefined; + } + if (display.type === 'text') { + return display.text; + } + if (display.type === 'diff') { + return renderDisplayDiff(display); + } + return JSON.stringify(display); +} From fbc87675f08cb85c38d19808316600e332fa1413 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 10 Apr 2026 11:15:36 -0700 Subject: [PATCH 03/21] refactor(cli): consume simplified ToolDisplay property --- .../components/messages/DenseToolMessage.tsx | 56 +++++++++++++++++++ .../components/messages/ShellToolMessage.tsx | 2 + .../ui/components/messages/ToolMessage.tsx | 11 +++- .../src/ui/components/messages/ToolShared.tsx | 17 +++++- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index f5e4b31c668..72b61429b77 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -17,6 +17,7 @@ import { isGrepResult, isListResult, isReadManyFilesResult, + type ToolDisplay, } from '@google/gemini-cli-core'; import { type IndividualToolCallDisplay, @@ -46,6 +47,7 @@ const PAYLOAD_SCROLL_GUTTER = 4; const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER; interface DenseToolMessageProps extends IndividualToolCallDisplay { + display?: ToolDisplay; terminalWidth: number; availableTerminalHeight?: number; } @@ -269,6 +271,7 @@ export const DenseToolMessage: React.FC = (props) => { terminalWidth, availableTerminalHeight, description: originalDescription, + display, } = props; const settings = useSettings(); @@ -322,6 +325,58 @@ export const DenseToolMessage: React.FC = (props) => { // State-to-View Coordination const viewParts = useMemo((): ViewParts => { + if (display) { + const descriptionText = ( + + {display.description || originalDescription} + + ); + + const summaryText = display.resultSummary ? ( + + → {display.resultSummary} + + ) : status === CoreToolCallStatus.Error ? ( + + → {typeof resultDisplay === 'string' ? resultDisplay : 'Failed'} + + ) : undefined; + + // For now, DenseToolMessage still handles complex resultDisplay types + // like FileDiff or ListResult manually if display.result is not provided + // or doesn't cover them. + if (!display.result) { + if (diff) { + return { + ...getFileOpData( + diff, + status, + resultDisplay, + terminalWidth, + availableTerminalHeight, + isAlternateBuffer, + ), + description: descriptionText, + summary: summaryText, + }; + } + if (isListResult(resultDisplay)) { + return { + ...getListResultData(resultDisplay, originalDescription), + description: descriptionText, + summary: summaryText, + }; + } + } + + // If we have a display.result or a simple success, use it + return { + description: descriptionText, + summary: summaryText, + payload: undefined, // Payload rendering will be updated in Step 5 + }; + } + if (diff) { return getFileOpData( diff, @@ -383,6 +438,7 @@ export const DenseToolMessage: React.FC = (props) => { availableTerminalHeight, originalDescription, isAlternateBuffer, + display, ]); const { description, summary } = viewParts; diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index f3694f34902..201a78f70e7 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -43,6 +43,7 @@ export interface ShellToolMessageProps extends ToolMessageProps { export const ShellToolMessage: React.FC = ({ name, description, + display, resultDisplay, status, availableTerminalHeight, @@ -167,6 +168,7 @@ export const ShellToolMessage: React.FC = ({ name={name} status={status} description={description} + display={display} emphasis={emphasis} originalRequestName={originalRequestName} /> diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 5747f7677fa..103f4443b50 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -21,13 +21,20 @@ import { useFocusHint, FocusHint, } from './ToolShared.js'; -import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core'; +import { + type Config, + CoreToolCallStatus, + Kind, + type ToolDisplay, +} from '@google/gemini-cli-core'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { SUBAGENT_MAX_LINES } from '../../constants.js'; export type { TextEmphasis }; export interface ToolMessageProps extends IndividualToolCallDisplay { + description: string; + display?: ToolDisplay; availableTerminalHeight?: number; terminalWidth: number; emphasis?: TextEmphasis; @@ -44,6 +51,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { export const ToolMessage: React.FC = ({ name, description, + display, resultDisplay, status, kind, @@ -99,6 +107,7 @@ export const ToolMessage: React.FC = ({ name={name} status={status} description={description} + display={display} emphasis={emphasis} progressMessage={progressMessage} originalRequestName={originalRequestName} diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx index 2aa5ed992af..21157da2b35 100644 --- a/packages/cli/src/ui/components/messages/ToolShared.tsx +++ b/packages/cli/src/ui/components/messages/ToolShared.tsx @@ -21,6 +21,7 @@ import { isCompletedAskUserTool, type ToolResultDisplay, CoreToolCallStatus, + type ToolDisplay, } from '@google/gemini-cli-core'; import { useInactivityTimer } from '../../hooks/useInactivityTimer.js'; import { formatCommand } from '../../key/keybindingUtils.js'; @@ -192,6 +193,7 @@ type ToolInfoProps = { description: string; status: CoreToolCallStatus; emphasis: TextEmphasis; + display?: ToolDisplay; progressMessage?: string; originalRequestName?: string; }; @@ -201,6 +203,7 @@ export const ToolInfo: React.FC = ({ description, status: coreStatus, emphasis, + display, progressMessage: _progressMessage, originalRequestName, }) => { @@ -223,11 +226,15 @@ export const ToolInfo: React.FC = ({ // Hide description for completed Ask User tools (the result display speaks for itself) const isCompletedAskUser = isCompletedAskUserTool(name, status); + const displayName = display?.name || name; + const displayDescription = display?.description || description; + const displaySummary = display?.resultSummary; + return ( - {name} + {displayName} {originalRequestName && originalRequestName !== name && ( @@ -238,7 +245,13 @@ export const ToolInfo: React.FC = ({ {!isCompletedAskUser && ( <> {' '} - {description} + {displayDescription} + + )} + {displaySummary && ( + <> + + {displaySummary} )} From 43f93c3cde3e84ef8cad17033b6a4773a29e3029 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 10 Apr 2026 20:18:51 -0700 Subject: [PATCH 04/21] fix(ui): resolve rebase conflicts and type errors for ToolDisplay --- .../components/messages/DenseToolMessage.tsx | 57 ++++++++++++++++--- .../ui/components/messages/ToolMessage.tsx | 7 ++- packages/cli/src/ui/hooks/toolMapping.ts | 18 +++++- packages/cli/src/ui/hooks/useAgentStream.ts | 10 ++-- packages/core/src/agent/event-translator.ts | 16 +++--- .../src/agent/legacy-agent-session.test.ts | 11 +++- .../core/src/agent/legacy-agent-session.ts | 1 + packages/core/src/agent/tool-display-utils.ts | 5 +- packages/core/src/agent/types.ts | 4 +- packages/core/src/config/config.ts | 10 +++- packages/core/src/scheduler/tool-executor.ts | 8 +++ packages/core/src/scheduler/types.ts | 3 + packages/core/src/tools/edit.test.ts | 11 ++++ packages/core/src/tools/edit.ts | 23 ++++++++ packages/core/src/tools/grep.ts | 11 +++- packages/core/src/tools/ls.ts | 13 +++++ packages/core/src/tools/read-file.test.ts | 32 ++++++++--- packages/core/src/tools/read-file.ts | 12 ++++ packages/core/src/tools/ripGrep.ts | 11 +++- packages/core/src/tools/shell.test.ts | 6 ++ packages/core/src/tools/shell.ts | 15 +++++ packages/core/src/tools/tools.ts | 7 +++ packages/core/src/tools/write-file.test.ts | 10 ++++ packages/core/src/tools/write-file.ts | 13 +++++ 24 files changed, 276 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index 72b61429b77..7cdf1eb4fbd 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -24,6 +24,7 @@ import { type ToolResultDisplay, isTodoList, } from '../../types.js'; +import { isCompactTool } from './ToolGroupMessage.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { ToolStatusIndicator } from './ToolShared.js'; import { theme } from '../../semantic-colors.js'; @@ -286,6 +287,7 @@ export const DenseToolMessage: React.FC = (props) => { const [isFocused, setIsFocused] = useState(false); const toggleRef = useRef(null); + const isActuallyCompact = useMemo(() => isCompactTool(props, true), [props]); // Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails) const diff = useMemo((): FileDiff | undefined => { @@ -369,11 +371,36 @@ export const DenseToolMessage: React.FC = (props) => { } } - // If we have a display.result or a simple success, use it + // If we have a display.result, use it as the payload + let payload: React.ReactNode; + if (display.result) { + if (display.result.type === 'text') { + const text = display.result.text; + if (text) { + payload = ( + + {text} + + ); + } + } + // Step 5 will expand this to handle 'diff' type + } + + // Compact tools should elide text payloads by default unless expanded. + if ( + isActuallyCompact && + !isExpanded && + display.result?.type === 'text' && + !isAlternateBuffer + ) { + payload = undefined; + } + return { description: descriptionText, summary: summaryText, - payload: undefined, // Payload rendering will be updated in Step 5 + payload, }; } @@ -439,6 +466,8 @@ export const DenseToolMessage: React.FC = (props) => { originalDescription, isAlternateBuffer, display, + isActuallyCompact, + isExpanded, ]); const { description, summary } = viewParts; @@ -476,6 +505,10 @@ export const DenseToolMessage: React.FC = (props) => { }, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]); const showPayload = useMemo(() => { + // If we are using the new display protocol and it's a compact tool, + // hide the payload by default unless expanded. + if (display && isActuallyCompact && !isExpanded) return false; + const policy = !isAlternateBuffer || !diff || isExpanded; if (!policy) return false; @@ -495,6 +528,8 @@ export const DenseToolMessage: React.FC = (props) => { diffLines.length, viewParts.payload, outputFile, + isActuallyCompact, + display, ]); const keyExtractor = (_item: React.ReactNode, index: number) => @@ -505,7 +540,16 @@ export const DenseToolMessage: React.FC = (props) => { return ( - + @@ -519,12 +563,7 @@ export const DenseToolMessage: React.FC = (props) => { {summary && ( - + {summary} )} diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 103f4443b50..d70c470408b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -87,6 +87,11 @@ export const ToolMessage: React.FC = ({ resultDisplay, ); + const effectiveResultDisplay = + display?.resultSummary && !display.result + ? display.resultSummary + : resultDisplay; + return ( // It is crucial we don't replace this <> with a Box because otherwise the // sticky header inside it would be sticky to that box rather than to the @@ -139,7 +144,7 @@ export const ToolMessage: React.FC = ({ /> )} { let description: string; @@ -63,6 +71,7 @@ export function mapToDisplay( }; let resultDisplay: ToolResultDisplay | undefined = undefined; + let display: ToolDisplay | undefined = undefined; let confirmationDetails: SerializableConfirmationDetails | undefined = undefined; let outputFile: string | undefined = undefined; @@ -75,11 +84,17 @@ export function mapToDisplay( switch (call.status) { case CoreToolCallStatus.Success: resultDisplay = call.response.resultDisplay; + if (isAgentSessionInteractive) { + display = call.response.display; + } outputFile = call.response.outputFile; break; case CoreToolCallStatus.Error: case CoreToolCallStatus.Cancelled: resultDisplay = call.response.resultDisplay; + if (isAgentSessionInteractive) { + display = call.response.display; + } break; case CoreToolCallStatus.AwaitingApproval: correlationId = call.correlationId; @@ -112,6 +127,7 @@ export function mapToDisplay( status: call.status, isClientInitiated: !!call.request.isClientInitiated, kind: call.tool?.kind, + display, resultDisplay, confirmationDetails, outputFile, diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 4fb4a9c94fb..648a5e78fa0 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -224,9 +224,9 @@ export const useAgentStream = ({ else if (evtStatus === 'success') status = CoreToolCallStatus.Success; - const display = event.display?.result; const liveOutput = - displayContentToString(display) ?? tc.resultDisplay; + displayContentToString(event.display?.result) ?? + tc.resultDisplay; const progressMessage = legacyState?.progressMessage ?? tc.progressMessage; const progress = legacyState?.progress ?? tc.progress; @@ -237,6 +237,7 @@ export const useAgentStream = ({ return { ...tc, + name: event.display?.name ?? tc.name, status, display: event.display ? { ...tc.display, ...event.display } @@ -259,12 +260,13 @@ export const useAgentStream = ({ const legacyState = event._meta?.legacyState; const outputFile = legacyState?.outputFile; - const display = event.display?.result; + const display = event.display; const resultDisplay = - displayContentToString(display) ?? tc.resultDisplay; + displayContentToString(display?.result) ?? tc.resultDisplay; return { ...tc, + name: display?.name ?? tc.name, status: event.isError ? CoreToolCallStatus.Error : CoreToolCallStatus.Success, diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index dee56adbd0f..654dea9d75a 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -243,13 +243,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, diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 9ee8b032ad2..dead396ecfd 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, }, @@ -427,6 +430,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 5fb024378e1..194755b859f 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -267,6 +267,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 efdf2aa35ee..ed0e6f6b76e 100644 --- a/packages/core/src/agent/tool-display-utils.ts +++ b/packages/core/src/agent/tool-display-utils.ts @@ -21,13 +21,16 @@ export function populateToolDisplay({ invocation, resultDisplay, displayName, + display: prevDisplay, }: { name: string; invocation?: ToolInvocation; resultDisplay?: ToolResultDisplay; displayName?: string; + display?: ToolDisplay; }): ToolDisplay { const display: ToolDisplay = { + ...prevDisplay, name: displayName || name, description: invocation?.getDescription?.(), }; @@ -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 af48973f8fb..0f7cad8a9b7 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -187,8 +187,8 @@ export type DisplayContent = DisplayText | DisplayDiff; export interface ToolDisplay { name?: string; description?: string; - resultSummary?: string; - result?: DisplayContent; + resultSummary?: string | null; + result?: DisplayContent | null; } export interface ToolRequest { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5e8507eba4d..1fe75276cca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3422,11 +3422,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/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 3910aaee478..8eb1d265324 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, @@ -159,6 +160,7 @@ export class ToolExecutor { toolResult.error.type, displayText, toolResult.tailToolCallRequest, + toolResult.display, ); } } catch (executionError: unknown) { @@ -349,6 +351,7 @@ export class ToolExecutor { response: { callId: call.request.callId, responseParts, + display: toolResult?.display, resultDisplay: toolResult?.returnDisplay, error: undefined, errorType: undefined, @@ -385,6 +388,7 @@ export class ToolExecutor { const successResponse: ToolCallResponseInfo = { callId, responseParts: response, + display: toolResult.display, resultDisplay: toolResult.returnDisplay, error: undefined, errorType: undefined, @@ -419,12 +423,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; @@ -446,11 +452,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 170aab67ca1..596fd1f8d1c 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'; @@ -56,6 +57,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 075dca64b12..470aa95891c 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -719,6 +719,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 f0b9b448a3b..a8cc710d41d 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'; @@ -430,6 +431,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; @@ -984,8 +991,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 34be5885730..2e56d35f141 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -284,12 +284,21 @@ 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: null, + }, + }; } 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 ea660280711..17e21359a82 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -284,6 +284,19 @@ class LSToolInvocation extends BaseToolInvocation { return { llmContent: resultMessage, + display: { + name: LS_DISPLAY_NAME, + description: this.getDescription(), + resultSummary: displayMessage, + result: { + type: 'text', + text: entries + .map( + (entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`, + ) + .join('\n'), + }, + }, 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 78563b94f34..bc58397a93d 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 ae48f2387a8..ee50cff97e9 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 4449a7a08a7..a510e8a697d 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -326,12 +326,21 @@ 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: null, + }, + }; } 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 8e9b866fa68..1b41391451d 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -480,6 +480,12 @@ describe('ShellTool', () => { 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: '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 e299d88e4cc..d944009c1cb 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -925,8 +925,23 @@ 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: this.getDisplayTitle(), + description: this.getDescription(), + resultSummary: displayResultSummary, + result: + typeof returnDisplay === 'string' + ? { type: 'text', text: returnDisplay } + : undefined, + }, returnDisplay, data, ...executionError, diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index cd6209079c3..42bc2c2738a 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/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 0227b18663e..e9dbcd24c8b 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -677,6 +677,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 5766789f0c9..a760a3f8ef8 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -420,6 +420,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) { From 88bebef3a99b23abeef7b7beb8329dd20333640f Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 10 Apr 2026 20:50:04 -0700 Subject: [PATCH 05/21] fix(ui): hide summary in header when displayed in box --- packages/cli/src/ui/components/messages/ToolGroupMessage.tsx | 4 +++- packages/cli/src/ui/components/messages/ToolMessage.tsx | 1 + packages/cli/src/ui/components/messages/ToolShared.tsx | 4 +++- packages/core/src/agent/tool-display-utils.ts | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 3a37f3ff5e7..cfec822f9dd 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -61,7 +61,9 @@ export const isCompactTool = ( tool: IndividualToolCallDisplay, isCompactModeEnabled: boolean, ): boolean => { - const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name); + const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has( + tool.originalRequestName || tool.name, + ); const displayStatus = mapCoreStatusToDisplayStatus(tool.status); return ( isCompactModeEnabled && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index d70c470408b..25cf4765127 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -116,6 +116,7 @@ export const ToolMessage: React.FC = ({ emphasis={emphasis} progressMessage={progressMessage} originalRequestName={originalRequestName} + hideSummary={!display?.result && !!display?.resultSummary} /> = ({ @@ -206,6 +207,7 @@ export const ToolInfo: React.FC = ({ display, progressMessage: _progressMessage, originalRequestName, + hideSummary, }) => { const status = mapCoreStatusToDisplayStatus(coreStatus); const nameColor = React.useMemo(() => { @@ -228,7 +230,7 @@ export const ToolInfo: React.FC = ({ const displayName = display?.name || name; const displayDescription = display?.description || description; - const displaySummary = display?.resultSummary; + const displaySummary = hideSummary ? undefined : display?.resultSummary; return ( diff --git a/packages/core/src/agent/tool-display-utils.ts b/packages/core/src/agent/tool-display-utils.ts index ed0e6f6b76e..53ca5eaf756 100644 --- a/packages/core/src/agent/tool-display-utils.ts +++ b/packages/core/src/agent/tool-display-utils.ts @@ -35,7 +35,7 @@ export function populateToolDisplay({ description: invocation?.getDescription?.(), }; - if (resultDisplay) { + if (resultDisplay !== undefined && display.result === undefined) { display.result = toolResultDisplayToDisplayContent(resultDisplay); } From 383cb7d795f0061e6ca914150902950998510f2d Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sat, 11 Apr 2026 19:01:57 -0700 Subject: [PATCH 06/21] wip: HistoryItemToolGroupDisplay --- .../src/ui/components/HistoryItemDisplay.tsx | 1 + .../components/messages/ToolGroupMessage.tsx | 1 + packages/cli/src/ui/types.ts | 6 +++ packages/core/src/agent/types.ts | 38 ++++++++++++++++++- 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 0ceb70f8d72..414b6dcbe44 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -208,6 +208,7 @@ export const HistoryItemDisplay: React.FC = ({ isToolGroupBoundary={isToolGroupBoundary} /> )} + {/* TODO: tool_group_display goes here */} {itemForDisplay.type === 'subagent' && ( = ({ isToolGroupBoundary, }) => { const settings = useSettings(); + const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full'; const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 1ded2ae643e..590b4e4ea87 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -260,6 +260,11 @@ export type HistoryItemToolGroup = HistoryItemBase & { borderDimColor?: boolean; }; +export type HistoryItemToolDisplayGroup = HistoryItemBase & { + type: 'tool_display_group'; + tools: ToolDisplay[]; +}; + export type HistoryItemUserShell = HistoryItemBase & { type: 'user_shell'; text: string; @@ -393,6 +398,7 @@ export type HistoryItemWithoutId = | HistoryItemAbout | HistoryItemHelp | HistoryItemToolGroup + | HistoryItemToolDisplayGroup | HistoryItemStats | HistoryItemModelStats | HistoryItemToolStats diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 0f7cad8a9b7..d383c18c68d 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 'src/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; + /** 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 { From 410e6758371bc23ac4ab276e671a96d1c2daafbc Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sat, 11 Apr 2026 19:04:36 -0700 Subject: [PATCH 07/21] revert: remove invasive ToolDisplay logic from legacy UI components --- .../components/messages/DenseToolMessage.tsx | 109 ++---------------- .../components/messages/ShellToolMessage.tsx | 2 - .../components/messages/ToolGroupMessage.tsx | 5 +- .../ui/components/messages/ToolMessage.tsx | 19 +-- .../src/ui/components/messages/ToolShared.tsx | 19 +-- packages/cli/src/ui/hooks/toolMapping.ts | 18 +-- packages/cli/src/ui/hooks/useAgentStream.ts | 10 +- 7 files changed, 17 insertions(+), 165 deletions(-) diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index 7cdf1eb4fbd..f5e4b31c668 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -17,14 +17,12 @@ import { isGrepResult, isListResult, isReadManyFilesResult, - type ToolDisplay, } from '@google/gemini-cli-core'; import { type IndividualToolCallDisplay, type ToolResultDisplay, isTodoList, } from '../../types.js'; -import { isCompactTool } from './ToolGroupMessage.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { ToolStatusIndicator } from './ToolShared.js'; import { theme } from '../../semantic-colors.js'; @@ -48,7 +46,6 @@ const PAYLOAD_SCROLL_GUTTER = 4; const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER; interface DenseToolMessageProps extends IndividualToolCallDisplay { - display?: ToolDisplay; terminalWidth: number; availableTerminalHeight?: number; } @@ -272,7 +269,6 @@ export const DenseToolMessage: React.FC = (props) => { terminalWidth, availableTerminalHeight, description: originalDescription, - display, } = props; const settings = useSettings(); @@ -287,7 +283,6 @@ export const DenseToolMessage: React.FC = (props) => { const [isFocused, setIsFocused] = useState(false); const toggleRef = useRef(null); - const isActuallyCompact = useMemo(() => isCompactTool(props, true), [props]); // Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails) const diff = useMemo((): FileDiff | undefined => { @@ -327,83 +322,6 @@ export const DenseToolMessage: React.FC = (props) => { // State-to-View Coordination const viewParts = useMemo((): ViewParts => { - if (display) { - const descriptionText = ( - - {display.description || originalDescription} - - ); - - const summaryText = display.resultSummary ? ( - - → {display.resultSummary} - - ) : status === CoreToolCallStatus.Error ? ( - - → {typeof resultDisplay === 'string' ? resultDisplay : 'Failed'} - - ) : undefined; - - // For now, DenseToolMessage still handles complex resultDisplay types - // like FileDiff or ListResult manually if display.result is not provided - // or doesn't cover them. - if (!display.result) { - if (diff) { - return { - ...getFileOpData( - diff, - status, - resultDisplay, - terminalWidth, - availableTerminalHeight, - isAlternateBuffer, - ), - description: descriptionText, - summary: summaryText, - }; - } - if (isListResult(resultDisplay)) { - return { - ...getListResultData(resultDisplay, originalDescription), - description: descriptionText, - summary: summaryText, - }; - } - } - - // If we have a display.result, use it as the payload - let payload: React.ReactNode; - if (display.result) { - if (display.result.type === 'text') { - const text = display.result.text; - if (text) { - payload = ( - - {text} - - ); - } - } - // Step 5 will expand this to handle 'diff' type - } - - // Compact tools should elide text payloads by default unless expanded. - if ( - isActuallyCompact && - !isExpanded && - display.result?.type === 'text' && - !isAlternateBuffer - ) { - payload = undefined; - } - - return { - description: descriptionText, - summary: summaryText, - payload, - }; - } - if (diff) { return getFileOpData( diff, @@ -465,9 +383,6 @@ export const DenseToolMessage: React.FC = (props) => { availableTerminalHeight, originalDescription, isAlternateBuffer, - display, - isActuallyCompact, - isExpanded, ]); const { description, summary } = viewParts; @@ -505,10 +420,6 @@ export const DenseToolMessage: React.FC = (props) => { }, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]); const showPayload = useMemo(() => { - // If we are using the new display protocol and it's a compact tool, - // hide the payload by default unless expanded. - if (display && isActuallyCompact && !isExpanded) return false; - const policy = !isAlternateBuffer || !diff || isExpanded; if (!policy) return false; @@ -528,8 +439,6 @@ export const DenseToolMessage: React.FC = (props) => { diffLines.length, viewParts.payload, outputFile, - isActuallyCompact, - display, ]); const keyExtractor = (_item: React.ReactNode, index: number) => @@ -540,16 +449,7 @@ export const DenseToolMessage: React.FC = (props) => { return ( - + @@ -563,7 +463,12 @@ export const DenseToolMessage: React.FC = (props) => { {summary && ( - + {summary} )} diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 201a78f70e7..f3694f34902 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -43,7 +43,6 @@ export interface ShellToolMessageProps extends ToolMessageProps { export const ShellToolMessage: React.FC = ({ name, description, - display, resultDisplay, status, availableTerminalHeight, @@ -168,7 +167,6 @@ export const ShellToolMessage: React.FC = ({ name={name} status={status} description={description} - display={display} emphasis={emphasis} originalRequestName={originalRequestName} /> diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index b28acee83ec..3a37f3ff5e7 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -61,9 +61,7 @@ export const isCompactTool = ( tool: IndividualToolCallDisplay, isCompactModeEnabled: boolean, ): boolean => { - const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has( - tool.originalRequestName || tool.name, - ); + const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name); const displayStatus = mapCoreStatusToDisplayStatus(tool.status); return ( isCompactModeEnabled && @@ -121,7 +119,6 @@ export const ToolGroupMessage: React.FC = ({ isToolGroupBoundary, }) => { const settings = useSettings(); - const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full'; const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 25cf4765127..5747f7677fa 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -21,20 +21,13 @@ import { useFocusHint, FocusHint, } from './ToolShared.js'; -import { - type Config, - CoreToolCallStatus, - Kind, - type ToolDisplay, -} from '@google/gemini-cli-core'; +import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { SUBAGENT_MAX_LINES } from '../../constants.js'; export type { TextEmphasis }; export interface ToolMessageProps extends IndividualToolCallDisplay { - description: string; - display?: ToolDisplay; availableTerminalHeight?: number; terminalWidth: number; emphasis?: TextEmphasis; @@ -51,7 +44,6 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { export const ToolMessage: React.FC = ({ name, description, - display, resultDisplay, status, kind, @@ -87,11 +79,6 @@ export const ToolMessage: React.FC = ({ resultDisplay, ); - const effectiveResultDisplay = - display?.resultSummary && !display.result - ? display.resultSummary - : resultDisplay; - return ( // It is crucial we don't replace this <> with a Box because otherwise the // sticky header inside it would be sticky to that box rather than to the @@ -112,11 +99,9 @@ export const ToolMessage: React.FC = ({ name={name} status={status} description={description} - display={display} emphasis={emphasis} progressMessage={progressMessage} originalRequestName={originalRequestName} - hideSummary={!display?.result && !!display?.resultSummary} /> = ({ /> )} = ({ @@ -204,10 +201,8 @@ export const ToolInfo: React.FC = ({ description, status: coreStatus, emphasis, - display, progressMessage: _progressMessage, originalRequestName, - hideSummary, }) => { const status = mapCoreStatusToDisplayStatus(coreStatus); const nameColor = React.useMemo(() => { @@ -228,15 +223,11 @@ export const ToolInfo: React.FC = ({ // Hide description for completed Ask User tools (the result display speaks for itself) const isCompletedAskUser = isCompletedAskUserTool(name, status); - const displayName = display?.name || name; - const displayDescription = display?.description || description; - const displaySummary = hideSummary ? undefined : display?.resultSummary; - return ( - {displayName} + {name} {originalRequestName && originalRequestName !== name && ( @@ -247,13 +238,7 @@ export const ToolInfo: React.FC = ({ {!isCompletedAskUser && ( <> {' '} - {displayDescription} - - )} - {displaySummary && ( - <> - - {displaySummary} + {description} )} diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index d6ff238483e..abf53e76e5b 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -8,7 +8,6 @@ import { type ToolCall, type SerializableConfirmationDetails, type ToolResultDisplay, - type ToolDisplay, debugLogger, CoreToolCallStatus, type SubagentActivityItem, @@ -36,17 +35,10 @@ export function mapToDisplay( borderBottom?: boolean; borderColor?: string; borderDimColor?: boolean; - isAgentSessionInteractive?: boolean; } = {}, ): HistoryItemToolGroup { const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools]; - const { - borderTop, - borderBottom, - borderColor, - borderDimColor, - isAgentSessionInteractive, - } = options; + const { borderTop, borderBottom, borderColor, borderDimColor } = options; const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => { let description: string; @@ -71,7 +63,6 @@ export function mapToDisplay( }; let resultDisplay: ToolResultDisplay | undefined = undefined; - let display: ToolDisplay | undefined = undefined; let confirmationDetails: SerializableConfirmationDetails | undefined = undefined; let outputFile: string | undefined = undefined; @@ -84,17 +75,11 @@ export function mapToDisplay( switch (call.status) { case CoreToolCallStatus.Success: resultDisplay = call.response.resultDisplay; - if (isAgentSessionInteractive) { - display = call.response.display; - } outputFile = call.response.outputFile; break; case CoreToolCallStatus.Error: case CoreToolCallStatus.Cancelled: resultDisplay = call.response.resultDisplay; - if (isAgentSessionInteractive) { - display = call.response.display; - } break; case CoreToolCallStatus.AwaitingApproval: correlationId = call.correlationId; @@ -127,7 +112,6 @@ export function mapToDisplay( status: call.status, isClientInitiated: !!call.request.isClientInitiated, kind: call.tool?.kind, - display, resultDisplay, confirmationDetails, outputFile, diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 648a5e78fa0..4fb4a9c94fb 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -224,9 +224,9 @@ export const useAgentStream = ({ else if (evtStatus === 'success') status = CoreToolCallStatus.Success; + const display = event.display?.result; const liveOutput = - displayContentToString(event.display?.result) ?? - tc.resultDisplay; + displayContentToString(display) ?? tc.resultDisplay; const progressMessage = legacyState?.progressMessage ?? tc.progressMessage; const progress = legacyState?.progress ?? tc.progress; @@ -237,7 +237,6 @@ export const useAgentStream = ({ return { ...tc, - name: event.display?.name ?? tc.name, status, display: event.display ? { ...tc.display, ...event.display } @@ -260,13 +259,12 @@ export const useAgentStream = ({ const legacyState = event._meta?.legacyState; const outputFile = legacyState?.outputFile; - const display = event.display; + const display = event.display?.result; const resultDisplay = - displayContentToString(display?.result) ?? tc.resultDisplay; + displayContentToString(display) ?? tc.resultDisplay; return { ...tc, - name: display?.name ?? tc.name, status: event.isError ? CoreToolCallStatus.Error : CoreToolCallStatus.Success, From 45eababfd8c09b8c99a6b93a21464fd7882fa1dc Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sun, 12 Apr 2026 11:46:18 -0700 Subject: [PATCH 08/21] feat(cli): refactor tool rendering to declarative ToolDisplay system This change completes the transition of the interactive agent session (`useAgentStream`) to a declarative-first tool rendering system. Key changes: - Reverted experimental `ToolDisplay` logic from legacy UI components (`DenseToolMessage`, etc.) to establish a clean baseline. - Introduced `HistoryItemToolDisplayGroup` and `ToolGroupDisplay` component in CLI. - Added `display` property to `ToolCallRequestInfo` to carry declarative UI info natively. - Populated tool request display information at the source (`Turn.ts` and `Scheduler.ts`) using dynamic descriptions from tool invocations. - Updated `useAgentStream` to emit the new history item type, providing a standalone rendering path for interactive sessions. - Ensured tool descriptions are updated when arguments are modified during confirmation. --- .../src/ui/components/HistoryItemDisplay.tsx | 8 +- .../components/messages/ToolGroupDisplay.tsx | 176 ++++++++++++++++++ packages/cli/src/ui/hooks/useAgentStream.ts | 26 ++- packages/cli/src/ui/types.ts | 11 +- packages/core/src/agent/event-translator.ts | 2 +- packages/core/src/core/geminiChat.ts | 4 + packages/core/src/core/turn.ts | 26 ++- packages/core/src/scheduler/scheduler.ts | 11 ++ packages/core/src/scheduler/state-manager.ts | 8 +- packages/core/src/scheduler/types.ts | 2 + 10 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 414b6dcbe44..513ad3a0cca 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'; @@ -208,7 +209,12 @@ export const HistoryItemDisplay: React.FC = ({ isToolGroupBoundary={isToolGroupBoundary} /> )} - {/* TODO: tool_group_display goes here */} + {itemForDisplay.type === 'tool_display_group' && ( + + )} {itemForDisplay.type === 'subagent' && ( = ({ + item, + isToolGroupBoundary, +}) => { + if (item.type !== 'tool_display_group') { + return null; + } + + const { tools, borderColor, borderDimColor, borderTop, borderBottom } = + item as HistoryItemToolDisplayGroup; + + return ( + + {tools.map((tool, index) => ( + + ))} + + ); +}; + +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". + // But per instructions, if display is missing (which we now interpret as the ToolDisplay part being effectively empty/null), show error. + 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; + } + + const isCompact = + format === 'compact' || (format === 'auto' && isCompactModeEnabled); + + if (isCompact) { + return ( + + + + {' '} + {name || tool.originalRequestName}{' '} + + {description && {description}} + {resultSummary && ( + → {resultSummary} + )} + + ); + } + + // 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}; + 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/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 4fb4a9c94fb..8f0fc61065a 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'; @@ -415,9 +415,15 @@ export const useAgentStream = ({ backgroundTasks, ); - const historyItem: HistoryItemToolGroup = { - type: 'tool_group', - tools: toolsToPush, + 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: isFirstToolInGroupRef.current, borderBottom: isLastInBatch, ...appearance, @@ -456,8 +462,14 @@ export const useAgentStream = ({ if (remainingTools.length > 0) { items.push({ - type: 'tool_group', - tools: remainingTools, + type: 'tool_display_group', + tools: remainingTools.map((tc) => ({ + name: tc.name, + description: tc.description, + ...tc.display, + status: tc.status, + originalRequestName: tc.originalRequestName, + })), borderTop: pushedToolCallIds.size === 0, borderBottom: false, ...appearance, @@ -486,7 +498,7 @@ export const useAgentStream = ({ (anyVisibleInHistory || anyVisibleInPending) ) { items.push({ - type: 'tool_group' as const, + type: 'tool_display_group', tools: [], borderTop: false, borderBottom: true, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 590b4e4ea87..d562bd76e19 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -260,9 +260,18 @@ export type HistoryItemToolGroup = HistoryItemBase & { borderDimColor?: boolean; }; +export type ToolDisplayItem = ToolDisplay & { + status: CoreToolCallStatus; + originalRequestName?: string; +}; + export type HistoryItemToolDisplayGroup = HistoryItemBase & { type: 'tool_display_group'; - tools: ToolDisplay[]; + tools: ToolDisplayItem[]; + borderTop?: boolean; + borderBottom?: boolean; + borderColor?: string; + borderDimColor?: boolean; }; export type HistoryItemUserShell = HistoryItemBase & { diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index 654dea9d75a..355a7f010bf 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; @@ -281,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/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index c480c3800b8..8c34fc1cb34 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -264,6 +264,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.ts b/packages/core/src/core/turn.ts index 9c0e536c48d..fc4c0f73f4f 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,13 +409,36 @@ 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 ?? `${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.ts b/packages/core/src/scheduler/scheduler.ts index fef22968e1e..f6151b63d6e 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, @@ -380,6 +381,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 c524a139bd0..6183be031c3 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/types.ts b/packages/core/src/scheduler/types.ts index 596fd1f8d1c..3173b76f8d2 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -37,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 From 9e03476e0387b0590b6a9b4887d245ed1b4cf687 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sun, 12 Apr 2026 16:17:43 -0700 Subject: [PATCH 09/21] feat(cli): support 'notice' format and refine declarative tool rendering Key enhancements: - Updated `UpdateTopicTool` to provide declarative 'notice' display info, using dynamic descriptions for high-fidelity output. - Refined `ToolGroupDisplay` to 'hoist' notice-format tools to the top of the group. - Implemented conditional boxing in `ToolGroupDisplay`: borders are now suppressed in compact mode, matching the standard CLI view. - Added support for `resultSummary` rendering at the bottom of text results in boxed mode. - Improved `useAgentStream` to wait for turn completion before pushing tools to history, ensuring all notices for a turn are correctly grouped and hoisted together. - Fixed margin and border logic to handle seamless transitions between notices and tool boxes. --- .../components/messages/ToolGroupDisplay.tsx | 106 +++++++++++++++--- packages/cli/src/ui/hooks/useAgentStream.ts | 61 ++++++---- packages/core/src/tools/ls.ts | 8 -- packages/core/src/tools/topicTool.ts | 5 + 4 files changed, 134 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx index 0e393cb7196..8e937964c58 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -25,6 +25,9 @@ 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; } @@ -32,23 +35,68 @@ export const ToolGroupDisplay: React.FC = ({ const { tools, borderColor, borderDimColor, borderTop, borderBottom } = item as HistoryItemToolDisplayGroup; + const noticeTools = tools.filter((t) => t.format === 'notice'); + const otherTools = tools.filter( + (t) => t.format !== 'notice' && t.format !== 'hidden', + ); + + const hasOtherTools = otherTools.length > 0; + const isClosingSlice = tools.length === 0 && borderBottom; + + // 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 ( - - {tools.map((tool, index) => ( - - ))} + + {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} ); }; @@ -90,6 +138,21 @@ const ToolDisplayMessage: React.FC = ({ tool }) => { return null; } + if (format === 'notice') { + return ( + + + {name || 'Topic'}: + + {description && ( + + {description} + + )} + + ); + } + const isCompact = format === 'compact' || (format === 'auto' && isCompactModeEnabled); @@ -153,7 +216,16 @@ const ToolResultDisplayContent: React.FC = ({ switch (content.type) { case 'text': - return {content.text}; + return ( + + {content.text} + {summary && ( + + {summary} + + )} + + ); case 'diff': // Simplified diff display for now return ( diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 8f0fc61065a..ac0c0db2ca9 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -77,6 +77,8 @@ export const useAgentStream = ({ useStateAndRef>(new Set()); const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] = useStateAndRef(true); + const [_hasEmittedBoxInTurn, hasEmittedBoxInTurnRef, setHasEmittedBoxInTurn] = + useStateAndRef(false); const { startNewPrompt } = useSessionStats(); @@ -381,32 +383,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, @@ -415,6 +412,13 @@ export const useAgentStream = ({ backgroundTasks, ); + 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) => ({ @@ -424,21 +428,27 @@ export const useAgentStream = ({ status: tc.status, originalRequestName: tc.originalRequestName, })), - borderTop: isFirstToolInGroupRef.current, - borderBottom: isLastInBatch, + 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, @@ -447,7 +457,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[] = []; @@ -461,6 +471,13 @@ 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_display_group', tools: remainingTools.map((tc) => ({ @@ -470,7 +487,7 @@ export const useAgentStream = ({ status: tc.status, originalRequestName: tc.originalRequestName, })), - borderTop: pushedToolCallIds.size === 0, + borderTop: shouldStartNewBlock, borderBottom: false, ...appearance, }); @@ -510,6 +527,8 @@ export const useAgentStream = ({ }, [ trackedTools, pushedToolCallIds, + pushedToolCallIdsRef, + hasEmittedBoxInTurnRef, activePtyId, isShellFocused, backgroundTasks, diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 17e21359a82..c2e1a593bc8 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -288,14 +288,6 @@ class LSToolInvocation extends BaseToolInvocation { name: LS_DISPLAY_NAME, description: this.getDescription(), resultSummary: displayMessage, - result: { - type: 'text', - text: entries - .map( - (entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`, - ) - .join('\n'), - }, }, returnDisplay: { summary: displayMessage, diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts index 2b298159d1a..f0cb328b0a9 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, }; } From 8548c6675f89fbee029e120336e9e84cc7895163 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sun, 12 Apr 2026 16:40:48 -0700 Subject: [PATCH 10/21] test(cli): add unit tests for ToolGroupDisplay and implement tool hiding --- .../messages/ToolGroupDisplay.test.tsx | 304 ++++++++++++++++++ .../components/messages/ToolGroupDisplay.tsx | 14 +- 2 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx new file mode 100644 index 00000000000..96cefc09624 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.test.tsx @@ -0,0 +1,304 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { ToolGroupDisplay } from './ToolGroupDisplay.js'; +import { + CoreToolCallStatus, + UPDATE_TOPIC_DISPLAY_NAME, +} from '@google/gemini-cli-core'; +import type { + HistoryItemToolDisplayGroup, + ToolDisplayItem, +} from '../../types.js'; + +describe('', () => { + 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 index 8e937964c58..137e6391d44 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -6,6 +6,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; import type { HistoryItem, HistoryItemWithoutId, @@ -35,14 +36,23 @@ export const ToolGroupDisplay: React.FC = ({ const { tools, borderColor, borderDimColor, borderTop, borderBottom } = item as HistoryItemToolDisplayGroup; - const noticeTools = tools.filter((t) => t.format === 'notice'); - const otherTools = tools.filter( + 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 = From 46377d2133b7b5c094f760348c8eb82f4a7e4b49 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sun, 12 Apr 2026 16:44:05 -0700 Subject: [PATCH 11/21] fix(core): restore ReadFolder declarative display and add missing test snapshots --- .../ToolGroupDisplay.test.tsx.snap | 83 +++++++++++++++++++ packages/core/src/tools/ls.ts | 8 ++ 2 files changed, 91 insertions(+) create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap 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 00000000000..56d8fb9dc72 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap @@ -0,0 +1,83 @@ +// 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/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index c2e1a593bc8..17e21359a82 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -288,6 +288,14 @@ class LSToolInvocation extends BaseToolInvocation { name: LS_DISPLAY_NAME, description: this.getDescription(), resultSummary: displayMessage, + result: { + type: 'text', + text: entries + .map( + (entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`, + ) + .join('\n'), + }, }, returnDisplay: { summary: displayMessage, From e2b262181c25b7fb40e00a762c5965abb249508a Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Sun, 12 Apr 2026 16:45:23 -0700 Subject: [PATCH 12/21] revert(core): restore intentional ReadFolder display behavior (result: null) --- packages/core/src/tools/ls.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 17e21359a82..c2e1a593bc8 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -288,14 +288,6 @@ class LSToolInvocation extends BaseToolInvocation { name: LS_DISPLAY_NAME, description: this.getDescription(), resultSummary: displayMessage, - result: { - type: 'text', - text: entries - .map( - (entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`, - ) - .join('\n'), - }, }, returnDisplay: { summary: displayMessage, From de9a98c6b5ebc0fef82b9a281130bbfc81811d94 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Mon, 13 Apr 2026 12:19:20 -0700 Subject: [PATCH 13/21] fix(ui): flatten multiline summaries in compact ToolGroupDisplay and fix populateToolDisplay merge logic --- packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx | 5 ++++- .../messages/__snapshots__/ToolGroupDisplay.test.tsx.snap | 2 +- packages/core/src/agent/tool-display-utils.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx index 137e6391d44..df7bfadd21f 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -179,7 +179,10 @@ const ToolDisplayMessage: React.FC = ({ tool }) => { {description && {description}} {resultSummary && ( - → {resultSummary} + + {' '} + → {resultSummary.replace(/\n/g, ' ')} + )} ); 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 index 56d8fb9dc72..e4d783d80b1 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap @@ -17,7 +17,7 @@ exports[` > Border & Margin Logic > forces top border on box `; exports[` > Golden Snapshots > renders error message when display info is missing 1`] = ` -" ⊷ Error: Tool display missing +" ⊶ Error: Tool display missing " `; diff --git a/packages/core/src/agent/tool-display-utils.ts b/packages/core/src/agent/tool-display-utils.ts index 53ca5eaf756..070cd254d02 100644 --- a/packages/core/src/agent/tool-display-utils.ts +++ b/packages/core/src/agent/tool-display-utils.ts @@ -30,9 +30,9 @@ export function populateToolDisplay({ display?: ToolDisplay; }): ToolDisplay { const display: ToolDisplay = { - ...prevDisplay, name: displayName || name, description: invocation?.getDescription?.(), + ...prevDisplay, }; if (resultDisplay !== undefined && display.result === undefined) { From af5dfc445af249449a9f7876d85059344d0b20af Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Tue, 14 Apr 2026 00:05:45 -0700 Subject: [PATCH 14/21] feat(cli): refine tool display aesthetics for legacy UI parity --- .../components/messages/ToolGroupDisplay.tsx | 30 +++++++++++-------- .../ToolGroupDisplay.test.tsx.snap | 20 +++++-------- packages/core/src/tools/shell.ts | 5 ++-- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx index df7bfadd21f..28e79f5710a 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupDisplay.tsx @@ -149,11 +149,17 @@ const ToolDisplayMessage: React.FC = ({ tool }) => { } 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 || 'Topic'}: - + {name && !isRedundant && ( + + {name}: + + )} {description && ( {description} @@ -190,8 +196,8 @@ const ToolDisplayMessage: React.FC = ({ tool }) => { // Box format (full) return ( - - + + = ({ tool }) => { {description && {description}} {resultSummary && !result && ( - - → {resultSummary} + + {resultSummary} )} {result && ( - + )} @@ -231,10 +237,10 @@ const ToolResultDisplayContent: React.FC = ({ case 'text': return ( - {content.text} + {content.text} {summary && ( - - {summary} + + {summary} )} @@ -243,7 +249,7 @@ const ToolResultDisplayContent: React.FC = ({ // Simplified diff display for now return ( - {summary && {summary}} + {summary && {summary}} {`[Diff Display: ${content.beforeText.length} -> ${content.afterText.length} chars]`} 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 index e4d783d80b1..9e80fae63ec 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupDisplay.test.tsx.snap @@ -10,8 +10,7 @@ exports[` > Border & Margin Logic > forces top border on box Test description ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Tool in Box Test description │ -│ │ +│ ✓ Tool in Box Test description │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; @@ -24,8 +23,7 @@ exports[` > Golden Snapshots > renders error message when di exports[` > Golden Snapshots > renders in boxed mode (full verbosity) 1`] = ` " ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Tool A Test description │ -│ │ +│ ✓ Tool A Test description │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; @@ -42,8 +40,7 @@ exports[` > Golden Snapshots > renders notices at the top (h New Topic ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Tool A Test description │ -│ │ +│ ✓ Tool A Test description │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; @@ -63,9 +60,9 @@ exports[` > Result Formatting > renders compact tools with s exports[` > Result Formatting > renders placeholder for diff results 1`] = ` " ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool Test description │ -│ [Diff Display: 3 -> 3 chars] │ +│ ✓ test-tool Test description │ │ │ +│ [Diff Display: 3 -> 3 chars] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; @@ -73,11 +70,10 @@ exports[` > Result Formatting > renders placeholder for diff exports[` > Result Formatting > renders text results with summary below 1`] = ` " ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool Test description │ -│ Detailed output │ -│ │ -│ Short summary │ +│ ✓ test-tool Test description │ │ │ +│ Detailed output │ +│ Short summary │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d944009c1cb..31f0ecae54d 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -934,13 +934,14 @@ export class ShellToolInvocation extends BaseToolInvocation< return { llmContent, display: { - name: this.getDisplayTitle(), + name: this._toolDisplayName, description: this.getDescription(), resultSummary: displayResultSummary, result: typeof returnDisplay === 'string' ? { type: 'text', text: returnDisplay } - : undefined, + : // TODO: Add support for terminal display type (AnsiOutput) + undefined, }, returnDisplay, data, From 005e0cfc532e20d3c3cca6972068843104dbdeb6 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Tue, 5 May 2026 11:45:12 -0700 Subject: [PATCH 15/21] feat(agent): formalize first-class tool lifecycle states and status mapping (#24993) --- packages/cli/src/ui/hooks/useAgentStream.ts | 24 ++++-- packages/core/src/agent/event-translator.ts | 2 + .../core/src/agent/legacy-agent-session.ts | 81 +++++++++++++++++++ packages/core/src/agent/types.ts | 14 ++++ scripts/build_package.js | 11 ++- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 982391a4370..6d46d2f8e60 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -224,14 +224,17 @@ export const useAgentStream = ({ if (tc.callId !== event.requestId) return tc; const legacyState = event._meta?.legacyState; - const evtStatus = legacyState?.status; - let status = tc.status; - if (evtStatus === 'executing') + if (event.status === 'executing') status = CoreToolCallStatus.Executing; - else if (evtStatus === 'error') status = CoreToolCallStatus.Error; - else if (evtStatus === 'success') + else if (event.status === 'pending_input') + status = CoreToolCallStatus.AwaitingApproval; + else if (event.status === 'errored') + status = CoreToolCallStatus.Error; + else if (event.status === 'succeeded') status = CoreToolCallStatus.Success; + else if (event.status === 'aborted') + status = CoreToolCallStatus.Cancelled; const display = event.display?.result; const liveOutput = @@ -272,11 +275,16 @@ export const useAgentStream = ({ const resultDisplay = displayContentToString(display) ?? tc.resultDisplay; + let status = CoreToolCallStatus.Success; + if (event.status === 'errored') status = CoreToolCallStatus.Error; + else if (event.status === 'aborted') + status = CoreToolCallStatus.Cancelled; + else if (event.status === 'succeeded') + status = CoreToolCallStatus.Success; + return { ...tc, - status: event.isError - ? CoreToolCallStatus.Error - : CoreToolCallStatus.Success, + status, display: event.display ? { ...tc.display, ...event.display } : tc.display, diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index f60822a8e6f..f91b3e6ddd7 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -235,6 +235,7 @@ export function translateEvent( makeEvent('tool_request', state, { requestId: event.value.callId, name: event.value.name, + status: 'pending', args: event.value.args, display: event.value.display, }), @@ -257,6 +258,7 @@ export function translateEvent( makeEvent('tool_response', state, { requestId: event.value.callId, name: state.pendingToolNames.get(event.value.callId) ?? 'unknown', + status: event.value.error ? 'errored' : 'succeeded', content: event.value.error ? [{ type: 'text', text: event.value.error.message }] : geminiPartsToContentParts(event.value.responseParts), diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index d65c583b0b3..11007f444c7 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -39,7 +39,12 @@ import type { ContentPart, StreamEndReason, Unsubscribe, + ToolEventStatus, } from './types.js'; +import { + MessageBusType, + type ToolCallsUpdateMessage, +} from '../confirmation-bus/types.js'; function isAbortLikeError(err: unknown): boolean { return err instanceof Error && err.name === 'AbortError'; @@ -64,6 +69,7 @@ export class LegacyAgentProtocol implements AgentProtocol { private _activeStreamId?: string; private _abortController = new AbortController(); private _nextStreamIdOverride?: string; + private _lastToolStatuses = new Map(); private readonly _client: GeminiClient; private readonly _scheduler: Scheduler; @@ -92,6 +98,11 @@ export class LegacyAgentProtocol implements AgentProtocol { } this._scheduler = scheduler; } + + this._config.messageBus.subscribe( + MessageBusType.TOOL_CALLS_UPDATE, + this._handleToolCallsUpdate.bind(this), + ); } get events(): readonly AgentEvent[] { @@ -274,6 +285,11 @@ export class LegacyAgentProtocol implements AgentProtocol { this._makeToolResponseEvent({ requestId: request.callId, name: request.name, + status: response.error + ? 'errored' + : tc.status === 'cancelled' + ? 'aborted' + : 'succeeded', content, isError: response.error !== undefined, ...(display ? { display } : {}), @@ -487,6 +503,71 @@ export class LegacyAgentProtocol implements AgentProtocol { } satisfies AgentEvent<'error'>; return event; } + + private _handleToolCallsUpdate(msg: ToolCallsUpdateMessage): void { + if (!this._activeStreamId) { + return; + } + + const eventsToEmit: AgentEvent[] = []; + + for (const tc of msg.toolCalls) { + const callId = tc.request.callId; + let status: ToolEventStatus = 'pending'; + if (tc.status === 'validating' || tc.status === 'scheduled') { + status = 'pending'; + } else if (tc.status === 'awaiting_approval') { + status = 'pending_input'; + } else if (tc.status === 'executing') { + status = 'executing'; + } else if (tc.status === 'success') { + status = 'succeeded'; + } else if (tc.status === 'error') { + status = 'errored'; + } else if (tc.status === 'cancelled') { + status = 'aborted'; + } + + const lastStatus = this._lastToolStatuses.get(callId); + + if (lastStatus !== status) { + this._lastToolStatuses.set(callId, status); + + const display = populateToolDisplay({ + name: tc.request.name, + invocation: 'invocation' in tc ? tc.invocation : undefined, + displayName: 'tool' in tc ? tc.tool?.displayName : undefined, + display: 'response' in tc ? tc.response?.display : undefined, + }); + + eventsToEmit.push( + this._makeToolUpdateEvent({ + requestId: callId, + status, + ...(display ? { display } : {}), + }), + ); + } + } + + if (eventsToEmit.length > 0) { + this._emit(eventsToEmit); + } + } + + private _makeToolUpdateEvent( + payload: Omit< + AgentEvent<'tool_update'>, + 'id' | 'timestamp' | 'streamId' | 'type' + >, + ): AgentEvent<'tool_update'> { + const event = { + ...this._nextEventFields(), + type: 'tool_update', + ...payload, + } satisfies AgentEvent<'tool_update'>; + return event; + } } export class LegacyAgentSession extends AgentSession { diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 0d41c466024..1f6990703be 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -227,11 +227,21 @@ export interface ToolDisplay { format?: ToolDisplayFormat; } +export type ToolEventStatus = + | 'pending' + | 'pending_input' + | 'executing' + | 'succeeded' + | 'errored' + | 'aborted'; + export interface ToolRequest { /** A unique identifier for this tool request to be correlated by the response. */ requestId: string; /** The name of the tool being requested. */ name: string; + /** The status of the tool execution. */ + status: ToolEventStatus; /** The arguments for the tool. */ /** Tool-controlled display information. */ display?: ToolDisplay; @@ -255,6 +265,8 @@ export interface ToolRequest { */ export interface ToolUpdate { requestId: string; + /** The status of the tool execution. */ + status: ToolEventStatus; /** Tool-controlled display information. */ display?: ToolDisplay; content?: ContentPart[]; @@ -276,6 +288,8 @@ export interface ToolUpdate { export interface ToolResponse { requestId: string; name: string; + /** The status of the tool execution. */ + status: ToolEventStatus; /** Tool-controlled display information. */ display?: ToolDisplay; /** Multi-part content to be sent to the model. */ diff --git a/scripts/build_package.js b/scripts/build_package.js index 279e46fa948..5523748a39e 100644 --- a/scripts/build_package.js +++ b/scripts/build_package.js @@ -18,7 +18,7 @@ // limitations under the License. import { execSync } from 'node:child_process'; -import { writeFileSync, existsSync, cpSync } from 'node:fs'; +import { writeFileSync, existsSync, cpSync, rmSync } from 'node:fs'; import { join, basename } from 'node:path'; if (!process.cwd().includes('packages')) { @@ -48,7 +48,14 @@ if (packageName === 'core') { const docsSource = join(process.cwd(), '..', '..', 'docs'); const docsTarget = join(process.cwd(), 'dist', 'docs'); if (existsSync(docsSource)) { - cpSync(docsSource, docsTarget, { recursive: true, dereference: true }); + if (existsSync(docsTarget)) { + rmSync(docsTarget, { recursive: true, force: true }); + } + cpSync(docsSource, docsTarget, { + recursive: true, + dereference: true, + force: true, + }); console.log('Copied documentation to dist/docs'); } } From 4a4f54c20d0d7183de8498f2fc603983ba3d70c3 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Tue, 5 May 2026 12:07:37 -0700 Subject: [PATCH 16/21] test(core): harden messageBus subscription for legacy-agent-session tests --- packages/core/src/agent/legacy-agent-session.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 11007f444c7..1940157ba8a 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -99,10 +99,12 @@ export class LegacyAgentProtocol implements AgentProtocol { this._scheduler = scheduler; } - this._config.messageBus.subscribe( - MessageBusType.TOOL_CALLS_UPDATE, - this._handleToolCallsUpdate.bind(this), - ); + if (this._config.messageBus) { + this._config.messageBus.subscribe( + MessageBusType.TOOL_CALLS_UPDATE, + this._handleToolCallsUpdate.bind(this), + ); + } } get events(): readonly AgentEvent[] { From 48e9d80bdb3dc26a4452bc1cc92886de524fdc4f Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Wed, 6 May 2026 12:12:06 -0700 Subject: [PATCH 17/21] fix(core): filter tool updates by callId to prevent session cross-talk --- packages/core/src/agent/legacy-agent-session.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 1940157ba8a..b1fb496e3a3 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -515,6 +515,11 @@ export class LegacyAgentProtocol implements AgentProtocol { for (const tc of msg.toolCalls) { const callId = tc.request.callId; + + if (!this._translationState.pendingToolNames.has(callId)) { + continue; + } + let status: ToolEventStatus = 'pending'; if (tc.status === 'validating' || tc.status === 'scheduled') { status = 'pending'; From 93cafc2fceabd2ab49d4081bd8b3f047d04d4f3e Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Thu, 7 May 2026 10:35:51 -0700 Subject: [PATCH 18/21] fix(core): use CoreToolCallStatus enum for status mapping in LegacyAgentSession --- .../core/src/agent/legacy-agent-session.ts | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index b1fb496e3a3..92e8622769e 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -13,7 +13,10 @@ import { GeminiEventType } from '../core/turn.js'; import type { Part } from '@google/genai'; import type { GeminiClient } from '../core/client.js'; import type { Config } from '../config/config.js'; -import type { ToolCallRequestInfo } from '../scheduler/types.js'; +import { + type ToolCallRequestInfo, + CoreToolCallStatus, +} from '../scheduler/types.js'; import { Scheduler } from '../scheduler/scheduler.js'; import { recordToolCallInteractions } from '../code_assist/telemetry.js'; import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js'; @@ -289,7 +292,7 @@ export class LegacyAgentProtocol implements AgentProtocol { name: request.name, status: response.error ? 'errored' - : tc.status === 'cancelled' + : tc.status === CoreToolCallStatus.Cancelled ? 'aborted' : 'succeeded', content, @@ -521,18 +524,29 @@ export class LegacyAgentProtocol implements AgentProtocol { } let status: ToolEventStatus = 'pending'; - if (tc.status === 'validating' || tc.status === 'scheduled') { - status = 'pending'; - } else if (tc.status === 'awaiting_approval') { - status = 'pending_input'; - } else if (tc.status === 'executing') { - status = 'executing'; - } else if (tc.status === 'success') { - status = 'succeeded'; - } else if (tc.status === 'error') { - status = 'errored'; - } else if (tc.status === 'cancelled') { - status = 'aborted'; + switch (tc.status) { + case CoreToolCallStatus.Validating: + case CoreToolCallStatus.Scheduled: + status = 'pending'; + break; + case CoreToolCallStatus.AwaitingApproval: + status = 'pending_input'; + break; + case CoreToolCallStatus.Executing: + status = 'executing'; + break; + case CoreToolCallStatus.Success: + status = 'succeeded'; + break; + case CoreToolCallStatus.Error: + status = 'errored'; + break; + case CoreToolCallStatus.Cancelled: + status = 'aborted'; + break; + default: + status = 'pending'; + break; } const lastStatus = this._lastToolStatuses.get(callId); From a7ae31d732874fe54f48d34a6bb361b9aef92690 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Mon, 11 May 2026 11:26:48 -0700 Subject: [PATCH 19/21] fix: fix type issues from upstream merge --- packages/core/src/agents/local-subagent-protocol.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/agents/local-subagent-protocol.ts b/packages/core/src/agents/local-subagent-protocol.ts index 57c6121ea34..1328a072f38 100644 --- a/packages/core/src/agents/local-subagent-protocol.ts +++ b/packages/core/src/agents/local-subagent-protocol.ts @@ -277,6 +277,7 @@ class LocalSubagentProtocol implements AgentProtocol { this._makeEvent('tool_request', { requestId: callId, name, + status: 'executing', args, }), ]; @@ -292,6 +293,7 @@ class LocalSubagentProtocol implements AgentProtocol { this._makeEvent('tool_response', { requestId, name, + status: 'succeeded', content: [{ type: 'text', text: output }], }), ]; From aa8eca7bfebd46afcd824aa276a2a4337a917a27 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Mon, 11 May 2026 14:56:10 -0700 Subject: [PATCH 20/21] fix(agent): implement AgentProtocol disposal to prevent memory leaks --- packages/cli/src/nonInteractiveCliAgentSession.ts | 6 ++++-- packages/cli/src/ui/AppContainer.tsx | 4 ++++ packages/core/src/agent/agent-session.ts | 4 ++++ packages/core/src/agent/legacy-agent-session.ts | 7 +++++++ packages/core/src/agent/types.ts | 5 +++++ scripts/build_package.js | 11 ++--------- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index e0a532becf5..e64b4a5686f 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -193,6 +193,7 @@ export async function runNonInteractive({ let errorToHandle: unknown | undefined; let scheduler: Scheduler | undefined; + let session: LegacyAgentSession | undefined; let abortSession = () => {}; try { consolePatcher.patch(); @@ -296,7 +297,7 @@ export async function runNonInteractive({ } // Create LegacyAgentSession — owns the agentic loop - const session = new LegacyAgentSession({ + session = new LegacyAgentSession({ client: geminiClient, scheduler, config, @@ -305,7 +306,7 @@ export async function runNonInteractive({ // Wire Ctrl+C to session abort abortSession = () => { - void session.abort(); + void session?.abort(); }; abortController.signal.addEventListener('abort', abortSession); if (abortController.signal.aborted) { @@ -640,6 +641,7 @@ export async function runNonInteractive({ cleanupStdinCancellation(); abortController.signal.removeEventListener('abort', abortSession); + session?.dispose(); scheduler?.dispose(); consolePatcher.cleanup(); coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d77c9adfb6f..7cc9b4ec7b2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1180,6 +1180,10 @@ Logging in with Google... Restarting Gemini CLI to continue. [config, getPreferredEditor], ); + useEffect(() => () => { + streamAgent?.dispose?.(); + }, [streamAgent]); + const activeStream = streamAgent ? // eslint-disable-next-line react-hooks/rules-of-hooks useAgentStream({ diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts index 6a4c295fc86..e07695532db 100644 --- a/packages/core/src/agent/agent-session.ts +++ b/packages/core/src/agent/agent-session.ts @@ -34,6 +34,10 @@ export class AgentSession implements AgentProtocol { return this._protocol.abort(); } + dispose(): void { + this._protocol.dispose?.(); + } + get events(): readonly AgentEvent[] { return this._protocol.events; } diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index b1ade30a245..587c107c696 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -71,6 +71,7 @@ export class LegacyAgentProtocol implements AgentProtocol { private _agentEndEmitted = false; private _activeStreamId?: string; private _abortController = new AbortController(); + private _disposalController = new AbortController(); private _nextStreamIdOverride?: string; private _lastToolStatuses = new Map(); @@ -106,10 +107,16 @@ export class LegacyAgentProtocol implements AgentProtocol { this._config.messageBus.subscribe( MessageBusType.TOOL_CALLS_UPDATE, this._handleToolCallsUpdate.bind(this), + { signal: this._disposalController.signal }, ); } } + dispose(): void { + this._disposalController.abort(); + void this.abort(); + } + get events(): readonly AgentEvent[] { return this._events; } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 1f6990703be..9595135da38 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -37,6 +37,11 @@ export interface AgentProtocol extends Trajectory { */ abort(): Promise; + /** + * Disposes of the protocol, cleaning up any long-lived resources. + */ + dispose?(): void; + /** * AgentProtocol implements the Trajectory interface and can retrieve existing events. */ diff --git a/scripts/build_package.js b/scripts/build_package.js index 5523748a39e..279e46fa948 100644 --- a/scripts/build_package.js +++ b/scripts/build_package.js @@ -18,7 +18,7 @@ // limitations under the License. import { execSync } from 'node:child_process'; -import { writeFileSync, existsSync, cpSync, rmSync } from 'node:fs'; +import { writeFileSync, existsSync, cpSync } from 'node:fs'; import { join, basename } from 'node:path'; if (!process.cwd().includes('packages')) { @@ -48,14 +48,7 @@ if (packageName === 'core') { const docsSource = join(process.cwd(), '..', '..', 'docs'); const docsTarget = join(process.cwd(), 'dist', 'docs'); if (existsSync(docsSource)) { - if (existsSync(docsTarget)) { - rmSync(docsTarget, { recursive: true, force: true }); - } - cpSync(docsSource, docsTarget, { - recursive: true, - dereference: true, - force: true, - }); + cpSync(docsSource, docsTarget, { recursive: true, dereference: true }); console.log('Copied documentation to dist/docs'); } } From b6cac32a711b33e17b46e49cfdc1d23ce56c7290 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Mon, 11 May 2026 15:16:09 -0700 Subject: [PATCH 21/21] fix: format --- packages/cli/src/ui/AppContainer.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7cc9b4ec7b2..16321cd2592 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1180,9 +1180,12 @@ Logging in with Google... Restarting Gemini CLI to continue. [config, getPreferredEditor], ); - useEffect(() => () => { + useEffect( + () => () => { streamAgent?.dispose?.(); - }, [streamAgent]); + }, + [streamAgent], + ); const activeStream = streamAgent ? // eslint-disable-next-line react-hooks/rules-of-hooks