From b55bf440f9de296bc91dc98518fa85e9923713da Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Tue, 27 Jan 2026 17:15:27 -0500 Subject: [PATCH] init --- README.md | 2 +- packages/cli/src/core/initializer.ts | 30 ++- packages/cli/src/ui/AppContainer.tsx | 75 ++++++ packages/cli/src/ui/commands/ideCommand.ts | 41 ++-- packages/cli/src/ui/commands/types.ts | 2 + .../ui/components/IdeConnectionSelector.tsx | 74 ++++++ .../ui/hooks/slashCommandProcessor.test.tsx | 2 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 + .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + packages/core/src/ide/detect-ide.test.ts | 95 ++------ packages/core/src/ide/detect-ide.ts | 68 +----- packages/core/src/ide/ide-client.test.ts | 50 ++-- packages/core/src/ide/ide-client.ts | 146 +++++------ packages/core/src/ide/process-utils.test.ts | 182 -------------- packages/core/src/ide/process-utils.ts | 226 ------------------ 15 files changed, 324 insertions(+), 672 deletions(-) create mode 100644 packages/cli/src/ui/components/IdeConnectionSelector.tsx delete mode 100644 packages/core/src/ide/process-utils.test.ts delete mode 100644 packages/core/src/ide/process-utils.ts 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> { - const processMap = new Map(); - try { - // Fetch ProcessId, ParentProcessId, Name, and CommandLine for all processes. - const powershellCommand = - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'; - // Increase maxBuffer to handle large process lists (default is 1MB) - const { stdout } = await execAsync(`powershell "${powershellCommand}"`, { - maxBuffer: 10 * 1024 * 1024, - }); - - if (!stdout.trim()) { - return processMap; - } - - let processes: RawProcessInfo | RawProcessInfo[]; - try { - processes = JSON.parse(stdout); - } catch (_e) { - return processMap; - } - - if (!Array.isArray(processes)) { - processes = [processes]; - } - - for (const p of processes) { - if (p && typeof p.ProcessId === 'number') { - processMap.set(p.ProcessId, { - pid: p.ProcessId, - parentPid: p.ParentProcessId || 0, - name: p.Name || '', - command: p.CommandLine || '', - }); - } - } - } catch (_e) { - // Fallback or error handling if PowerShell fails - } - return processMap; -} - -/** - * Fetches the parent process ID, name, and command for a given process ID on Unix. - * - * @param pid The process ID to inspect. - * @returns A promise that resolves to the parent's PID, name, and command. - */ -async function getProcessInfo(pid: number): Promise<{ - parentPid: number; - name: string; - command: string; -}> { - try { - const command = `ps -o ppid=,command= -p ${pid}`; - const { stdout } = await execAsync(command); - const trimmedStdout = stdout.trim(); - if (!trimmedStdout) { - return { parentPid: 0, name: '', command: '' }; - } - const parts = trimmedStdout.split(/\s+/); - const ppidString = parts[0]; - const parentPid = parseInt(ppidString, 10); - const fullCommand = trimmedStdout.substring(ppidString.length).trim(); - const processName = path.basename(fullCommand.split(' ')[0]); - - return { - parentPid: isNaN(parentPid) ? 1 : parentPid, - name: processName, - command: fullCommand, - }; - } catch (_e) { - return { parentPid: 0, name: '', command: '' }; - } -} - -/** - * Finds the IDE process info on Unix-like systems. - * - * The strategy is to find the shell process that spawned the CLI, and then - * find that shell's parent process (the IDE). To get the true IDE process, - * we traverse one level higher to get the grandparent. - * - * @returns A promise that resolves to the PID and command of the IDE process. - */ -async function getIdeProcessInfoForUnix(): Promise<{ - pid: number; - command: string; -}> { - const shells = ['zsh', 'bash', 'sh', 'tcsh', 'csh', 'ksh', 'fish', 'dash']; - let currentPid = process.pid; - - for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { - try { - const { parentPid, name } = await getProcessInfo(currentPid); - - const isShell = shells.some((shell) => name === shell); - if (isShell) { - // The direct parent of the shell is often a utility process (e.g. VS - // Code's `ptyhost` process). To get the true IDE process, we need to - // traverse one level higher to get the grandparent. - let idePid = parentPid; - try { - const { parentPid: grandParentPid } = await getProcessInfo(parentPid); - if (grandParentPid > 1) { - idePid = grandParentPid; - } - } catch { - // Ignore if getting grandparent fails, we'll just use the parent pid. - } - const { command } = await getProcessInfo(idePid); - return { pid: idePid, command }; - } - - if (parentPid <= 1) { - break; // Reached the root - } - currentPid = parentPid; - } catch { - // Process in chain died - break; - } - } - - const { command } = await getProcessInfo(currentPid); - return { pid: currentPid, command }; -} - -/** - * Finds the IDE process info on Windows using a snapshot approach. - */ -async function getIdeProcessInfoForWindows(): Promise<{ - pid: number; - command: string; -}> { - // Fetch the entire process table in one go. - const processMap = await getProcessTableWindows(); - const myPid = process.pid; - const myProc = processMap.get(myPid); - - if (!myProc) { - // Fallback: try to get info for current process directly if snapshot fails - const { command } = await getProcessInfo(myPid); - return { pid: myPid, command }; - } - - // Perform tree traversal in memory. - // Strategy: Find the great-grandchild of the root process (pid 0 or non-existent parent). - const ancestors: ProcessInfo[] = []; - let curr: ProcessInfo | undefined = myProc; - - for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) { - ancestors.push(curr); - if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) { - break; // Reached root - } - curr = processMap.get(curr.parentPid); - } - - if (ancestors.length >= 3) { - const target = ancestors[ancestors.length - 3]; - return { pid: target.pid, command: target.command }; - } else if (ancestors.length > 0) { - const target = ancestors[ancestors.length - 1]; - return { pid: target.pid, command: target.command }; - } - - return { pid: myPid, command: myProc.command }; -} - -/** - * Traverses up the process tree to find the process ID and command of the IDE. - * - * This function uses different strategies depending on the operating system - * to identify the main application process (e.g., the main VS Code window - * process). - * - * If the IDE process cannot be reliably identified, it will return the - * top-level ancestor process ID and command as a fallback. - * - * @returns A promise that resolves to the PID and command of the IDE process. - */ -export async function getIdeProcessInfo(): Promise<{ - pid: number; - command: string; -}> { - const platform = os.platform(); - - if (platform === 'win32') { - return getIdeProcessInfoForWindows(); - } - - return getIdeProcessInfoForUnix(); -}