diff --git a/README.md b/README.md
index 22e258e289..561071e63e 100644
--- a/README.md
+++ b/README.md
@@ -389,5 +389,5 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
---
- Built with ❤️ by Google and the open source community
+ Built with ❤️ by Google, shreya, and the open source community
diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts
index e99efd90f6..5f525c3647 100644
--- a/packages/cli/src/core/initializer.ts
+++ b/packages/cli/src/core/initializer.ts
@@ -13,6 +13,8 @@ import {
StartSessionEvent,
logCliConfiguration,
startupProfiler,
+ type ConnectionConfig,
+ type IdeInfo,
} from '@google/gemini-cli-core';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
@@ -23,6 +25,9 @@ export interface InitializationResult {
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
+ availableIdeConnections?: Array<
+ ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
+ >;
}
/**
@@ -52,10 +57,30 @@ export async function initializeApp(
new StartSessionEvent(config, config.getToolRegistry()),
);
+ let availableIdeConnections:
+ | Array
+ | undefined;
+
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();
- await ideClient.connect();
- logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
+ // Try to auto-connect if possible (legacy behavior or single match)
+ // We attempt to connect. If it requires selection, we get a list back?
+ // No, I need to implement the logic here.
+ const connections = await ideClient.discoverAvailableConnections();
+
+ // Heuristic: If we have a PID match, prioritize it.
+ // Ideally IdeClient.getInstance() already did some detection but didn't connect.
+ // Actually IdeClient.connect() (without args) tries to find "the one" config.
+ // If I want to support multiple, I should check here.
+
+ if (connections.length > 1) {
+ // Multiple connections found, let the UI handle selection
+ availableIdeConnections = connections;
+ } else {
+ // 0 or 1 connection, or let connect() handle the "best guess" fallback
+ await ideClient.connect();
+ logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
+ }
}
return {
@@ -63,5 +88,6 @@ export async function initializeApp(
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
+ availableIdeConnections,
};
}
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index f08d947eca..6f1e061ca4 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -65,7 +65,12 @@ import {
generateSummary,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
+ IdeConnectionEvent,
+ IdeConnectionType,
+ logIdeConnection,
+ type ConnectionConfig,
} from '@google/gemini-cli-core';
+import { IdeConnectionSelector } from './components/IdeConnectionSelector.js';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
@@ -750,7 +755,40 @@ Logging in with Google... Restarting Gemini CLI to continue.
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
+ promptIdeConnection: async () => {
+ const ideClient = await IdeClient.getInstance();
+ await ideClient.disconnect();
+ const connections = await ideClient.discoverAvailableConnections();
+
+ if (connections.length > 1) {
+ setAvailableIdeConnections(connections);
+ } else if (connections.length === 1) {
+ await ideClient.connect();
+ logIdeConnection(
+ config,
+ new IdeConnectionEvent(IdeConnectionType.START),
+ );
+ // Show success message
+ historyManager.addItem(
+ {
+ type: MessageType.INFO,
+ text: `Connected to IDE: ${connections[0].ideInfo?.displayName || 'Unknown IDE'}`,
+ },
+ Date.now(),
+ );
+ } else {
+ // Show error message
+ historyManager.addItem(
+ {
+ type: MessageType.ERROR,
+ text: 'No IDE connections found.',
+ },
+ Date.now(),
+ );
+ }
+ },
}),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
[
setAuthState,
openThemeDialog,
@@ -1965,6 +2003,43 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
+ // ... (existing imports)
+
+ // Inside AppContainer function:
+
+ // ... (existing state)
+ const [availableIdeConnections, setAvailableIdeConnections] = useState<
+ | Array
+ | undefined
+ >(initializationResult.availableIdeConnections);
+
+ // ... (existing effects/hooks)
+
+ if (availableIdeConnections && availableIdeConnections.length > 0) {
+ return (
+ {
+ const ideClient = await IdeClient.getInstance();
+ await ideClient.connect({ connectionConfig: conn });
+ logIdeConnection(
+ config,
+ new IdeConnectionEvent(IdeConnectionType.START),
+ );
+ setAvailableIdeConnections(undefined);
+ }}
+ onCancel={() => {
+ setAvailableIdeConnections(undefined);
+ }}
+ />
+ );
+ }
+
if (authState === AuthState.AwaitingGoogleLoginRestart) {
return (
=> {
const ideClient = await IdeClient.getInstance();
const currentIDE = ideClient.getCurrentIde();
- if (!currentIDE) {
- return {
- name: 'ide',
- description: 'Manage IDE integration',
- kind: CommandKind.BUILT_IN,
- autoExecute: false,
- action: (): SlashCommandActionReturn =>
- ({
- type: 'message',
- messageType: 'error',
- content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
- }) as const,
- };
- }
const ideSlashCommand: SlashCommand = {
name: 'ide',
@@ -181,6 +167,16 @@ export const ideCommand = async (): Promise => {
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
+ if (!currentIDE) {
+ context.ui.addItem(
+ {
+ type: 'error',
+ text: 'No IDE detected. Cannot run installer.',
+ },
+ Date.now(),
+ );
+ return;
+ }
const installer = getIdeInstaller(currentIDE);
if (!installer) {
context.ui.addItem(
@@ -297,15 +293,30 @@ export const ideCommand = async (): Promise => {
},
};
+ const switchCommand: SlashCommand = {
+ name: 'switch',
+ description: 'Switch to a different IDE connection',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: async (context: CommandContext) => {
+ await context.ui.promptIdeConnection();
+ },
+ };
+
const { status } = ideClient.getConnectionStatus();
const isConnected = status === IDEConnectionStatus.Connected;
if (isConnected) {
- ideSlashCommand.subCommands = [statusCommand, disableCommand];
+ ideSlashCommand.subCommands = [
+ statusCommand,
+ switchCommand,
+ disableCommand,
+ ];
} else {
ideSlashCommand.subCommands = [
enableCommand,
statusCommand,
+ switchCommand,
installCommand,
];
}
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index 9f5ca8eb41..e11fd33310 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -83,7 +83,9 @@ export interface CommandContext {
extensionsUpdateState: Map;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
+
removeComponent: () => void;
+ promptIdeConnection: () => Promise;
};
// Session-specific data
session: {
diff --git a/packages/cli/src/ui/components/IdeConnectionSelector.tsx b/packages/cli/src/ui/components/IdeConnectionSelector.tsx
new file mode 100644
index 0000000000..0ecc0e2ab6
--- /dev/null
+++ b/packages/cli/src/ui/components/IdeConnectionSelector.tsx
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { type ConnectionConfig, type IdeInfo } from '@google/gemini-cli-core';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
+
+interface IdeConnectionSelectorProps {
+ connections: Array<
+ ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
+ >;
+ onSelect: (
+ connection: ConnectionConfig & {
+ workspacePath?: string;
+ ideInfo?: IdeInfo;
+ },
+ ) => void;
+ onCancel: () => void;
+}
+
+export const IdeConnectionSelector = ({
+ connections,
+ onSelect,
+ onCancel,
+}: IdeConnectionSelectorProps) => {
+ const items: Array> = connections.map(
+ (conn, index) => {
+ const label = `${conn.ideInfo?.displayName || 'Unknown IDE'} (${conn.workspacePath || 'No workspace'})`;
+ return {
+ label,
+ value: index,
+ key: index.toString(),
+ };
+ },
+ );
+
+ // Add an option to skip/cancel
+ items.push({
+ label: 'Do not connect to an IDE',
+ value: -1,
+ key: 'cancel',
+ });
+
+ return (
+
+
+ Multiple IDE connections found. Please select one:
+
+
+ {
+ if (value === -1) {
+ onCancel();
+ } else {
+ onSelect(connections[value]);
+ }
+ }}
+ />
+
+
+ );
+};
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
index 4a6a6a1c9b..54060b9a3e 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
@@ -213,7 +213,9 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
+
setText: vi.fn(),
+ promptIdeConnection: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index efd0762320..9f05f925b2 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -83,6 +83,7 @@ interface SlashCommandProcessorActions {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
setText: (text: string) => void;
+ promptIdeConnection: () => Promise;
}
/**
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
removeComponent: () => setCustomDialog(null),
+ promptIdeConnection: actions.promptIdeConnection,
},
session: {
stats: session.stats,
diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
index 6632583223..1e72ab39a0 100644
--- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
+++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
@@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
removeComponent: () => {},
+ promptIdeConnection: async () => {},
};
}
diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts
index 1f717cec56..cea494f82a 100644
--- a/packages/core/src/ide/detect-ide.test.ts
+++ b/packages/core/src/ide/detect-ide.test.ts
@@ -13,9 +13,6 @@ beforeEach(() => {
});
describe('detectIde', () => {
- const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
- const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' };
-
beforeEach(() => {
// Ensure these env vars don't leak from the host environment
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
@@ -40,147 +37,93 @@ describe('detectIde', () => {
it('should return undefined if TERM_PROGRAM is not vscode', () => {
vi.stubEnv('TERM_PROGRAM', '');
- expect(detectIde(ideProcessInfo)).toBeUndefined();
+ expect(detectIde()).toBeUndefined();
});
it('should detect Devin', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('__COG_BASHRC_SOURCED', '1');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.devin);
});
it('should detect Replit', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('REPLIT_USER', 'testuser');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.replit);
});
it('should detect Cursor', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CURSOR_TRACE_ID', 'some-id');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.cursor);
});
it('should detect Codespaces', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CODESPACES', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.codespaces);
});
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell);
});
it('should detect Cloud Shell via CLOUD_SHELL', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CLOUD_SHELL', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell);
});
it('should detect Trae', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('TERM_PRODUCT', 'Trae');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.trae);
});
it('should detect Firebase Studio via MONOSPACE_ENV', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio);
- });
-
- it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
- vi.stubEnv('TERM_PROGRAM', 'vscode');
- vi.stubEnv('MONOSPACE_ENV', '');
- vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode);
- });
-
- it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => {
- vi.stubEnv('TERM_PROGRAM', 'vscode');
- vi.stubEnv('MONOSPACE_ENV', '');
- vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.firebasestudio);
});
it('should detect AntiGravity', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.antigravity);
});
it('should detect Sublime Text', () => {
vi.stubEnv('TERM_PROGRAM', 'sublime');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.sublimetext);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.sublimetext);
});
it('should prioritize Antigravity over Sublime Text', () => {
vi.stubEnv('TERM_PROGRAM', 'sublime');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.antigravity);
});
it('should detect JetBrains IDE via TERMINAL_EMULATOR', () => {
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
- });
-
- describe('JetBrains IDE detection via command', () => {
- beforeEach(() => {
- vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
- });
-
- it.each([
- [
- 'IntelliJ IDEA',
- '/Applications/IntelliJ IDEA.app',
- IDE_DEFINITIONS.intellijidea,
- ],
- ['WebStorm', '/Applications/WebStorm.app', IDE_DEFINITIONS.webstorm],
- ['PyCharm', '/Applications/PyCharm.app', IDE_DEFINITIONS.pycharm],
- ['GoLand', '/Applications/GoLand.app', IDE_DEFINITIONS.goland],
- [
- 'Android Studio',
- '/Applications/Android Studio.app',
- IDE_DEFINITIONS.androidstudio,
- ],
- ['CLion', '/Applications/CLion.app', IDE_DEFINITIONS.clion],
- ['RustRover', '/Applications/RustRover.app', IDE_DEFINITIONS.rustrover],
- ['DataGrip', '/Applications/DataGrip.app', IDE_DEFINITIONS.datagrip],
- ['PhpStorm', '/Applications/PhpStorm.app', IDE_DEFINITIONS.phpstorm],
- ])('should detect %s via command', (_name, command, expectedIde) => {
- const processInfo = { pid: 123, command };
- expect(detectIde(processInfo)).toBe(expectedIde);
- });
- });
-
- it('should return generic JetBrains when command does not match specific IDE', () => {
- vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
- const genericProcessInfo = {
- pid: 123,
- command: '/Applications/SomeJetBrainsApp.app',
- };
- expect(detectIde(genericProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.jetbrains);
});
it('should prioritize JetBrains detection over VS Code when TERMINAL_EMULATOR is set', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
- expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
+ expect(detectIde()).toBe(IDE_DEFINITIONS.jetbrains);
});
});
describe('detectIde with ideInfoFromFile', () => {
- const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
-
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -205,24 +148,20 @@ describe('detectIde with ideInfoFromFile', () => {
name: 'custom-ide',
displayName: 'Custom IDE',
};
- expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile);
+ expect(detectIde(ideInfoFromFile)).toEqual(ideInfoFromFile);
});
it('should fall back to env detection if name is missing', () => {
const ideInfoFromFile = { displayName: 'Custom IDE' };
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
- IDE_DEFINITIONS.vscode,
- );
+ expect(detectIde(ideInfoFromFile)).toBe(IDE_DEFINITIONS.vscode);
});
it('should fall back to env detection if displayName is missing', () => {
const ideInfoFromFile = { name: 'custom-ide' };
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CURSOR_TRACE_ID', '');
- expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
- IDE_DEFINITIONS.vscode,
- );
+ expect(detectIde(ideInfoFromFile)).toBe(IDE_DEFINITIONS.vscode);
});
});
diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts
index 6c1f0b458b..35bff64e50 100644
--- a/packages/core/src/ide/detect-ide.ts
+++ b/packages/core/src/ide/detect-ide.ts
@@ -77,65 +77,10 @@ export function detectIdeFromEnv(): IdeInfo {
return IDE_DEFINITIONS.vscode;
}
-function verifyVSCode(
- ide: IdeInfo,
- ideProcessInfo: {
- pid: number;
- command: string;
- },
-): IdeInfo {
- if (ide.name !== IDE_DEFINITIONS.vscode.name) {
- return ide;
- }
- if (
- !ideProcessInfo.command ||
- ideProcessInfo.command.toLowerCase().includes('code')
- ) {
- return IDE_DEFINITIONS.vscode;
- }
- return IDE_DEFINITIONS.vscodefork;
-}
-
-function verifyJetBrains(
- ide: IdeInfo,
- ideProcessInfo: {
- pid: number;
- command: string;
- },
-): IdeInfo {
- if (ide.name !== IDE_DEFINITIONS.jetbrains.name || !ideProcessInfo.command) {
- return ide;
- }
-
- const command = ideProcessInfo.command.toLowerCase();
- const jetbrainsProducts: Array<[string, IdeInfo]> = [
- ['idea', IDE_DEFINITIONS.intellijidea],
- ['webstorm', IDE_DEFINITIONS.webstorm],
- ['pycharm', IDE_DEFINITIONS.pycharm],
- ['goland', IDE_DEFINITIONS.goland],
- ['studio', IDE_DEFINITIONS.androidstudio],
- ['clion', IDE_DEFINITIONS.clion],
- ['rustrover', IDE_DEFINITIONS.rustrover],
- ['datagrip', IDE_DEFINITIONS.datagrip],
- ['phpstorm', IDE_DEFINITIONS.phpstorm],
- ];
-
- for (const [product, ideInfo] of jetbrainsProducts) {
- if (command.includes(product)) {
- return ideInfo;
- }
- }
-
- return ide;
-}
-
-export function detectIde(
- ideProcessInfo: {
- pid: number;
- command: string;
- },
- ideInfoFromFile?: { name?: string; displayName?: string },
-): IdeInfo | undefined {
+export function detectIde(ideInfoFromFile?: {
+ name?: string;
+ displayName?: string;
+}): IdeInfo | undefined {
if (ideInfoFromFile?.name && ideInfoFromFile.displayName) {
return {
name: ideInfoFromFile.name,
@@ -152,8 +97,5 @@ export function detectIde(
return undefined;
}
- const ide = detectIdeFromEnv();
- return isJetBrains()
- ? verifyJetBrains(ide, ideProcessInfo)
- : verifyVSCode(ide, ideProcessInfo);
+ return detectIdeFromEnv();
}
diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts
index 775c70afcc..528a9f015c 100644
--- a/packages/core/src/ide/ide-client.test.ts
+++ b/packages/core/src/ide/ide-client.test.ts
@@ -15,7 +15,6 @@ import {
} from 'vitest';
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
import * as fs from 'node:fs';
-import { getIdeProcessInfo } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -43,7 +42,6 @@ vi.mock('node:fs', async (importOriginal) => {
};
});
-vi.mock('./process-utils.js');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
@@ -81,10 +79,6 @@ describe('IdeClient', () => {
// Mock dependencies
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode);
- vi.mocked(getIdeProcessInfo).mockResolvedValue({
- pid: 12345,
- command: 'test-ide',
- });
// Mock MCP client and transports
mockClient = {
@@ -118,10 +112,10 @@ describe('IdeClient', () => {
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
- const config = { port: '8080' };
+ const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -139,10 +133,13 @@ describe('IdeClient', () => {
});
it('should connect using stdio when stdio config is provided in file', async () => {
- const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
+ const config = {
+ stdio: { command: 'test-cmd', args: ['--foo'] },
+ workspacePath: '/test/workspace',
+ };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -163,10 +160,11 @@ describe('IdeClient', () => {
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
+ workspacePath: '/test/workspace',
};
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -214,10 +212,10 @@ describe('IdeClient', () => {
});
it('should prioritize file config over environment variables', async () => {
- const config = { port: '8080' };
+ const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -255,7 +253,7 @@ describe('IdeClient', () => {
const config = { port: '1234', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -509,10 +507,10 @@ describe('IdeClient', () => {
});
it('should return false if tool discovery fails', async () => {
- const config = { port: '8080' };
+ const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -528,10 +526,10 @@ describe('IdeClient', () => {
});
it('should return false if diffing tools are not available', async () => {
- const config = { port: '8080' };
+ const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -549,10 +547,10 @@ describe('IdeClient', () => {
});
it('should return false if only openDiff tool is available', async () => {
- const config = { port: '8080' };
+ const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -570,10 +568,10 @@ describe('IdeClient', () => {
});
it('should return true if connected and diffing tools are available', async () => {
- const config = { port: '8080' };
+ const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -842,10 +840,14 @@ describe('IdeClient', () => {
describe('authentication', () => {
it('should connect with an auth token if provided in the discovery file', async () => {
const authToken = 'test-auth-token';
- const config = { port: '8080', authToken };
+ const config = {
+ port: '8080',
+ authToken,
+ workspacePath: '/test/workspace',
+ };
const configPath = path.join(
ideConfigDir,
- 'gemini-ide-server-12345.json',
+ 'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts
index 928c411395..af1bd8b9c8 100644
--- a/packages/core/src/ide/ide-client.ts
+++ b/packages/core/src/ide/ide-client.ts
@@ -14,7 +14,6 @@ import {
IdeDiffClosedNotificationSchema,
IdeDiffRejectedNotificationSchema,
} from './types.js';
-import { getIdeProcessInfo } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -59,7 +58,7 @@ type StdioConfig = {
args: string[];
};
-type ConnectionConfig = {
+export type ConnectionConfig = {
port?: string;
authToken?: string;
stdio?: StdioConfig;
@@ -77,7 +76,6 @@ export class IdeClient {
'IDE integration is currently disabled. To enable it, run /ide enable.',
};
private currentIde: IdeInfo | undefined;
- private ideProcessInfo: { pid: number; command: string } | undefined;
private connectionConfig:
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined;
@@ -99,12 +97,8 @@ export class IdeClient {
if (!IdeClient.instancePromise) {
IdeClient.instancePromise = (async () => {
const client = new IdeClient();
- client.ideProcessInfo = await getIdeProcessInfo();
client.connectionConfig = await client.getConnectionConfigFromFile();
- client.currentIde = detectIde(
- client.ideProcessInfo,
- client.connectionConfig?.ideInfo,
- );
+ client.currentIde = detectIde(client.connectionConfig?.ideInfo);
return client;
})();
}
@@ -127,7 +121,15 @@ export class IdeClient {
this.trustChangeListeners.delete(listener);
}
- async connect(options: { logToConsole?: boolean } = {}): Promise {
+ async connect(
+ options: {
+ logToConsole?: boolean;
+ connectionConfig?: ConnectionConfig & {
+ workspacePath?: string;
+ ideInfo?: IdeInfo;
+ };
+ } = {},
+ ): Promise {
const logError = options.logToConsole ?? true;
if (!this.currentIde) {
this.setState(
@@ -140,7 +142,12 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting);
- this.connectionConfig = await this.getConnectionConfigFromFile();
+ if (options.connectionConfig) {
+ this.connectionConfig = options.connectionConfig;
+ } else {
+ this.connectionConfig = await this.getConnectionConfigFromFile();
+ }
+
this.authToken =
this.connectionConfig?.authToken ??
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
@@ -565,104 +572,81 @@ export class IdeClient {
return { command, args };
}
- private async getConnectionConfigFromFile(): Promise<
- | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
- | undefined
+ async discoverAvailableConnections(): Promise<
+ Array
> {
- if (!this.ideProcessInfo) {
- return undefined;
- }
-
- // For backwards compatibility
- try {
- const portFile = path.join(
- os.tmpdir(),
- 'gemini',
- 'ide',
- `gemini-ide-server-${this.ideProcessInfo.pid}.json`,
- );
- const portFileContents = await fs.promises.readFile(portFile, 'utf8');
- return JSON.parse(portFileContents);
- } catch (_) {
- // For newer extension versions, the file name matches the pattern
- // /^gemini-ide-server-${pid}-\d+\.json$/. If multiple IDE
- // windows are open, multiple files matching the pattern are expected to
- // exist.
- }
-
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
- return undefined;
+ return [];
}
if (!portFiles) {
- return undefined;
+ return [];
}
- const fileRegex = new RegExp(
- `^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
- );
- const matchingFiles = portFiles
- .filter((file) => fileRegex.test(file))
- .sort();
- if (matchingFiles.length === 0) {
- return undefined;
- }
+ const fileRegex = /^gemini-ide-server-(\d+)-(\d+)\.json$/;
+ const allFiles = portFiles.filter((file) => fileRegex.test(file)).sort();
let fileContents: string[];
try {
fileContents = await Promise.all(
- matchingFiles.map((file) =>
+ allFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
- return undefined;
- }
- const parsedContents = fileContents.map((content) => {
- try {
- return JSON.parse(content);
- } catch (e) {
- logger.debug('Failed to parse JSON from config file: ', e);
- return undefined;
- }
- });
-
- const validWorkspaces = parsedContents.filter((content) => {
- if (!content) {
- return false;
- }
- const { isValid } = IdeClient.validateWorkspacePath(
- content.workspacePath,
- process.cwd(),
- );
- return isValid;
- });
-
- if (validWorkspaces.length === 0) {
- return undefined;
+ return [];
}
- if (validWorkspaces.length === 1) {
- return validWorkspaces[0];
- }
+ const configs = fileContents
+ .map((content) => {
+ try {
+ return JSON.parse(content);
+ } catch (e) {
+ logger.debug('Failed to parse JSON from config file: ', e);
+ return undefined;
+ }
+ })
+ .filter((config) => {
+ if (!config) {
+ return false;
+ }
+ // Basic validation
+ const { isValid } = IdeClient.validateWorkspacePath(
+ config.workspacePath,
+ process.cwd(),
+ );
+ return isValid;
+ });
- const portFromEnv = this.getPortFromEnv();
- if (portFromEnv) {
- const matchingPort = validWorkspaces.find(
- (content) => String(content.port) === portFromEnv,
- );
- if (matchingPort) {
- return matchingPort;
+ return configs;
+ }
+
+ private async getConnectionConfigFromFile(): Promise<
+ | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
+ | undefined
+ > {
+ const available = await this.discoverAvailableConnections();
+ const portEnv = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
+ if (available.length > 1 && portEnv) {
+ const match = available.find((c) => c.port?.toString() === portEnv);
+ if (match) {
+ return match;
}
}
- return validWorkspaces[0];
+ if (available.length > 0) {
+ // Return the first available connection if specific port match isn't found
+ // This maintains backward compatibility with tests expecting a return value.
+ return available[0];
+ }
+
+ return undefined;
}
private async createProxyAwareFetch(ideServerHost: string) {
diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts
deleted file mode 100644
index 9176dad49e..0000000000
--- a/packages/core/src/ide/process-utils.test.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
- describe,
- it,
- expect,
- vi,
- afterEach,
- beforeEach,
- type Mock,
-} from 'vitest';
-import { getIdeProcessInfo } from './process-utils.js';
-import os from 'node:os';
-
-const mockedExec = vi.hoisted(() => vi.fn());
-vi.mock('node:util', () => ({
- promisify: vi.fn().mockReturnValue(mockedExec),
-}));
-vi.mock('node:os', () => ({
- default: {
- platform: vi.fn(),
- },
-}));
-
-describe('getIdeProcessInfo', () => {
- beforeEach(() => {
- Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
- mockedExec.mockReset();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- describe('on Unix', () => {
- it('should traverse up to find the shell and return grandparent process info', async () => {
- (os.platform as Mock).mockReturnValue('linux');
- // process (1000) -> shell (800) -> IDE (700)
- mockedExec
- .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
- .mockResolvedValueOnce({ stdout: '700 /usr/lib/vscode/code' }) // pid 800 -> ppid 700 (IDE)
- .mockResolvedValueOnce({ stdout: '700 /usr/lib/vscode/code' }); // get command for pid 700
-
- const result = await getIdeProcessInfo();
-
- expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
- });
-
- it('should return parent process info if grandparent lookup fails', async () => {
- (os.platform as Mock).mockReturnValue('linux');
- mockedExec
- .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
- .mockRejectedValueOnce(new Error('ps failed')) // lookup for ppid of 800 fails
- .mockResolvedValueOnce({ stdout: '800 /bin/bash' }); // get command for pid 800
-
- const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 800, command: '/bin/bash' });
- });
- });
-
- describe('on Windows', () => {
- it('should traverse up and find the great-grandchild of the root process', async () => {
- (os.platform as Mock).mockReturnValue('win32');
- // process (1000) -> powershell (900) -> code (800) -> wininit (700) -> root (0)
- // Ancestors: [1000, 900, 800, 700]
- // Target (great-grandchild of root): 900
- const processes = [
- {
- ProcessId: 1000,
- ParentProcessId: 900,
- Name: 'node.exe',
- CommandLine: 'node.exe',
- },
- {
- ProcessId: 900,
- ParentProcessId: 800,
- Name: 'powershell.exe',
- CommandLine: 'powershell.exe',
- },
- {
- ProcessId: 800,
- ParentProcessId: 700,
- Name: 'code.exe',
- CommandLine: 'code.exe',
- },
- {
- ProcessId: 700,
- ParentProcessId: 0,
- Name: 'wininit.exe',
- CommandLine: 'wininit.exe',
- },
- ];
- mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
-
- const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
- expect(mockedExec).toHaveBeenCalledWith(
- expect.stringContaining('Get-CimInstance Win32_Process'),
- expect.anything(),
- );
- });
-
- it('should handle short process chains', async () => {
- (os.platform as Mock).mockReturnValue('win32');
- // process (1000) -> root (0)
- const processes = [
- {
- ProcessId: 1000,
- ParentProcessId: 0,
- Name: 'node.exe',
- CommandLine: 'node.exe',
- },
- ];
- mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
-
- const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 1000, command: 'node.exe' });
- });
-
- it('should handle PowerShell failure gracefully', async () => {
- (os.platform as Mock).mockReturnValue('win32');
- mockedExec.mockRejectedValueOnce(new Error('PowerShell failed'));
- // Fallback to getProcessInfo for current PID
- mockedExec.mockResolvedValueOnce({ stdout: '' }); // ps command fails on windows
-
- const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 1000, command: '' });
- });
-
- it('should handle malformed JSON output gracefully', async () => {
- (os.platform as Mock).mockReturnValue('win32');
- mockedExec.mockResolvedValueOnce({ stdout: '{"invalid":json}' });
- // Fallback to getProcessInfo for current PID
- mockedExec.mockResolvedValueOnce({ stdout: '' });
-
- const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 1000, command: '' });
- });
-
- it('should handle single process output from ConvertTo-Json', async () => {
- (os.platform as Mock).mockReturnValue('win32');
- const process = {
- ProcessId: 1000,
- ParentProcessId: 0,
- Name: 'node.exe',
- CommandLine: 'node.exe',
- };
- mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(process) });
-
- const result = await getIdeProcessInfo();
- expect(result).toEqual({ pid: 1000, command: 'node.exe' });
- });
-
- it('should handle missing process in map during traversal', async () => {
- (os.platform as Mock).mockReturnValue('win32');
- // process (1000) -> parent (900) -> missing (800)
- const processes = [
- {
- ProcessId: 1000,
- ParentProcessId: 900,
- Name: 'node.exe',
- CommandLine: 'node.exe',
- },
- {
- ProcessId: 900,
- ParentProcessId: 800,
- Name: 'parent.exe',
- CommandLine: 'parent.exe',
- },
- ];
- mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
-
- const result = await getIdeProcessInfo();
- // Ancestors: [1000, 900]. Length < 3, returns last (900)
- expect(result).toEqual({ pid: 900, command: 'parent.exe' });
- });
- });
-});
diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts
deleted file mode 100644
index 5c1ca570a6..0000000000
--- a/packages/core/src/ide/process-utils.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { exec } from 'node:child_process';
-import { promisify } from 'node:util';
-import os from 'node:os';
-import path from 'node:path';
-
-const execAsync = promisify(exec);
-
-const MAX_TRAVERSAL_DEPTH = 32;
-
-interface ProcessInfo {
- pid: number;
- parentPid: number;
- name: string;
- command: string;
-}
-
-interface RawProcessInfo {
- ProcessId?: number;
- ParentProcessId?: number;
- Name?: string;
- CommandLine?: string;
-}
-
-/**
- * Fetches the entire process table on Windows.
- */
-async function getProcessTableWindows(): Promise