feat: Add flow to allow modifying edits during edit tool call (#808)
This commit is contained in:
parent
584286cfd9
commit
9efca40dae
|
@ -172,6 +172,7 @@ export async function loadCliConfig(
|
||||||
fileFilteringRespectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
fileFilteringRespectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
||||||
fileFilteringAllowBuildArtifacts:
|
fileFilteringAllowBuildArtifacts:
|
||||||
settings.fileFiltering?.allowBuildArtifacts,
|
settings.fileFiltering?.allowBuildArtifacts,
|
||||||
|
enableModifyWithExternalEditors: settings.enableModifyWithExternalEditors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,7 @@ export interface Settings {
|
||||||
contextFileName?: string;
|
contextFileName?: string;
|
||||||
accessibility?: AccessibilitySettings;
|
accessibility?: AccessibilitySettings;
|
||||||
telemetry?: boolean;
|
telemetry?: boolean;
|
||||||
|
enableModifyWithExternalEditors?: boolean;
|
||||||
|
|
||||||
// Git-aware file filtering settings
|
// Git-aware file filtering settings
|
||||||
fileFiltering?: {
|
fileFiltering?: {
|
||||||
|
|
|
@ -355,6 +355,7 @@ export const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
key={h.id}
|
key={h.id}
|
||||||
item={h}
|
item={h}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
|
config={config}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
]}
|
]}
|
||||||
|
@ -370,6 +371,7 @@ export const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
||||||
item={{ ...item, id: 0 }}
|
item={{ ...item, id: 0 }}
|
||||||
isPending={true}
|
isPending={true}
|
||||||
|
config={config}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -15,17 +15,20 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||||
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||||
import { Box } from 'ink';
|
import { Box } from 'ink';
|
||||||
import { AboutBox } from './AboutBox.js';
|
import { AboutBox } from './AboutBox.js';
|
||||||
|
import { Config } from '@gemini-cli/core';
|
||||||
|
|
||||||
interface HistoryItemDisplayProps {
|
interface HistoryItemDisplayProps {
|
||||||
item: HistoryItem;
|
item: HistoryItem;
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight: number;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
config?: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
item,
|
item,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
isPending,
|
isPending,
|
||||||
|
config,
|
||||||
}) => (
|
}) => (
|
||||||
<Box flexDirection="column" key={item.id}>
|
<Box flexDirection="column" key={item.id}>
|
||||||
{/* Render standard message types */}
|
{/* Render standard message types */}
|
||||||
|
@ -60,6 +63,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
toolCalls={item.tools}
|
toolCalls={item.tools}
|
||||||
groupId={item.id}
|
groupId={item.id}
|
||||||
availableTerminalHeight={availableTerminalHeight}
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
config={config}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
ToolExecuteConfirmationDetails,
|
ToolExecuteConfirmationDetails,
|
||||||
ToolMcpConfirmationDetails,
|
ToolMcpConfirmationDetails,
|
||||||
|
checkHasEditor,
|
||||||
|
Config,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import {
|
import {
|
||||||
RadioButtonSelect,
|
RadioButtonSelect,
|
||||||
|
@ -21,11 +23,12 @@ import {
|
||||||
|
|
||||||
export interface ToolConfirmationMessageProps {
|
export interface ToolConfirmationMessageProps {
|
||||||
confirmationDetails: ToolCallConfirmationDetails;
|
confirmationDetails: ToolCallConfirmationDetails;
|
||||||
|
config?: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolConfirmationMessage: React.FC<
|
export const ToolConfirmationMessage: React.FC<
|
||||||
ToolConfirmationMessageProps
|
ToolConfirmationMessageProps
|
||||||
> = ({ confirmationDetails }) => {
|
> = ({ confirmationDetails, config }) => {
|
||||||
const { onConfirm } = confirmationDetails;
|
const { onConfirm } = confirmationDetails;
|
||||||
|
|
||||||
useInput((_, key) => {
|
useInput((_, key) => {
|
||||||
|
@ -44,6 +47,24 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
>();
|
>();
|
||||||
|
|
||||||
if (confirmationDetails.type === 'edit') {
|
if (confirmationDetails.type === 'edit') {
|
||||||
|
if (confirmationDetails.isModifying) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minWidth="90%"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={Colors.Gray}
|
||||||
|
justifyContent="space-around"
|
||||||
|
padding={1}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Text>Modify in progress: </Text>
|
||||||
|
<Text color={Colors.AccentGreen}>
|
||||||
|
Save and close external editor to continue
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Body content is now the DiffRenderer, passing filename to it
|
// Body content is now the DiffRenderer, passing filename to it
|
||||||
// The bordered box is removed from here and handled within DiffRenderer
|
// The bordered box is removed from here and handled within DiffRenderer
|
||||||
bodyContent = (
|
bodyContent = (
|
||||||
|
@ -63,8 +84,28 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
label: 'Yes, allow always',
|
label: 'Yes, allow always',
|
||||||
value: ToolConfirmationOutcome.ProceedAlways,
|
value: ToolConfirmationOutcome.ProceedAlways,
|
||||||
},
|
},
|
||||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Conditionally add editor options if editors are installed
|
||||||
|
const notUsingSandbox = !process.env.SANDBOX;
|
||||||
|
const externalEditorsEnabled =
|
||||||
|
config?.getEnableModifyWithExternalEditors() ?? false;
|
||||||
|
|
||||||
|
if (checkHasEditor('vscode') && notUsingSandbox && externalEditorsEnabled) {
|
||||||
|
options.push({
|
||||||
|
label: 'Modify with VS Code',
|
||||||
|
value: ToolConfirmationOutcome.ModifyVSCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkHasEditor('vim') && externalEditorsEnabled) {
|
||||||
|
options.push({
|
||||||
|
label: 'Modify with vim',
|
||||||
|
value: ToolConfirmationOutcome.ModifyVim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel });
|
||||||
} else if (confirmationDetails.type === 'exec') {
|
} else if (confirmationDetails.type === 'exec') {
|
||||||
const executionProps =
|
const executionProps =
|
||||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||||
|
|
|
@ -10,17 +10,20 @@ import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
||||||
import { ToolMessage } from './ToolMessage.js';
|
import { ToolMessage } from './ToolMessage.js';
|
||||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
|
import { Config } from '@gemini-cli/core';
|
||||||
|
|
||||||
interface ToolGroupMessageProps {
|
interface ToolGroupMessageProps {
|
||||||
groupId: number;
|
groupId: number;
|
||||||
toolCalls: IndividualToolCallDisplay[];
|
toolCalls: IndividualToolCallDisplay[];
|
||||||
availableTerminalHeight: number;
|
availableTerminalHeight: number;
|
||||||
|
config?: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component renders the border and maps the tools using ToolMessage
|
// Main component renders the border and maps the tools using ToolMessage
|
||||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
toolCalls,
|
toolCalls,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
config,
|
||||||
}) => {
|
}) => {
|
||||||
const hasPending = !toolCalls.every(
|
const hasPending = !toolCalls.every(
|
||||||
(t) => t.status === ToolCallStatus.Success,
|
(t) => t.status === ToolCallStatus.Success,
|
||||||
|
@ -80,6 +83,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
tool.confirmationDetails && (
|
tool.confirmationDetails && (
|
||||||
<ToolConfirmationMessage
|
<ToolConfirmationMessage
|
||||||
confirmationDetails={tool.confirmationDetails}
|
confirmationDetails={tool.confirmationDetails}
|
||||||
|
config={config}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -1440,7 +1440,7 @@ export interface TextBuffer {
|
||||||
key: Record<string, boolean>,
|
key: Record<string, boolean>,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
/**
|
/**
|
||||||
* Opens the current buffer contents in the user’s preferred terminal text
|
* Opens the current buffer contents in the user's preferred terminal text
|
||||||
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
||||||
* until the editor exits, then reloads the file and replaces the in‑memory
|
* until the editor exits, then reloads the file and replaces the in‑memory
|
||||||
* buffer with whatever the user saved.
|
* buffer with whatever the user saved.
|
||||||
|
@ -1451,7 +1451,7 @@ export interface TextBuffer {
|
||||||
*
|
*
|
||||||
* Note: We purposefully rely on the *synchronous* spawn API so that the
|
* Note: We purposefully rely on the *synchronous* spawn API so that the
|
||||||
* calling process genuinely waits for the editor to close before
|
* calling process genuinely waits for the editor to close before
|
||||||
* continuing. This mirrors Git’s behaviour and simplifies downstream
|
* continuing. This mirrors Git's behaviour and simplifies downstream
|
||||||
* control‑flow (callers can simply `await` the Promise).
|
* control‑flow (callers can simply `await` the Promise).
|
||||||
*/
|
*/
|
||||||
openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;
|
openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;
|
||||||
|
|
|
@ -78,6 +78,7 @@ export interface ConfigParameters {
|
||||||
telemetryLogUserPromptsEnabled?: boolean;
|
telemetryLogUserPromptsEnabled?: boolean;
|
||||||
fileFilteringRespectGitIgnore?: boolean;
|
fileFilteringRespectGitIgnore?: boolean;
|
||||||
fileFilteringAllowBuildArtifacts?: boolean;
|
fileFilteringAllowBuildArtifacts?: boolean;
|
||||||
|
enableModifyWithExternalEditors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
@ -106,6 +107,7 @@ export class Config {
|
||||||
private readonly geminiIgnorePatterns: string[] = [];
|
private readonly geminiIgnorePatterns: string[] = [];
|
||||||
private readonly fileFilteringRespectGitIgnore: boolean;
|
private readonly fileFilteringRespectGitIgnore: boolean;
|
||||||
private readonly fileFilteringAllowBuildArtifacts: boolean;
|
private readonly fileFilteringAllowBuildArtifacts: boolean;
|
||||||
|
private readonly enableModifyWithExternalEditors: boolean;
|
||||||
private fileDiscoveryService: FileDiscoveryService | null = null;
|
private fileDiscoveryService: FileDiscoveryService | null = null;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
|
@ -135,6 +137,8 @@ export class Config {
|
||||||
params.fileFilteringRespectGitIgnore ?? true;
|
params.fileFilteringRespectGitIgnore ?? true;
|
||||||
this.fileFilteringAllowBuildArtifacts =
|
this.fileFilteringAllowBuildArtifacts =
|
||||||
params.fileFilteringAllowBuildArtifacts ?? false;
|
params.fileFilteringAllowBuildArtifacts ?? false;
|
||||||
|
this.enableModifyWithExternalEditors =
|
||||||
|
params.enableModifyWithExternalEditors ?? false;
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
|
@ -266,6 +270,10 @@ export class Config {
|
||||||
return this.fileFilteringAllowBuildArtifacts;
|
return this.fileFilteringAllowBuildArtifacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEnableModifyWithExternalEditors(): boolean {
|
||||||
|
return this.enableModifyWithExternalEditors;
|
||||||
|
}
|
||||||
|
|
||||||
async getFileService(): Promise<FileDiscoveryService> {
|
async getFileService(): Promise<FileDiscoveryService> {
|
||||||
if (!this.fileDiscoveryService) {
|
if (!this.fileDiscoveryService) {
|
||||||
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir);
|
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir);
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolRegistry,
|
ToolRegistry,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
|
EditTool,
|
||||||
|
EditToolParams,
|
||||||
} from '../index.js';
|
} from '../index.js';
|
||||||
import { Part, PartListUnion } from '@google/genai';
|
import { Part, PartListUnion } from '@google/genai';
|
||||||
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
|
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
|
||||||
|
@ -358,6 +360,16 @@ export class CoreToolScheduler {
|
||||||
this.checkAndNotifyCompletion();
|
this.checkAndNotifyCompletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setArgsInternal(targetCallId: string, args: unknown): void {
|
||||||
|
this.toolCalls = this.toolCalls.map((call) => {
|
||||||
|
if (call.request.callId !== targetCallId) return call;
|
||||||
|
return {
|
||||||
|
...call,
|
||||||
|
request: { ...call.request, args: args as Record<string, unknown> },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private isRunning(): boolean {
|
private isRunning(): boolean {
|
||||||
return this.toolCalls.some(
|
return this.toolCalls.some(
|
||||||
(call) =>
|
(call) =>
|
||||||
|
@ -471,6 +483,33 @@ export class CoreToolScheduler {
|
||||||
'cancelled',
|
'cancelled',
|
||||||
'User did not allow tool call',
|
'User did not allow tool call',
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
outcome === ToolConfirmationOutcome.ModifyVSCode ||
|
||||||
|
outcome === ToolConfirmationOutcome.ModifyVim
|
||||||
|
) {
|
||||||
|
const waitingToolCall = toolCall as WaitingToolCall;
|
||||||
|
if (waitingToolCall?.confirmationDetails?.type === 'edit') {
|
||||||
|
const editTool = waitingToolCall.tool as EditTool;
|
||||||
|
this.setStatusInternal(callId, 'awaiting_approval', {
|
||||||
|
...waitingToolCall.confirmationDetails,
|
||||||
|
isModifying: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modifyResults = await editTool.onModify(
|
||||||
|
waitingToolCall.request.args as unknown as EditToolParams,
|
||||||
|
this.abortController.signal,
|
||||||
|
outcome,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modifyResults) {
|
||||||
|
this.setArgsInternal(callId, modifyResults.updatedParams);
|
||||||
|
this.setStatusInternal(callId, 'awaiting_approval', {
|
||||||
|
...waitingToolCall.confirmationDetails,
|
||||||
|
fileDiff: modifyResults.updatedDiff,
|
||||||
|
isModifying: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.setStatusInternal(callId, 'scheduled');
|
this.setStatusInternal(callId, 'scheduled');
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ export * from './utils/errors.js';
|
||||||
export * from './utils/getFolderStructure.js';
|
export * from './utils/getFolderStructure.js';
|
||||||
export * from './utils/memoryDiscovery.js';
|
export * from './utils/memoryDiscovery.js';
|
||||||
export * from './utils/gitIgnoreParser.js';
|
export * from './utils/gitIgnoreParser.js';
|
||||||
|
export * from './utils/editor.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn());
|
const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn());
|
||||||
const mockGenerateJson = vi.hoisted(() => vi.fn());
|
const mockGenerateJson = vi.hoisted(() => vi.fn());
|
||||||
|
const mockOpenDiff = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('../utils/editCorrector.js', () => ({
|
vi.mock('../utils/editCorrector.js', () => ({
|
||||||
ensureCorrectEdit: mockEnsureCorrectEdit,
|
ensureCorrectEdit: mockEnsureCorrectEdit,
|
||||||
|
@ -19,6 +20,10 @@ vi.mock('../core/client.js', () => ({
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/editor.js', () => ({
|
||||||
|
openDiff: mockOpenDiff,
|
||||||
|
}));
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest';
|
||||||
import { EditTool, EditToolParams } from './edit.js';
|
import { EditTool, EditToolParams } from './edit.js';
|
||||||
import { FileDiff } from './tools.js';
|
import { FileDiff } from './tools.js';
|
||||||
|
@ -27,6 +32,7 @@ import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { ApprovalMode, Config } from '../config/config.js';
|
import { ApprovalMode, Config } from '../config/config.js';
|
||||||
import { Content, Part, SchemaUnion } from '@google/genai';
|
import { Content, Part, SchemaUnion } from '@google/genai';
|
||||||
|
import { ToolConfirmationOutcome } from './tools.js';
|
||||||
|
|
||||||
describe('EditTool', () => {
|
describe('EditTool', () => {
|
||||||
let tool: EditTool;
|
let tool: EditTool;
|
||||||
|
@ -715,4 +721,140 @@ function makeRequest() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onModify', () => {
|
||||||
|
const testFile = 'some_file.txt';
|
||||||
|
let filePath: string;
|
||||||
|
const diffDir = path.join(os.tmpdir(), 'gemini-cli-edit-tool-diffs');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
filePath = path.join(rootDir, testFile);
|
||||||
|
mockOpenDiff.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(diffDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create temporary files, call openDiff, and return updated params with diff', async () => {
|
||||||
|
const originalContent = 'original content';
|
||||||
|
const params: EditToolParams = {
|
||||||
|
file_path: filePath,
|
||||||
|
edits: [
|
||||||
|
{ old_string: originalContent, new_string: 'modified content' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, originalContent, 'utf8');
|
||||||
|
|
||||||
|
const result = await tool.onModify(
|
||||||
|
params,
|
||||||
|
new AbortController().signal,
|
||||||
|
ToolConfirmationOutcome.ModifyVSCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
|
||||||
|
const [oldPath, newPath] = mockOpenDiff.mock.calls[0];
|
||||||
|
expect(oldPath).toMatch(
|
||||||
|
/gemini-cli-edit-tool-diffs[/\\]gemini-cli-edit-some_file\.txt-old-\d+/,
|
||||||
|
);
|
||||||
|
expect(newPath).toMatch(
|
||||||
|
/gemini-cli-edit-tool-diffs[/\\]gemini-cli-edit-some_file\.txt-new-\d+/,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.updatedParams).toEqual({
|
||||||
|
file_path: filePath,
|
||||||
|
edits: [
|
||||||
|
{ old_string: originalContent, new_string: 'modified content' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result!.updatedDiff).toEqual(`Index: some_file.txt
|
||||||
|
===================================================================
|
||||||
|
--- some_file.txt\tCurrent
|
||||||
|
+++ some_file.txt\tProposed
|
||||||
|
@@ -1,1 +1,1 @@
|
||||||
|
-original content
|
||||||
|
\\ No newline at end of file
|
||||||
|
+modified content
|
||||||
|
\\ No newline at end of file
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Verify temp files are cleaned up
|
||||||
|
expect(fs.existsSync(oldPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(newPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent files and return updated params', async () => {
|
||||||
|
const params: EditToolParams = {
|
||||||
|
file_path: filePath,
|
||||||
|
edits: [{ old_string: '', new_string: 'new file content' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await tool.onModify(
|
||||||
|
params,
|
||||||
|
new AbortController().signal,
|
||||||
|
ToolConfirmationOutcome.ModifyVSCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [oldPath, newPath] = mockOpenDiff.mock.calls[0];
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.updatedParams).toEqual({
|
||||||
|
file_path: filePath,
|
||||||
|
edits: [{ old_string: '', new_string: 'new file content' }],
|
||||||
|
});
|
||||||
|
expect(result!.updatedDiff).toContain('new file content');
|
||||||
|
|
||||||
|
// Verify temp files are cleaned up
|
||||||
|
expect(fs.existsSync(oldPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(newPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up previous temp files before creating new ones', async () => {
|
||||||
|
const params: EditToolParams = {
|
||||||
|
file_path: filePath,
|
||||||
|
edits: [{ old_string: 'old', new_string: 'new' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, 'some old content', 'utf8');
|
||||||
|
|
||||||
|
// Call onModify first time
|
||||||
|
const result1 = await tool.onModify(
|
||||||
|
params,
|
||||||
|
new AbortController().signal,
|
||||||
|
ToolConfirmationOutcome.ModifyVSCode,
|
||||||
|
);
|
||||||
|
const firstCall = mockOpenDiff.mock.calls[0];
|
||||||
|
const firstOldPath = firstCall[0];
|
||||||
|
const firstNewPath = firstCall[1];
|
||||||
|
|
||||||
|
expect(result1).toBeDefined();
|
||||||
|
expect(fs.existsSync(firstOldPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(firstNewPath)).toBe(false);
|
||||||
|
|
||||||
|
// Ensure different timestamps so that the file names are different for testing.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||||
|
|
||||||
|
const result2 = await tool.onModify(
|
||||||
|
params,
|
||||||
|
new AbortController().signal,
|
||||||
|
ToolConfirmationOutcome.ModifyVSCode,
|
||||||
|
);
|
||||||
|
const secondCall = mockOpenDiff.mock.calls[1];
|
||||||
|
const secondOldPath = secondCall[0];
|
||||||
|
const secondNewPath = secondCall[1];
|
||||||
|
|
||||||
|
// Call onModify second time
|
||||||
|
expect(result2).toBeDefined();
|
||||||
|
expect(fs.existsSync(secondOldPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(secondNewPath)).toBe(false);
|
||||||
|
|
||||||
|
// Verify different file names were used
|
||||||
|
expect(firstOldPath).not.toBe(secondOldPath);
|
||||||
|
expect(firstNewPath).not.toBe(secondNewPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,8 +4,9 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
import {
|
import {
|
||||||
BaseTool,
|
BaseTool,
|
||||||
|
@ -22,6 +23,7 @@ import { GeminiClient } from '../core/client.js';
|
||||||
import { Config, ApprovalMode } from '../config/config.js';
|
import { Config, ApprovalMode } from '../config/config.js';
|
||||||
import { ensureCorrectEdit } from '../utils/editCorrector.js';
|
import { ensureCorrectEdit } from '../utils/editCorrector.js';
|
||||||
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
|
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
|
||||||
|
import { openDiff } from '../utils/editor.js';
|
||||||
import { ReadFileTool } from './read-file.js';
|
import { ReadFileTool } from './read-file.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -75,6 +77,8 @@ export class EditTool extends BaseTool<EditToolParams, EditResult> {
|
||||||
private readonly config: Config;
|
private readonly config: Config;
|
||||||
private readonly rootDirectory: string;
|
private readonly rootDirectory: string;
|
||||||
private readonly client: GeminiClient;
|
private readonly client: GeminiClient;
|
||||||
|
private tempOldDiffPath?: string;
|
||||||
|
private tempNewDiffPath?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of the EditLogic
|
* Creates a new instance of the EditLogic
|
||||||
|
@ -514,6 +518,142 @@ Expectation for required parameters:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates temp files for the current and proposed file contents and opens a diff tool.
|
||||||
|
* When the diff tool is closed, the tool will check if the file has been modified and provide the updated params.
|
||||||
|
* @returns Updated params and diff if the file has been modified, undefined otherwise.
|
||||||
|
*/
|
||||||
|
async onModify(
|
||||||
|
params: EditToolParams,
|
||||||
|
_abortSignal: AbortSignal,
|
||||||
|
outcome: ToolConfirmationOutcome,
|
||||||
|
): Promise<
|
||||||
|
{ updatedParams: EditToolParams; updatedDiff: string } | undefined
|
||||||
|
> {
|
||||||
|
const { oldPath, newPath } = this.createTempFiles(params);
|
||||||
|
this.tempOldDiffPath = oldPath;
|
||||||
|
this.tempNewDiffPath = newPath;
|
||||||
|
|
||||||
|
await openDiff(
|
||||||
|
this.tempOldDiffPath,
|
||||||
|
this.tempNewDiffPath,
|
||||||
|
outcome === ToolConfirmationOutcome.ModifyVSCode ? 'vscode' : 'vim',
|
||||||
|
);
|
||||||
|
return await this.getUpdatedParamsIfModified(params, _abortSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUpdatedParamsIfModified(
|
||||||
|
params: EditToolParams,
|
||||||
|
_abortSignal: AbortSignal,
|
||||||
|
): Promise<
|
||||||
|
{ updatedParams: EditToolParams; updatedDiff: string } | undefined
|
||||||
|
> {
|
||||||
|
if (!this.tempOldDiffPath || !this.tempNewDiffPath) return undefined;
|
||||||
|
let oldContent = '';
|
||||||
|
let newContent = '';
|
||||||
|
try {
|
||||||
|
oldContent = fs.readFileSync(this.tempOldDiffPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||||
|
oldContent = '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
newContent = fs.readFileSync(this.tempNewDiffPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||||
|
newContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine the edits into a single edit
|
||||||
|
const updatedParams: EditToolParams = {
|
||||||
|
...params,
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
old_string: oldContent,
|
||||||
|
new_string: newContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedDiff = Diff.createPatch(
|
||||||
|
path.basename(params.file_path),
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
'Current',
|
||||||
|
'Proposed',
|
||||||
|
DEFAULT_DIFF_OPTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.deleteTempFiles();
|
||||||
|
return { updatedParams, updatedDiff };
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTempFiles(params: EditToolParams): Record<string, string> {
|
||||||
|
this.deleteTempFiles();
|
||||||
|
|
||||||
|
const tempDir = os.tmpdir();
|
||||||
|
const diffDir = path.join(tempDir, 'gemini-cli-edit-tool-diffs');
|
||||||
|
|
||||||
|
if (!fs.existsSync(diffDir)) {
|
||||||
|
fs.mkdirSync(diffDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(params.file_path);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const tempOldPath = path.join(
|
||||||
|
diffDir,
|
||||||
|
`gemini-cli-edit-${fileName}-old-${timestamp}`,
|
||||||
|
);
|
||||||
|
const tempNewPath = path.join(
|
||||||
|
diffDir,
|
||||||
|
`gemini-cli-edit-${fileName}-new-${timestamp}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentContent = '';
|
||||||
|
try {
|
||||||
|
currentContent = fs.readFileSync(params.file_path, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||||
|
currentContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let proposedContent = currentContent;
|
||||||
|
for (const edit of params.edits) {
|
||||||
|
proposedContent = this._applyReplacement(
|
||||||
|
proposedContent,
|
||||||
|
edit.old_string,
|
||||||
|
edit.new_string,
|
||||||
|
edit.old_string === '' && currentContent === '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(tempOldPath, currentContent, 'utf8');
|
||||||
|
fs.writeFileSync(tempNewPath, proposedContent, 'utf8');
|
||||||
|
return {
|
||||||
|
oldPath: tempOldPath,
|
||||||
|
newPath: tempNewPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteTempFiles(): void {
|
||||||
|
try {
|
||||||
|
if (this.tempOldDiffPath) {
|
||||||
|
fs.unlinkSync(this.tempOldDiffPath);
|
||||||
|
this.tempOldDiffPath = undefined;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`Error deleting temp diff file: `, this.tempOldDiffPath);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.tempNewDiffPath) {
|
||||||
|
fs.unlinkSync(this.tempNewDiffPath);
|
||||||
|
this.tempNewDiffPath = undefined;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`Error deleting temp diff file: `, this.tempNewDiffPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates parent directories if they don't exist
|
* Creates parent directories if they don't exist
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -202,6 +202,7 @@ export interface ToolEditConfirmationDetails {
|
||||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileDiff: string;
|
fileDiff: string;
|
||||||
|
isModifying?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolExecuteConfirmationDetails {
|
export interface ToolExecuteConfirmationDetails {
|
||||||
|
@ -231,5 +232,7 @@ export enum ToolConfirmationOutcome {
|
||||||
ProceedAlways = 'proceed_always',
|
ProceedAlways = 'proceed_always',
|
||||||
ProceedAlwaysServer = 'proceed_always_server',
|
ProceedAlwaysServer = 'proceed_always_server',
|
||||||
ProceedAlwaysTool = 'proceed_always_tool',
|
ProceedAlwaysTool = 'proceed_always_tool',
|
||||||
|
ModifyVSCode = 'modify_vscode',
|
||||||
|
ModifyVim = 'modify_vim',
|
||||||
Cancel = 'cancel',
|
Cancel = 'cancel',
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync, spawn } from 'child_process';
|
||||||
|
|
||||||
|
type EditorType = 'vscode' | 'vim';
|
||||||
|
|
||||||
|
interface DiffCommand {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkHasEditor(editor: EditorType): boolean {
|
||||||
|
const commandExists = (cmd: string): boolean => {
|
||||||
|
try {
|
||||||
|
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editor === 'vscode') {
|
||||||
|
return commandExists('code');
|
||||||
|
} else if (editor === 'vim') {
|
||||||
|
return commandExists('vim');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the diff command for a specific editor.
|
||||||
|
*/
|
||||||
|
export function getDiffCommand(
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
editor: EditorType,
|
||||||
|
): DiffCommand | null {
|
||||||
|
switch (editor) {
|
||||||
|
case 'vscode':
|
||||||
|
return {
|
||||||
|
command: 'code',
|
||||||
|
args: ['--wait', '--diff', oldPath, newPath],
|
||||||
|
};
|
||||||
|
case 'vim':
|
||||||
|
return {
|
||||||
|
command: 'vim',
|
||||||
|
args: [
|
||||||
|
'-d',
|
||||||
|
// skip viminfo file to avoid E138 errors
|
||||||
|
'-i',
|
||||||
|
'NONE',
|
||||||
|
// make the left window read-only and the right window editable
|
||||||
|
'-c',
|
||||||
|
'wincmd h | set readonly | wincmd l',
|
||||||
|
// set up colors for diffs
|
||||||
|
'-c',
|
||||||
|
'highlight DiffAdd cterm=bold ctermbg=22 guibg=#005f00 | highlight DiffChange cterm=bold ctermbg=24 guibg=#005f87 | highlight DiffText ctermbg=21 guibg=#0000af | highlight DiffDelete ctermbg=52 guibg=#5f0000',
|
||||||
|
// Show helpful messages
|
||||||
|
'-c',
|
||||||
|
'set showtabline=2 | set tabline=[Instructions]\\ :wqa(save\\ &\\ quit)\\ \\|\\ i/esc(toggle\\ edit\\ mode)',
|
||||||
|
'-c',
|
||||||
|
'wincmd h | setlocal statusline=OLD\\ FILE',
|
||||||
|
'-c',
|
||||||
|
'wincmd l | setlocal statusline=%#StatusBold#NEW\\ FILE\\ :wqa(save\\ &\\ quit)\\ \\|\\ i/esc(toggle\\ edit\\ mode)',
|
||||||
|
// Auto close all windows when one is closed
|
||||||
|
'-c',
|
||||||
|
'autocmd WinClosed * wqa',
|
||||||
|
oldPath,
|
||||||
|
newPath,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a diff tool to compare two files.
|
||||||
|
* Terminal-based editors by default blocks parent process until the editor exits.
|
||||||
|
* GUI-based editors requires args such as "--wait" to block parent process.
|
||||||
|
*/
|
||||||
|
export async function openDiff(
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
editor: EditorType,
|
||||||
|
): Promise<void> {
|
||||||
|
const diffCommand = getDiffCommand(oldPath, newPath, editor);
|
||||||
|
if (!diffCommand) {
|
||||||
|
console.error('No diff tool available. Install vim or vscode.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editor === 'vscode') {
|
||||||
|
// Use spawn to avoid blocking the entire process, resolve this function when editor is closed.
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const process = spawn(diffCommand.command, diffCommand.args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`VS Code exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use execSync for terminal-based editors like vim
|
||||||
|
const command = `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`;
|
||||||
|
execSync(command, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue