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.
This commit is contained in:
Jarrod Whelan
2026-02-10 20:23:18 -08:00
parent 204d62980f
commit 2db8fb20fa
11 changed files with 133 additions and 9 deletions
@@ -82,10 +82,26 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
); );
const truncatedResultDisplay = React.useMemo(() => { 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 // Only truncate string output if not in alternate buffer mode to ensure
// we can scroll through the full output. // we can scroll through the full output.
if (typeof resultDisplay === 'string' && !isAlternateBuffer) { if (typeof displayContent === 'string' && !isAlternateBuffer) {
let text = resultDisplay; let text = displayContent;
if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
} }
@@ -101,7 +117,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
} }
return text; return text;
} }
return resultDisplay; return displayContent;
}, [resultDisplay, isAlternateBuffer, maxLines]); }, [resultDisplay, isAlternateBuffer, maxLines]);
if (!truncatedResultDisplay) return null; if (!truncatedResultDisplay) return null;
+13 -1
View File
@@ -15,11 +15,23 @@ import type {
RetrieveUserQuotaResponse, RetrieveUserQuotaResponse,
SkillDefinition, SkillDefinition,
AgentDefinition, AgentDefinition,
GrepResult,
ListDirectoryResult,
ReadManyFilesResult,
FileDiff,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { PartListUnion } from '@google/genai'; import type { PartListUnion } from '@google/genai';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
export type { ThoughtSummary, SkillDefinition, ToolResultDisplay }; export type {
ThoughtSummary,
SkillDefinition,
ToolResultDisplay,
GrepResult,
ListDirectoryResult,
ReadManyFilesResult,
FileDiff,
};
export enum AuthState { export enum AuthState {
// Attempting to authenticate or re-authenticate // Attempting to authenticate or re-authenticate
@@ -6,6 +6,7 @@
import { type FunctionCall } from '@google/genai'; import { type FunctionCall } from '@google/genai';
import type { import type {
DiffStat,
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolConfirmationPayload, ToolConfirmationPayload,
} from '../tools/tools.js'; } from '../tools/tools.js';
@@ -79,6 +80,7 @@ export type SerializableConfirmationDetails =
fileDiff: string; fileDiff: string;
originalContent: string | null; originalContent: string | null;
newContent: string; newContent: string;
diffStat?: DiffStat;
isModifying?: boolean; isModifying?: boolean;
} }
| { | {
@@ -346,6 +346,8 @@ export class SchedulerStateManager {
response: ToolCallResponseInfo, response: ToolCallResponseInfo,
): ErroredToolCall { ): ErroredToolCall {
const startTime = 'startTime' in call ? call.startTime : undefined; const startTime = 'startTime' in call ? call.startTime : undefined;
const confirmationDetails =
'confirmationDetails' in call ? call.confirmationDetails : undefined;
return { return {
request: call.request, request: call.request,
status: 'error', status: 'error',
@@ -353,6 +355,7 @@ export class SchedulerStateManager {
response, response,
durationMs: startTime ? Date.now() - startTime : undefined, durationMs: startTime ? Date.now() - startTime : undefined,
outcome: call.outcome, outcome: call.outcome,
confirmationDetails,
schedulerId: call.schedulerId, schedulerId: call.schedulerId,
}; };
} }
@@ -415,6 +418,8 @@ export class SchedulerStateManager {
private toCancelled(call: ToolCall, reason: string): CancelledToolCall { private toCancelled(call: ToolCall, reason: string): CancelledToolCall {
this.validateHasToolAndInvocation(call, 'cancelled'); this.validateHasToolAndInvocation(call, 'cancelled');
const startTime = 'startTime' in call ? call.startTime : undefined; 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. // TODO: Refactor this tool-specific logic into the confirmation details payload.
// See: https://github.com/google-gemini/gemini-cli/issues/16716 // See: https://github.com/google-gemini/gemini-cli/issues/16716
@@ -463,6 +468,7 @@ export class SchedulerStateManager {
}, },
durationMs: startTime ? Date.now() - startTime : undefined, durationMs: startTime ? Date.now() - startTime : undefined,
outcome: call.outcome, outcome: call.outcome,
confirmationDetails,
schedulerId: call.schedulerId, schedulerId: call.schedulerId,
}; };
} }
+6
View File
@@ -71,6 +71,9 @@ export type ErroredToolCall = {
tool?: AnyDeclarativeTool; tool?: AnyDeclarativeTool;
durationMs?: number; durationMs?: number;
outcome?: ToolConfirmationOutcome; outcome?: ToolConfirmationOutcome;
confirmationDetails?:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails;
schedulerId?: string; schedulerId?: string;
}; };
@@ -105,6 +108,9 @@ export type CancelledToolCall = {
invocation: AnyToolInvocation; invocation: AnyToolInvocation;
durationMs?: number; durationMs?: number;
outcome?: ToolConfirmationOutcome; outcome?: ToolConfirmationOutcome;
confirmationDetails?:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails;
schedulerId?: string; schedulerId?: string;
}; };
+9
View File
@@ -687,6 +687,14 @@ class EditToolInvocation
'Proposed', 'Proposed',
DEFAULT_DIFF_OPTIONS, DEFAULT_DIFF_OPTIONS,
); );
const diffStat = getDiffStat(
fileName,
editData.currentContent ?? '',
editData.newContent,
this.params.new_string,
);
const ideClient = await IdeClient.getInstance(); const ideClient = await IdeClient.getInstance();
const ideConfirmation = const ideConfirmation =
this.config.getIdeMode() && ideClient.isDiffingEnabled() this.config.getIdeMode() && ideClient.isDiffingEnabled()
@@ -701,6 +709,7 @@ class EditToolInvocation
fileDiff, fileDiff,
originalContent: editData.currentContent, originalContent: editData.currentContent,
newContent: editData.newContent, newContent: editData.newContent,
diffStat,
onConfirm: async (outcome: ToolConfirmationOutcome) => { onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) { if (outcome === ToolConfirmationOutcome.ProceedAlways) {
// No need to publish a policy update as the default policy for // No need to publish a policy update as the default policy for
+8 -1
View File
@@ -286,7 +286,14 @@ class GrepToolInvocation extends BaseToolInvocation<
return { return {
llmContent: llmContent.trim(), 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) { } catch (error) {
debugLogger.warn(`Error during GrepLogic execution: ${error}`); debugLogger.warn(`Error during GrepLogic execution: ${error}`);
+6 -1
View File
@@ -254,7 +254,12 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
return { return {
llmContent: resultMessage, llmContent: resultMessage,
returnDisplay: displayMessage, returnDisplay: {
summary: displayMessage,
files: entries.map(
(entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`,
),
},
}; };
} catch (error) { } catch (error) {
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`; const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
+19 -2
View File
@@ -5,7 +5,11 @@
*/ */
import type { MessageBus } from '../confirmation-bus/message-bus.js'; 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 { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import * as fsPromises from 'node:fs/promises'; import * as fsPromises from 'node:fs/promises';
@@ -441,9 +445,22 @@ ${finalExclusionPatternsForDescription
'No files matching the criteria were found or all were skipped.', '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 { return {
llmContent: contentParts, llmContent: contentParts,
returnDisplay: displayMessage.trim(), returnDisplay,
}; };
} }
} }
+35 -1
View File
@@ -664,7 +664,40 @@ export interface TodoList {
todos: Todo[]; 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'; export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
@@ -706,6 +739,7 @@ export interface ToolEditConfirmationDetails {
fileDiff: string; fileDiff: string;
originalContent: string | null; originalContent: string | null;
newContent: string; newContent: string;
diffStat?: DiffStat;
isModifying?: boolean; isModifying?: boolean;
ideConfirmation?: Promise<DiffUpdateResult>; ideConfirmation?: Promise<DiffUpdateResult>;
} }
+10
View File
@@ -212,6 +212,15 @@ class WriteFileToolInvocation extends BaseToolInvocation<
DEFAULT_DIFF_OPTIONS, 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 ideClient = await IdeClient.getInstance();
const ideConfirmation = const ideConfirmation =
this.config.getIdeMode() && ideClient.isDiffingEnabled() this.config.getIdeMode() && ideClient.isDiffingEnabled()
@@ -226,6 +235,7 @@ class WriteFileToolInvocation extends BaseToolInvocation<
fileDiff, fileDiff,
originalContent, originalContent,
newContent: correctedContent, newContent: correctedContent,
diffStat,
onConfirm: async (outcome: ToolConfirmationOutcome) => { onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) { if (outcome === ToolConfirmationOutcome.ProceedAlways) {
// No need to publish a policy update as the default policy for // No need to publish a policy update as the default policy for