diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 947f9ad2da3..66f5a693310 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -230,7 +230,8 @@ Slash commands provide meta-level control over the CLI itself. purposes. - **`/quit`** (or **`/exit`**) - - **Description:** Exit Gemini CLI. + - **Description:** Exit Gemini CLI. You can also type `quit` or `exit` without + the leading slash. - **`/vim`** - **Description:** Toggle vim mode on or off. When vim mode is enabled, the diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 77be67c3038..f7cb47cea28 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -574,6 +574,31 @@ describe('useSlashCommandProcessor', () => { expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']); }); + + it.each(['exit', 'quit', 'EXIT', 'Quit'])( + 'should handle "%s" command without a slash', + async (cmd) => { + const quitAction = vi + .fn() + .mockResolvedValue({ type: 'quit', messages: ['bye'] }); + const command = createTestCommand({ + name: 'quit', + altNames: ['exit'], + action: quitAction, + }); + const result = await setupProcessorHook([command]); + + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(cmd); + }); + + expect(quitAction).toHaveBeenCalled(); + }, + ); it('should handle "submit_prompt" action returned from a file-based command', async () => { const fileCommand = createTestCommand( { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 2b43e1bb7b9..076853d4251 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -322,7 +322,14 @@ export const useSlashCommandProcessor = ( } const trimmed = rawQuery.trim(); - if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { + const parts = trimmed.split(/\s+/); + const commandWord = parts[0].toLowerCase(); + const isExitOrQuit = commandWord === 'exit' || commandWord === 'quit'; + if ( + !trimmed.startsWith('/') && + !trimmed.startsWith('?') && + !isExitOrQuit + ) { return false; } @@ -337,11 +344,20 @@ export const useSlashCommandProcessor = ( } let hasError = false; + let commandToParse = trimmed; + if ( + isExitOrQuit && + !trimmed.startsWith('/') && + !trimmed.startsWith('?') + ) { + parts[0] = commandWord; + commandToParse = `/${parts.join(' ')}`; + } const { commandToExecute, args, canonicalPath: resolvedCommandPath, - } = parseSlashCommand(trimmed, commands); + } = parseSlashCommand(commandToParse, commands); const subcommand = resolvedCommandPath.length > 1 diff --git a/packages/cli/src/ui/hooks/useGeminiStream.exit.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.exit.test.tsx new file mode 100644 index 00000000000..72fc1d75ef3 --- /dev/null +++ b/packages/cli/src/ui/hooks/useGeminiStream.exit.test.tsx @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { useGeminiStream } from './useGeminiStream.js'; +import { AuthType, ApprovalMode } from '@google/gemini-cli-core'; +import type { Config , EditorType } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; + +// Mocks +const mockHandleSlashCommand = vi.fn(); + +vi.mock('./useReactToolScheduler.js', () => ({ + useReactToolScheduler: vi.fn().mockReturnValue([ + [], // toolCalls + vi.fn(), // scheduleToolCalls + vi.fn(), // markToolsAsSubmitted + vi.fn(), // setToolCallsForDisplay + vi.fn(), // cancelAllToolCalls + 0, // lastToolOutputTime + ]), + mapToDisplay: vi.fn(), +})); + +vi.mock('./useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('./shellCommandProcessor.js', () => ({ + useShellCommandProcessor: vi.fn().mockReturnValue({ + handleShellCommand: vi.fn(), + }), +})); + +vi.mock('./atCommandProcessor.js', () => ({ + handleAtCommand: vi.fn(), +})); + +vi.mock('../utils/markdownUtilities.js', () => ({ + findLastSafeSplitPoint: vi.fn((s) => s.length), +})); + +vi.mock('./useStateAndRef.js', () => ({ + useStateAndRef: vi.fn((initial) => { + let val = initial; + const ref = { current: val }; + const setVal = vi.fn((updater) => { + val = typeof updater === 'function' ? updater(val) : updater; + ref.current = val; + }); + return [val, ref, setVal]; + }), +})); + +vi.mock('./useLogger.js', () => ({ + useLogger: vi.fn().mockReturnValue({ + logMessage: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock('../contexts/SessionContext.js', () => ({ + useSessionStats: vi.fn(() => ({ + startNewPrompt: vi.fn(), + addUsage: vi.fn(), + getPromptCount: vi.fn(() => 0), + })), +})); + +vi.mock('./slashCommandProcessor.js', () => ({ + handleSlashCommand: vi.fn(), +})); + +vi.mock('./useAlternateBuffer.js', () => ({ + useAlternateBuffer: vi.fn(() => false), +})); + +describe('useGeminiStream - Exit/Quit Commands', () => { + let mockConfig: Config; + let mockLoadedSettings: LoadedSettings; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfig = { + getGeminiClient: vi.fn().mockReturnValue({ + getCurrentSequenceModel: vi.fn(), + getChat: vi.fn().mockReturnValue({ + recordCompletedToolCalls: vi.fn(), + }), + }), + storage: {}, + getProjectRoot: vi.fn(), + getApprovalMode: () => ApprovalMode.DEFAULT, + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getModel: vi.fn(() => 'gemini-pro'), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.USE_GEMINI, + }), + getCheckpointingEnabled: vi.fn(() => false), + setQuotaErrorOccurred: vi.fn(), + getMaxSessionTurns: vi.fn(() => 100), + getSessionId: vi.fn(() => 'test-session-id'), + isInteractive: vi.fn(() => true), + getExperiments: vi.fn(() => ({ experimentIds: [] })), + } as unknown as Config; + + mockLoadedSettings = { + merged: { ui: { showCitations: true } }, + } as unknown as LoadedSettings; + }); + + it('should treat "exit" as a slash command', async () => { + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + vi.fn(), + mockConfig, + mockLoadedSettings, + vi.fn(), + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + vi.fn(), + vi.fn(), + false, + vi.fn(), + vi.fn(), + vi.fn(), + 80, + 24, + ), + ); + + await result.current.submitQuery('exit'); + + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/exit'); + }); + + it('should treat "quit" as a slash command', async () => { + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + vi.fn(), + mockConfig, + mockLoadedSettings, + vi.fn(), + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + vi.fn(), + vi.fn(), + false, + vi.fn(), + vi.fn(), + vi.fn(), + 80, + 24, + ), + ); + + await result.current.submitQuery('quit'); + + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/exit'); + }); + + it('should treat "EXIT" (case insensitive) as a slash command', async () => { + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + vi.fn(), + mockConfig, + mockLoadedSettings, + vi.fn(), + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + vi.fn(), + vi.fn(), + false, + vi.fn(), + vi.fn(), + vi.fn(), + 80, + 24, + ), + ); + + await result.current.submitQuery('EXIT'); + + expect(mockHandleSlashCommand).toHaveBeenCalledWith('/exit'); + }); + + it('should NOT treat "exit now" as a slash command', async () => { + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + vi.fn(), + mockConfig, + mockLoadedSettings, + vi.fn(), + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + vi.fn(), + vi.fn(), + false, + vi.fn(), + vi.fn(), + vi.fn(), + 80, + 24, + ), + ); + + await result.current.submitQuery('exit now'); + + expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/exit'); + }); +}); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 979f520dcd2..cc833ae73a5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -424,9 +424,15 @@ export const useGeminiStream = ( if (!shellModeActive) { // Handle UI-only commands first - const slashCommandResult = isSlashCommand(trimmedQuery) - ? await handleSlashCommand(trimmedQuery) - : false; + let slashCommandResult: SlashCommandProcessorResult | false = false; + if ( + trimmedQuery.toLowerCase() === 'exit' || + trimmedQuery.toLowerCase() === 'quit' + ) { + slashCommandResult = await handleSlashCommand('/exit'); + } else if (isSlashCommand(trimmedQuery)) { + slashCommandResult = await handleSlashCommand(trimmedQuery); + } if (slashCommandResult) { switch (slashCommandResult.type) { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index f9b105372b2..d92725ee695 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -399,7 +399,13 @@ export class IdeClient { return; } for (const filePath of this.diffResponses.keys()) { - await this.closeDiff(filePath); + // We want to try to close the diff, but we don't want to block the + // disconnect for too long if the IDE is unresponsive. + const closeDiffPromise = this.closeDiff(filePath); + const timeoutPromise = new Promise((resolve) => + setTimeout(resolve, 2000), + ); + await Promise.race([closeDiffPromise, timeoutPromise]); } this.diffResponses.clear(); this.setState(