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

View File

@@ -82,10 +82,26 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
);
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<ToolResultDisplayProps> = ({
}
return text;
}
return resultDisplay;
return displayContent;
}, [resultDisplay, isAlternateBuffer, maxLines]);
if (!truncatedResultDisplay) return null;

View File

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

View File

@@ -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;
}
| {

View File

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

View File

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

View File

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

View File

@@ -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}`);

View File

@@ -254,7 +254,12 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
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)}`;

View File

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

View File

@@ -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<DiffUpdateResult>;
}

View File

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