Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
Expand Down
228 changes: 228 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.exit.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
12 changes: 9 additions & 3 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/ide/ide-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) =>
setTimeout(resolve, 2000),
);
await Promise.race([closeDiffPromise, timeoutPromise]);
}
this.diffResponses.clear();
this.setState(
Expand Down