mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(cli): add --delete flag to /exit command for session deletion (#19332)
Co-authored-by: David Pierce <davidapierce@google.com>
This commit is contained in:
@@ -61,6 +61,19 @@ gemini --list-sessions
|
|||||||
gemini --delete-session 1
|
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)
|
## How to rewind time (Undo mistakes)
|
||||||
|
|
||||||
Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow.
|
Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow.
|
||||||
|
|||||||
@@ -323,6 +323,11 @@ Slash commands provide meta-level control over the CLI itself.
|
|||||||
### `/quit` (or `/exit`)
|
### `/quit` (or `/exit`)
|
||||||
|
|
||||||
- **Description:** Exit Gemini CLI.
|
- **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`
|
### `/restore`
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ describe('quitCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!quitCommand.action) throw new Error('Action is not defined');
|
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(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'quit',
|
type: 'quit',
|
||||||
|
deleteSession: false,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
type: 'user',
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ export const quitCommand: SlashCommand = {
|
|||||||
description: 'Exit the cli',
|
description: 'Exit the cli',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: (context) => {
|
action: (context, args) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const { sessionStartTime } = context.session.stats;
|
const { sessionStartTime } = context.session.stats;
|
||||||
const wallDuration = now - sessionStartTime.getTime();
|
const wallDuration = now - sessionStartTime.getTime();
|
||||||
|
|
||||||
|
const deleteSession = args.trim() === '--delete';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'quit',
|
type: 'quit',
|
||||||
|
deleteSession,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
type: 'user',
|
type: 'user',
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export interface CommandContext {
|
|||||||
export interface QuitActionReturn {
|
export interface QuitActionReturn {
|
||||||
type: 'quit';
|
type: 'quit';
|
||||||
messages: HistoryItem[];
|
messages: HistoryItem[];
|
||||||
|
/** When true, the current session's history and temporary files will be deleted on exit. */
|
||||||
|
deleteSession?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -646,6 +646,108 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
|
|
||||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
|
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 () => {
|
it('should handle "submit_prompt" action returned from a file-based command', async () => {
|
||||||
const fileCommand = createTestCommand(
|
const fileCommand = createTestCommand(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -557,6 +557,18 @@ export const useSlashCommandProcessor = (
|
|||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
}
|
}
|
||||||
case 'quit':
|
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);
|
actions.quit(result.messages);
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('recordDirectories', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await chatRecordingService.initialize();
|
await chatRecordingService.initialize();
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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.
|
* Rewinds the conversation to the state just before the specified message ID.
|
||||||
* All messages from (and including) the specified ID onwards are removed.
|
* All messages from (and including) the specified ID onwards are removed.
|
||||||
|
|||||||
Reference in New Issue
Block a user