feat(chat): Add overwrite confirmation dialog to /chat save (#5686)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Hiroaki Mitsuyoshi
2025-08-09 15:59:22 +09:00
committed by GitHub
parent 191cc01bf5
commit 6487cc1689
7 changed files with 214 additions and 3 deletions

View File

@@ -168,8 +168,12 @@ describe('chatCommand', () => {
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
});
it('should return an error if tag is missing', async () => {
@@ -191,7 +195,7 @@ describe('chatCommand', () => {
});
});
it('should save the conversation', async () => {
it('should save the conversation if checkpoint does not exist', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
@@ -199,8 +203,52 @@ describe('chatCommand', () => {
},
];
mockGetHistory.mockReturnValue(history);
mockCheckpointExists.mockResolvedValue(false);
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
it('should return confirm_action if checkpoint already exists', async () => {
mockCheckpointExists.mockResolvedValue(true);
mockContext.invocation = {
raw: `/chat save ${tag}`,
name: 'save',
args: tag,
};
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'confirm_action',
originalInvocation: { raw: `/chat save ${tag}` },
});
// Check that prompt is a React element
expect(result).toHaveProperty('prompt');
});
it('should save the conversation if overwrite is confirmed', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
text: 'hello',
},
];
mockGetHistory.mockReturnValue(history);
mockContext.overwriteConfirmed = true;
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',

View File

@@ -5,11 +5,15 @@
*/
import * as fsPromises from 'fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
CommandContext,
SlashCommand,
MessageActionReturn,
CommandKind,
SlashCommandActionReturn,
} from './types.js';
import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js';
@@ -96,7 +100,7 @@ const saveCommand: SlashCommand = {
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
if (!tag) {
return {
@@ -108,6 +112,26 @@ const saveCommand: SlashCommand = {
const { logger, config } = context.services;
await logger.initialize();
if (!context.overwriteConfirmed) {
const exists = await logger.checkpointExists(tag);
if (exists) {
return {
type: 'confirm_action',
prompt: React.createElement(
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: Colors.AccentPurple }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
},
};
}
}
const chat = await config?.getGeminiClient()?.getChat();
if (!chat) {
return {

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ReactNode } from 'react';
import { Content } from '@google/genai';
import { HistoryItemWithoutId } from '../types.js';
import { Config, GitService, Logger } from '@google/gemini-cli-core';
@@ -67,6 +68,8 @@ export interface CommandContext {
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
};
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
}
/**
@@ -135,6 +138,16 @@ export interface ConfirmShellCommandsActionReturn {
};
}
export interface ConfirmActionReturn {
type: 'confirm_action';
/** The React node to display as the confirmation prompt. */
prompt: ReactNode;
/** The original invocation context to be re-run after confirmation. */
originalInvocation: {
raw: string;
};
}
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
@@ -142,7 +155,8 @@ export type SlashCommandActionReturn =
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn;
| ConfirmShellCommandsActionReturn
| ConfirmActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',