From 2db8fb20fa6eeaddb2cdaad6c79628ae40585e2b Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 10 Feb 2026 20:23:18 -0800 Subject: [PATCH] feat: support structured tool results and preserve terminal state context Establishes core data contracts and state management for enhanced tool outputs. - Introduces GrepResult, ListDirectoryResult, and ReadManyFilesResult types. - Updates tools to return structured data instead of flat strings. - Preserves confirmation details and diff stats in terminal tool states. - Ensures backward compatibility in standard CLI views via safe type guards. --- .../components/messages/ToolResultDisplay.tsx | 22 ++++++++++-- packages/cli/src/ui/types.ts | 14 +++++++- packages/core/src/confirmation-bus/types.ts | 2 ++ packages/core/src/scheduler/state-manager.ts | 6 ++++ packages/core/src/scheduler/types.ts | 6 ++++ packages/core/src/tools/edit.ts | 9 +++++ packages/core/src/tools/grep.ts | 9 ++++- packages/core/src/tools/ls.ts | 7 +++- packages/core/src/tools/read-many-files.ts | 21 +++++++++-- packages/core/src/tools/tools.ts | 36 ++++++++++++++++++- packages/core/src/tools/write-file.ts | 10 ++++++ 11 files changed, 133 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 61f1540017..39a6c96b7c 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -82,10 +82,26 @@ export const ToolResultDisplay: React.FC = ({ ); const truncatedResultDisplay = React.useMemo(() => { + // Helper to handle structured objects that should be displayed as strings + // in the main view (grep, ls, read-many-files). + let displayContent: string | object | undefined = resultDisplay; + + if (typeof resultDisplay === 'object' && resultDisplay !== null) { + // Use a type-safe check for structured results that contain a summary + // (e.g. GrepResult, ListDirectoryResult, ReadManyFilesResult) + const hasSummary = (obj: object): obj is { summary: string } => + 'summary' in obj && + typeof (obj as { summary?: unknown }).summary === 'string'; + + if (hasSummary(resultDisplay)) { + displayContent = resultDisplay.summary; + } + } + // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. - if (typeof resultDisplay === 'string' && !isAlternateBuffer) { - let text = resultDisplay; + if (typeof displayContent === 'string' && !isAlternateBuffer) { + let text = displayContent; if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); } @@ -101,7 +117,7 @@ export const ToolResultDisplay: React.FC = ({ } return text; } - return resultDisplay; + return displayContent; }, [resultDisplay, isAlternateBuffer, maxLines]); if (!truncatedResultDisplay) return null; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index eb914c0edc..ef89ff8b37 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -15,11 +15,23 @@ import type { RetrieveUserQuotaResponse, SkillDefinition, AgentDefinition, + GrepResult, + ListDirectoryResult, + ReadManyFilesResult, + FileDiff, } from '@google/gemini-cli-core'; import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; -export type { ThoughtSummary, SkillDefinition, ToolResultDisplay }; +export type { + ThoughtSummary, + SkillDefinition, + ToolResultDisplay, + GrepResult, + ListDirectoryResult, + ReadManyFilesResult, + FileDiff, +}; export enum AuthState { // Attempting to authenticate or re-authenticate diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 8aa21f8ca1..dba7f78418 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -6,6 +6,7 @@ import { type FunctionCall } from '@google/genai'; import type { + DiffStat, ToolConfirmationOutcome, ToolConfirmationPayload, } from '../tools/tools.js'; @@ -79,6 +80,7 @@ export type SerializableConfirmationDetails = fileDiff: string; originalContent: string | null; newContent: string; + diffStat?: DiffStat; isModifying?: boolean; } | { diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index 21e931a18a..3e28d27c26 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -346,6 +346,8 @@ export class SchedulerStateManager { response: ToolCallResponseInfo, ): ErroredToolCall { const startTime = 'startTime' in call ? call.startTime : undefined; + const confirmationDetails = + 'confirmationDetails' in call ? call.confirmationDetails : undefined; return { request: call.request, status: 'error', @@ -353,6 +355,7 @@ export class SchedulerStateManager { response, durationMs: startTime ? Date.now() - startTime : undefined, outcome: call.outcome, + confirmationDetails, schedulerId: call.schedulerId, }; } @@ -415,6 +418,8 @@ export class SchedulerStateManager { private toCancelled(call: ToolCall, reason: string): CancelledToolCall { this.validateHasToolAndInvocation(call, 'cancelled'); const startTime = 'startTime' in call ? call.startTime : undefined; + const confirmationDetails = + 'confirmationDetails' in call ? call.confirmationDetails : undefined; // TODO: Refactor this tool-specific logic into the confirmation details payload. // See: https://github.com/google-gemini/gemini-cli/issues/16716 @@ -463,6 +468,7 @@ export class SchedulerStateManager { }, durationMs: startTime ? Date.now() - startTime : undefined, outcome: call.outcome, + confirmationDetails, schedulerId: call.schedulerId, }; } diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index c0b6cae3d7..bca966e0c4 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -71,6 +71,9 @@ export type ErroredToolCall = { tool?: AnyDeclarativeTool; durationMs?: number; outcome?: ToolConfirmationOutcome; + confirmationDetails?: + | ToolCallConfirmationDetails + | SerializableConfirmationDetails; schedulerId?: string; }; @@ -105,6 +108,9 @@ export type CancelledToolCall = { invocation: AnyToolInvocation; durationMs?: number; outcome?: ToolConfirmationOutcome; + confirmationDetails?: + | ToolCallConfirmationDetails + | SerializableConfirmationDetails; schedulerId?: string; }; diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index d7c8973a91..657ecfe793 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -687,6 +687,14 @@ class EditToolInvocation 'Proposed', DEFAULT_DIFF_OPTIONS, ); + + const diffStat = getDiffStat( + fileName, + editData.currentContent ?? '', + editData.newContent, + this.params.new_string, + ); + const ideClient = await IdeClient.getInstance(); const ideConfirmation = this.config.getIdeMode() && ideClient.isDiffingEnabled() @@ -701,6 +709,7 @@ class EditToolInvocation fileDiff, originalContent: editData.currentContent, newContent: editData.newContent, + diffStat, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { // No need to publish a policy update as the default policy for diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index c47d65c37b..41ef2533de 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -286,7 +286,14 @@ class GrepToolInvocation extends BaseToolInvocation< return { llmContent: llmContent.trim(), - returnDisplay: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`, + returnDisplay: { + summary: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`, + matches: allMatches.map((m) => ({ + filePath: m.filePath, + lineNumber: m.lineNumber, + line: m.line, + })), + }, }; } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index a264f5cf54..a4645fb8a3 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -254,7 +254,12 @@ class LSToolInvocation extends BaseToolInvocation { return { llmContent: resultMessage, - returnDisplay: displayMessage, + returnDisplay: { + summary: displayMessage, + files: entries.map( + (entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`, + ), + }, }; } catch (error) { const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`; diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 89919dc2cb..9f35927439 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -5,7 +5,11 @@ */ import type { MessageBus } from '../confirmation-bus/message-bus.js'; -import type { ToolInvocation, ToolResult } from './tools.js'; +import type { + ToolInvocation, + ToolResult, + ReadManyFilesResult, +} from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { getErrorMessage } from '../utils/errors.js'; import * as fsPromises from 'node:fs/promises'; @@ -441,9 +445,22 @@ ${finalExclusionPatternsForDescription 'No files matching the criteria were found or all were skipped.', ); } + + const returnDisplay: ReadManyFilesResult = { + summary: + processedFilesRelativePaths.length > 0 + ? `Read ${processedFilesRelativePaths.length} file(s)` + : 'No files read', + files: processedFilesRelativePaths, + skipped: skippedFiles, + include: this.params.include, + excludes: effectiveExcludes, + targetDir: this.config.getTargetDir(), + }; + return { llmContent: contentParts, - returnDisplay: displayMessage.trim(), + returnDisplay, }; } } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 3d90e80699..07656285f0 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -664,7 +664,40 @@ export interface TodoList { todos: Todo[]; } -export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList; +export interface GrepResult { + summary: string; + matches: Array<{ + filePath: string; + lineNumber: number; + line: string; + }>; + payload?: string; +} + +export interface ListDirectoryResult { + summary: string; + files: string[]; + payload?: string; +} + +export interface ReadManyFilesResult { + summary: string; + files: string[]; + skipped?: Array<{ path: string; reason: string }>; + include?: string[]; + excludes?: string[]; + targetDir?: string; + payload?: string; +} + +export type ToolResultDisplay = + | string + | FileDiff + | AnsiOutput + | TodoList + | GrepResult + | ListDirectoryResult + | ReadManyFilesResult; export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; @@ -706,6 +739,7 @@ export interface ToolEditConfirmationDetails { fileDiff: string; originalContent: string | null; newContent: string; + diffStat?: DiffStat; isModifying?: boolean; ideConfirmation?: Promise; } diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 8dfc4d7855..3369042777 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -212,6 +212,15 @@ class WriteFileToolInvocation extends BaseToolInvocation< DEFAULT_DIFF_OPTIONS, ); + const originallyProposedContent = + this.params.ai_proposed_content || this.params.content; + const diffStat = getDiffStat( + fileName, + originalContent, + originallyProposedContent, + this.params.content, + ); + const ideClient = await IdeClient.getInstance(); const ideConfirmation = this.config.getIdeMode() && ideClient.isDiffingEnabled() @@ -226,6 +235,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< fileDiff, originalContent, newContent: correctedContent, + diffStat, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { // No need to publish a policy update as the default policy for