Skip to content

Commit 8969a23

Browse files
authored
feat(ide): Check for IDE diffing capabilities before opening diffs (google-gemini#8266)
1 parent 59182a9 commit 8969a23

File tree

9 files changed

+183
-50
lines changed

9 files changed

+183
-50
lines changed

packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import type React from 'react';
8+
import { useEffect, useState } from 'react';
89
import { Box, Text } from 'ink';
910
import { DiffRenderer } from './DiffRenderer.js';
1011
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -41,12 +42,31 @@ export const ToolConfirmationMessage: React.FC<
4142
const { onConfirm } = confirmationDetails;
4243
const childWidth = terminalWidth - 2; // 2 for padding
4344

45+
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
46+
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
47+
48+
useEffect(() => {
49+
let isMounted = true;
50+
if (config.getIdeMode()) {
51+
const getIdeClient = async () => {
52+
const client = await IdeClient.getInstance();
53+
if (isMounted) {
54+
setIdeClient(client);
55+
setIsDiffingEnabled(client?.isDiffingEnabled() ?? false);
56+
}
57+
};
58+
getIdeClient();
59+
}
60+
return () => {
61+
isMounted = false;
62+
};
63+
}, [config]);
64+
4465
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
4566
if (confirmationDetails.type === 'edit') {
46-
if (config.getIdeMode()) {
67+
if (config.getIdeMode() && isDiffingEnabled) {
4768
const cliOutcome =
4869
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
49-
const ideClient = await IdeClient.getInstance();
5070
await ideClient?.resolveDiffFromCli(
5171
confirmationDetails.filePath,
5272
cliOutcome,
@@ -137,22 +157,18 @@ export const ToolConfirmationMessage: React.FC<
137157
value: ToolConfirmationOutcome.ProceedAlways,
138158
});
139159
}
140-
if (config.getIdeMode()) {
141-
options.push({
142-
label: 'No (esc)',
143-
value: ToolConfirmationOutcome.Cancel,
144-
});
145-
} else {
160+
if (!config.getIdeMode() || !isDiffingEnabled) {
146161
options.push({
147162
label: 'Modify with external editor',
148163
value: ToolConfirmationOutcome.ModifyWithEditor,
149164
});
150-
options.push({
151-
label: 'No, suggest changes (esc)',
152-
value: ToolConfirmationOutcome.Cancel,
153-
});
154165
}
155166

167+
options.push({
168+
label: 'No, suggest changes (esc)',
169+
value: ToolConfirmationOutcome.Cancel,
170+
});
171+
156172
bodyContent = (
157173
<DiffRenderer
158174
diffContent={confirmationDetails.fileDiff}

packages/core/src/ide/ide-client.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ describe('IdeClient', () => {
8383
close: vi.fn(),
8484
setNotificationHandler: vi.fn(),
8585
callTool: vi.fn(),
86+
request: vi.fn(),
8687
} as unknown as Mocked<Client>;
8788
mockHttpTransport = {
8889
close: vi.fn(),
@@ -534,4 +535,93 @@ describe('IdeClient', () => {
534535
);
535536
});
536537
});
538+
539+
describe('isDiffingEnabled', () => {
540+
it('should return false if not connected', async () => {
541+
const ideClient = await IdeClient.getInstance();
542+
expect(ideClient.isDiffingEnabled()).toBe(false);
543+
});
544+
545+
it('should return false if tool discovery fails', async () => {
546+
const config = { port: '8080' };
547+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
548+
(
549+
vi.mocked(fs.promises.readdir) as Mock<
550+
(path: fs.PathLike) => Promise<string[]>
551+
>
552+
).mockResolvedValue([]);
553+
mockClient.request.mockRejectedValue(new Error('Method not found'));
554+
555+
const ideClient = await IdeClient.getInstance();
556+
await ideClient.connect();
557+
558+
expect(ideClient.getConnectionStatus().status).toBe(
559+
IDEConnectionStatus.Connected,
560+
);
561+
expect(ideClient.isDiffingEnabled()).toBe(false);
562+
});
563+
564+
it('should return false if diffing tools are not available', async () => {
565+
const config = { port: '8080' };
566+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
567+
(
568+
vi.mocked(fs.promises.readdir) as Mock<
569+
(path: fs.PathLike) => Promise<string[]>
570+
>
571+
).mockResolvedValue([]);
572+
mockClient.request.mockResolvedValue({
573+
tools: [{ name: 'someOtherTool' }],
574+
});
575+
576+
const ideClient = await IdeClient.getInstance();
577+
await ideClient.connect();
578+
579+
expect(ideClient.getConnectionStatus().status).toBe(
580+
IDEConnectionStatus.Connected,
581+
);
582+
expect(ideClient.isDiffingEnabled()).toBe(false);
583+
});
584+
585+
it('should return false if only openDiff tool is available', async () => {
586+
const config = { port: '8080' };
587+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
588+
(
589+
vi.mocked(fs.promises.readdir) as Mock<
590+
(path: fs.PathLike) => Promise<string[]>
591+
>
592+
).mockResolvedValue([]);
593+
mockClient.request.mockResolvedValue({
594+
tools: [{ name: 'openDiff' }],
595+
});
596+
597+
const ideClient = await IdeClient.getInstance();
598+
await ideClient.connect();
599+
600+
expect(ideClient.getConnectionStatus().status).toBe(
601+
IDEConnectionStatus.Connected,
602+
);
603+
expect(ideClient.isDiffingEnabled()).toBe(false);
604+
});
605+
606+
it('should return true if connected and diffing tools are available', async () => {
607+
const config = { port: '8080' };
608+
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
609+
(
610+
vi.mocked(fs.promises.readdir) as Mock<
611+
(path: fs.PathLike) => Promise<string[]>
612+
>
613+
).mockResolvedValue([]);
614+
mockClient.request.mockResolvedValue({
615+
tools: [{ name: 'openDiff' }, { name: 'closeDiff' }],
616+
});
617+
618+
const ideClient = await IdeClient.getInstance();
619+
await ideClient.connect();
620+
621+
expect(ideClient.getConnectionStatus().status).toBe(
622+
IDEConnectionStatus.Connected,
623+
);
624+
expect(ideClient.isDiffingEnabled()).toBe(true);
625+
});
626+
});
537627
});

packages/core/src/ide/ide-client.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
2222
import * as os from 'node:os';
2323
import * as path from 'node:path';
2424
import { EnvHttpProxyAgent } from 'undici';
25+
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
2526

2627
const logger = {
2728
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -78,6 +79,7 @@ export class IdeClient {
7879
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
7980
private statusListeners = new Set<(state: IDEConnectionState) => void>();
8081
private trustChangeListeners = new Set<(isTrusted: boolean) => void>();
82+
private availableTools: string[] = [];
8183
/**
8284
* A mutex to ensure that only one diff view is open in the IDE at a time.
8385
* This prevents race conditions and UI issues in IDEs like VSCode that
@@ -334,6 +336,53 @@ export class IdeClient {
334336
return this.currentIdeDisplayName;
335337
}
336338

339+
isDiffingEnabled(): boolean {
340+
return (
341+
!!this.client &&
342+
this.state.status === IDEConnectionStatus.Connected &&
343+
this.availableTools.includes('openDiff') &&
344+
this.availableTools.includes('closeDiff')
345+
);
346+
}
347+
348+
private async discoverTools(): Promise<void> {
349+
if (!this.client) {
350+
return;
351+
}
352+
try {
353+
logger.debug('Discovering tools from IDE...');
354+
const response = await this.client.request(
355+
{ method: 'tools/list', params: {} },
356+
ListToolsResultSchema,
357+
);
358+
359+
// Map the array of tool objects to an array of tool names (strings)
360+
this.availableTools = response.tools.map((tool) => tool.name);
361+
362+
if (this.availableTools.length > 0) {
363+
logger.debug(
364+
`Discovered ${this.availableTools.length} tools from IDE: ${this.availableTools.join(', ')}`,
365+
);
366+
} else {
367+
logger.debug(
368+
'IDE supports tool discovery, but no tools are available.',
369+
);
370+
}
371+
} catch (error) {
372+
// It's okay if this fails, the IDE might not support it.
373+
// Don't log an error if the method is not found, which is a common case.
374+
if (
375+
error instanceof Error &&
376+
!error.message?.includes('Method not found')
377+
) {
378+
logger.error(`Error discovering tools from IDE: ${error.message}`);
379+
} else {
380+
logger.debug('IDE does not support tool discovery.');
381+
}
382+
this.availableTools = [];
383+
}
384+
}
385+
337386
private setState(
338387
status: IDEConnectionStatus,
339388
details?: string,
@@ -631,6 +680,7 @@ export class IdeClient {
631680
);
632681
await this.client.connect(transport);
633682
this.registerClientHandlers();
683+
await this.discoverTools();
634684
this.setState(IDEConnectionStatus.Connected);
635685
return true;
636686
} catch (_error) {
@@ -664,6 +714,7 @@ export class IdeClient {
664714
});
665715
await this.client.connect(transport);
666716
this.registerClientHandlers();
717+
await this.discoverTools();
667718
this.setState(IDEConnectionStatus.Connected);
668719
return true;
669720
} catch (_error) {

packages/core/src/tools/edit.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,12 @@ const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn());
1010
const mockGenerateJson = vi.hoisted(() => vi.fn());
1111
const mockOpenDiff = vi.hoisted(() => vi.fn());
1212

13-
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
13+
import { IdeClient } from '../ide/ide-client.js';
1414

1515
vi.mock('../ide/ide-client.js', () => ({
1616
IdeClient: {
1717
getInstance: vi.fn(),
1818
},
19-
IDEConnectionStatus: {
20-
Connected: 'connected',
21-
Disconnected: 'disconnected',
22-
},
2319
}));
2420

2521
vi.mock('../utils/editCorrector.js', () => ({
@@ -896,9 +892,7 @@ describe('EditTool', () => {
896892
filePath = path.join(rootDir, testFile);
897893
ideClient = {
898894
openDiff: vi.fn(),
899-
getConnectionStatus: vi.fn().mockReturnValue({
900-
status: IDEConnectionStatus.Connected,
901-
}),
895+
isDiffingEnabled: vi.fn().mockReturnValue(true),
902896
};
903897
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
904898
(mockConfig as any).getIdeMode = () => true;

packages/core/src/tools/edit.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import type {
3232
ModifiableDeclarativeTool,
3333
ModifyContext,
3434
} from './modifiable-tool.js';
35-
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
35+
import { IdeClient } from '../ide/ide-client.js';
3636

3737
export function applyReplacement(
3838
currentContent: string | null,
@@ -268,8 +268,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
268268
);
269269
const ideClient = await IdeClient.getInstance();
270270
const ideConfirmation =
271-
this.config.getIdeMode() &&
272-
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
271+
this.config.getIdeMode() && ideClient.isDiffingEnabled()
273272
? ideClient.openDiff(this.params.file_path, editData.newContent)
274273
: undefined;
275274

packages/core/src/tools/smart-edit.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,12 @@ const mockFixLLMEditWithInstruction = vi.hoisted(() => vi.fn());
1010
const mockGenerateJson = vi.hoisted(() => vi.fn());
1111
const mockOpenDiff = vi.hoisted(() => vi.fn());
1212

13-
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
13+
import { IdeClient } from '../ide/ide-client.js';
1414

1515
vi.mock('../ide/ide-client.js', () => ({
1616
IdeClient: {
1717
getInstance: vi.fn(),
1818
},
19-
IDEConnectionStatus: {
20-
Connected: 'connected',
21-
Disconnected: 'disconnected',
22-
},
2319
}));
2420

2521
vi.mock('../utils/llm-edit-fixer.js', () => ({
@@ -461,9 +457,7 @@ describe('SmartEditTool', () => {
461457
filePath = path.join(rootDir, testFile);
462458
ideClient = {
463459
openDiff: vi.fn(),
464-
getConnectionStatus: vi.fn().mockReturnValue({
465-
status: IDEConnectionStatus.Connected,
466-
}),
460+
isDiffingEnabled: vi.fn().mockReturnValue(true),
467461
};
468462
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
469463
(mockConfig as any).getIdeMode = () => true;

packages/core/src/tools/smart-edit.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
type ModifiableDeclarativeTool,
2929
type ModifyContext,
3030
} from './modifiable-tool.js';
31-
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
31+
import { IdeClient } from '../ide/ide-client.js';
3232
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
3333

3434
export function applyReplacement(
@@ -528,8 +528,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
528528
);
529529
const ideClient = await IdeClient.getInstance();
530530
const ideConfirmation =
531-
this.config.getIdeMode() &&
532-
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
531+
this.config.getIdeMode() && ideClient.isDiffingEnabled()
533532
? ideClient.openDiff(this.params.file_path, editData.newContent)
534533
: undefined;
535534

0 commit comments

Comments
 (0)