diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 44de1d83..9a712336 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -162,6 +162,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { getIdeClient: vi.fn(() => ({ getCurrentIde: vi.fn(() => 'vscode'), getDetectedIdeDisplayName: vi.fn(() => 'VSCode'), + addStatusChangeListener: vi.fn(), + removeStatusChangeListener: vi.fn(), + getConnectionStatus: vi.fn(() => 'connected'), })), isTrustedFolder: vi.fn(() => true), }; diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 8576320b..f61df287 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -69,16 +69,35 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getCurrentIde: () => DetectedIde.VSCode, getDetectedIdeDisplayName: () => 'VS Code', + getConnectionStatus: () => ({ + status: core.IDEConnectionStatus.Disconnected, + }), } as ReturnType); const command = ideCommand(mockConfig); expect(command).not.toBeNull(); expect(command?.name).toBe('ide'); expect(command?.subCommands).toHaveLength(3); - expect(command?.subCommands?.[0].name).toBe('disable'); + expect(command?.subCommands?.[0].name).toBe('enable'); expect(command?.subCommands?.[1].name).toBe('status'); expect(command?.subCommands?.[2].name).toBe('install'); }); + it('should show disable command when connected', () => { + vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeClient).mockReturnValue({ + getCurrentIde: () => DetectedIde.VSCode, + getDetectedIdeDisplayName: () => 'VS Code', + getConnectionStatus: () => ({ + status: core.IDEConnectionStatus.Connected, + }), + } as ReturnType); + const command = ideCommand(mockConfig); + expect(command).not.toBeNull(); + const subCommandNames = command?.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).toContain('disable'); + expect(subCommandNames).not.toContain('enable'); + }); + describe('status subcommand', () => { const mockGetConnectionStatus = vi.fn(); beforeEach(() => { @@ -161,7 +180,9 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getCurrentIde: () => DetectedIde.VSCode, - getConnectionStatus: vi.fn(), + getConnectionStatus: () => ({ + status: core.IDEConnectionStatus.Disconnected, + }), getDetectedIdeDisplayName: () => 'VS Code', } as unknown as ReturnType); vi.mocked(core.getIdeInstaller).mockReturnValue({ diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index b7cbea3d..49766b8d 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -237,13 +237,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { }, }; - const ideModeEnabled = config.getIdeMode(); - if (ideModeEnabled) { - ideSlashCommand.subCommands = [ - disableCommand, - statusCommand, - installCommand, - ]; + const { status } = ideClient.getConnectionStatus(); + const isConnected = status === IDEConnectionStatus.Connected; + + if (isConnected) { + ideSlashCommand.subCommands = [statusCommand, disableCommand]; } else { ideSlashCommand.subCommands = [ enableCommand, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index d1af9964..4e70eab7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -206,7 +206,22 @@ export const useSlashCommandProcessor = ( ], ); - const ideMode = config?.getIdeMode(); + useEffect(() => { + if (!config) { + return; + } + + const ideClient = config.getIdeClient(); + const listener = () => { + reloadCommands(); + }; + + ideClient.addStatusChangeListener(listener); + + return () => { + ideClient.removeStatusChangeListener(listener); + }; + }, [config, reloadCommands]); useEffect(() => { const controller = new AbortController(); @@ -228,7 +243,7 @@ export const useSlashCommandProcessor = ( return () => { controller.abort(); }; - }, [config, ideMode, reloadTrigger]); + }, [config, reloadTrigger]); const handleSlashCommand = useCallback( async ( diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 0a99f0de..efb9c8f0 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -63,6 +63,7 @@ export class IdeClient { private readonly currentIde: DetectedIde | undefined; private readonly currentIdeDisplayName: string | undefined; private diffResponses = new Map void>(); + private statusListeners = new Set<(state: IDEConnectionState) => void>(); private constructor() { this.currentIde = detectIde(); @@ -78,6 +79,14 @@ export class IdeClient { return IdeClient.instance; } + addStatusChangeListener(listener: (state: IDEConnectionState) => void) { + this.statusListeners.add(listener); + } + + removeStatusChangeListener(listener: (state: IDEConnectionState) => void) { + this.statusListeners.delete(listener); + } + async connect(): Promise { if (!this.currentIde || !this.currentIdeDisplayName) { this.setState( @@ -237,6 +246,9 @@ export class IdeClient { // disconnected, so that the first detail message is preserved. if (!isAlreadyDisconnected) { this.state = { status, details }; + for (const listener of this.statusListeners) { + listener(this.state); + } if (details) { if (logToConsole) { logger.error(details); @@ -390,7 +402,6 @@ export class IdeClient { logger.debug('Failed to close transport:', closeError); } } - logger.error(`Failed to connect: ${_error}`); return false; } }