From 011c0f9bc00ba28857236408d0645d5b31c37e54 Mon Sep 17 00:00:00 2001 From: Abdul Tawab <122252873+AbdulTawabJuly@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:20:57 +0500 Subject: [PATCH] feat(cli): add --delete flag to /exit command for session deletion (#19332) Co-authored-by: David Pierce --- docs/cli/tutorials/session-management.md | 13 +++ docs/reference/commands.md | 5 + .../cli/src/ui/commands/quitCommand.test.ts | 53 ++++++++- packages/cli/src/ui/commands/quitCommand.ts | 5 +- packages/cli/src/ui/commands/types.ts | 2 + .../ui/hooks/slashCommandProcessor.test.tsx | 102 ++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 12 +++ .../src/services/chatRecordingService.test.ts | 56 ++++++++++ .../core/src/services/chatRecordingService.ts | 26 +++++ 9 files changed, 272 insertions(+), 2 deletions(-) diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 3a0a6fae86..e71da13921 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -61,6 +61,19 @@ gemini --list-sessions gemini --delete-session 1 ``` +### Scenario: Delete session on exit + +If you're doing a one-off task and don't want to leave any session history +behind, use the `--delete` flag when exiting: + +``` +/exit --delete +``` + +This removes the current session's conversation history and tool output files +before exiting. It's useful for privacy-sensitive tasks or quick one-off +interactions. + ## How to rewind time (Undo mistakes) Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 7651539cb2..f7f8692e38 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -323,6 +323,11 @@ Slash commands provide meta-level control over the CLI itself. ### `/quit` (or `/exit`) - **Description:** Exit Gemini CLI. +- **Flags:** + - **`--delete`** _(optional)_: Exit and permanently delete the current + session's history and temporary files (chat recording, tool outputs). Useful + for privacy or one-off tasks where you don't want to leave any traces. + - **Usage:** `/quit --delete` or `/exit --delete` ### `/restore` diff --git a/packages/cli/src/ui/commands/quitCommand.test.ts b/packages/cli/src/ui/commands/quitCommand.test.ts index e67723fdf1..24a4a682d6 100644 --- a/packages/cli/src/ui/commands/quitCommand.test.ts +++ b/packages/cli/src/ui/commands/quitCommand.test.ts @@ -33,11 +33,12 @@ describe('quitCommand', () => { }); if (!quitCommand.action) throw new Error('Action is not defined'); - const result = quitCommand.action(mockContext, 'quit'); + const result = quitCommand.action(mockContext, ''); expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms expect(result).toEqual({ type: 'quit', + deleteSession: false, messages: [ { type: 'user', @@ -52,4 +53,54 @@ describe('quitCommand', () => { ], }); }); + + it('sets deleteSession to true when --delete flag is provided', () => { + const mockContext = createMockCommandContext({ + session: { + stats: { + sessionStartTime: new Date('2025-01-01T00:00:00Z'), + }, + }, + }); + + if (!quitCommand.action) throw new Error('Action is not defined'); + const result = quitCommand.action(mockContext, '--delete'); + + expect(result).toEqual({ + type: 'quit', + deleteSession: true, + messages: [ + { + type: 'user', + text: '/quit', + id: expect.any(Number), + }, + { + type: 'quit', + duration: '1h 0m 0s', + id: expect.any(Number), + }, + ], + }); + }); + + it('does not set deleteSession for unrecognized args', () => { + const mockContext = createMockCommandContext({ + session: { + stats: { + sessionStartTime: new Date('2025-01-01T00:00:00Z'), + }, + }, + }); + + if (!quitCommand.action) throw new Error('Action is not defined'); + const result = quitCommand.action(mockContext, 'some-random-arg'); + + expect(result).toEqual( + expect.objectContaining({ + type: 'quit', + deleteSession: false, + }), + ); + }); }); diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index ab879f22ca..ebd17e2d21 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -13,13 +13,16 @@ export const quitCommand: SlashCommand = { description: 'Exit the cli', kind: CommandKind.BUILT_IN, autoExecute: true, - action: (context) => { + action: (context, args) => { const now = Date.now(); const { sessionStartTime } = context.session.stats; const wallDuration = now - sessionStartTime.getTime(); + const deleteSession = args.trim() === '--delete'; + return { type: 'quit', + deleteSession, messages: [ { type: 'user', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index f0805f4426..328e8fc5e4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -108,6 +108,8 @@ export interface CommandContext { export interface QuitActionReturn { type: 'quit'; messages: HistoryItem[]; + /** When true, the current session's history and temporary files will be deleted on exit. */ + deleteSession?: boolean; } /** diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index f4b18d5bbf..e4f0066189 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -646,6 +646,108 @@ describe('useSlashCommandProcessor', () => { expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']); }); + + it('should delete the current session when quit action has deleteSession flag', async () => { + const mockDeleteCurrentSessionAsync = vi + .fn() + .mockResolvedValue(undefined); + + const mockClient = { + getChatRecordingService: vi.fn().mockReturnValue({ + deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync, + }), + } as unknown as GeminiClient; + vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); + + const quitAction = vi.fn().mockResolvedValue({ + type: 'quit', + deleteSession: true, + messages: ['bye'], + }); + const command = createTestCommand({ + name: 'exit', + action: quitAction, + }); + const result = await setupProcessorHook({ + builtinCommands: [command], + }); + + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/exit --delete'); + }); + + expect(mockDeleteCurrentSessionAsync).toHaveBeenCalled(); + expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']); + }); + + it('should not delete session when quit action does not have deleteSession flag', async () => { + const mockDeleteCurrentSessionAsync = vi + .fn() + .mockResolvedValue(undefined); + const mockClient = { + getChatRecordingService: vi.fn().mockReturnValue({ + deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync, + }), + } as unknown as GeminiClient; + vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); + + const quitAction = vi.fn().mockResolvedValue({ + type: 'quit', + messages: ['bye'], + }); + const command = createTestCommand({ + name: 'exit', + action: quitAction, + }); + const result = await setupProcessorHook({ + builtinCommands: [command], + }); + + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); + + expect(mockDeleteCurrentSessionAsync).not.toHaveBeenCalled(); + expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']); + }); + + it('should still quit even if session deletion fails', async () => { + const mockClient = { + getChatRecordingService: vi.fn().mockReturnValue({ + deleteCurrentSessionAsync: vi + .fn() + .mockRejectedValue(new Error('Deletion failed')), + }), + } as unknown as GeminiClient; + vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); + + const quitAction = vi.fn().mockResolvedValue({ + type: 'quit', + deleteSession: true, + messages: ['bye'], + }); + const command = createTestCommand({ + name: 'exit', + action: quitAction, + }); + const result = await setupProcessorHook({ + builtinCommands: [command], + }); + + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/exit --delete'); + }); + + // Should still quit even though deletion threw + expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']); + }); + 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 3007a96a73..6e880ed4bb 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -557,6 +557,18 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; } case 'quit': + if (result.deleteSession) { + try { + const chatRecordingService = config + ?.getGeminiClient() + ?.getChatRecordingService(); + if (chatRecordingService) { + await chatRecordingService.deleteCurrentSessionAsync(); + } + } catch { + // Don't let deletion errors prevent exit. + } + } actions.quit(result.messages); return { type: 'handled' }; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 94b9c61c7a..d6588945e1 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -735,6 +735,62 @@ describe('ChatRecordingService', () => { }); }); + describe('deleteCurrentSessionAsync', () => { + it('should asynchronously delete the current session file and tool outputs', async () => { + await chatRecordingService.initialize(); + // Record a message to trigger the file write (writeConversation skips + // writing when there are no messages). + chatRecordingService.recordMessage({ + type: 'user', + content: 'test', + model: 'gemini-pro', + }); + const conversationFile = chatRecordingService.getConversationFilePath(); + expect(conversationFile).not.toBeNull(); + + // Create a tool output directory matching the session ID used by + // deleteSessionArtifactsAsync (this.sessionId = mockConfig.promptId). + const toolOutputDir = path.join( + testTempDir, + 'tool-outputs', + 'session-test-session-id', + ); + fs.mkdirSync(toolOutputDir, { recursive: true }); + fs.writeFileSync(path.join(toolOutputDir, 'output.txt'), 'data'); + + expect(fs.existsSync(conversationFile!)).toBe(true); + expect(fs.existsSync(toolOutputDir)).toBe(true); + + await chatRecordingService.deleteCurrentSessionAsync(); + + expect(fs.existsSync(conversationFile!)).toBe(false); + expect(fs.existsSync(toolOutputDir)).toBe(false); + }); + + it('should not throw if the session was never initialized', async () => { + // conversationFile is null when not initialized + await expect( + chatRecordingService.deleteCurrentSessionAsync(), + ).resolves.not.toThrow(); + }); + + it('should not throw if session file does not exist on disk', async () => { + // initialize() writes an initial metadata record synchronously, so + // delete the file manually to simulate the "missing on disk" scenario. + await chatRecordingService.initialize(); + const conversationFile = chatRecordingService.getConversationFilePath(); + expect(conversationFile).not.toBeNull(); + if (conversationFile && fs.existsSync(conversationFile)) { + fs.unlinkSync(conversationFile); + } + expect(fs.existsSync(conversationFile!)).toBe(false); + + await expect( + chatRecordingService.deleteCurrentSessionAsync(), + ).resolves.not.toThrow(); + }); + }); + describe('recordDirectories', () => { beforeEach(async () => { await chatRecordingService.initialize(); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 5791184d15..c7cf7ef95e 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -792,6 +792,32 @@ export class ChatRecordingService { } } + /** + * Asynchronously deletes the current session's chat file and tool outputs. + * This encapsulates the session ID logic and uses non-blocking I/O to avoid + * blocking the event loop on exit. + */ + async deleteCurrentSessionAsync(): Promise { + if (!this.conversationFile) { + return; + } + + try { + const tempDir = this.context.config.storage.getProjectTempDir(); + + // Delete the conversation file directly using the tracked path. + await fs.promises.unlink(this.conversationFile).catch(() => { + // File may not exist; ignore. + }); + + // Delegate tool-output and log cleanup to the shared utility. + await deleteSessionArtifactsAsync(this.sessionId, tempDir); + } catch (error) { + debugLogger.error('Error deleting current session.', error); + throw error; + } + } + /** * Rewinds the conversation to the state just before the specified message ID. * All messages from (and including) the specified ID onwards are removed.