/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fsPromises from 'node:fs/promises'; import React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { CommandContext, SlashCommand, SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { decodeTagName, type MessageActionReturn, INITIAL_HISTORY_LENGTH, } from '@google/gemini-cli-core'; import path from 'node:path'; import type { HistoryItemWithoutId, HistoryItemChatList, ChatDetail, } from '../types.js'; import { MessageType } from '../types.js'; import { exportHistoryToFile } from '../utils/historyExportUtils.js'; import { convertToRestPayload } from '@google/gemini-cli-core'; const CHECKPOINT_MENU_GROUP = 'checkpoints'; const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise => { const cfg = context.services.agentContext?.config; const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; } try { const file_head = 'checkpoint-'; const file_tail = '.json'; const files = await fsPromises.readdir(geminiDir); const chatDetails: ChatDetail[] = []; for (const file of files) { if (file.startsWith(file_head) && file.endsWith(file_tail)) { const filePath = path.join(geminiDir, file); const stats = await fsPromises.stat(filePath); const tagName = file.slice(file_head.length, -file_tail.length); chatDetails.push({ name: decodeTagName(tagName), mtime: stats.mtime.toISOString(), }); } } chatDetails.sort((a, b) => mtSortDesc ? b.mtime.localeCompare(a.mtime) : a.mtime.localeCompare(b.mtime), ); return chatDetails; } catch (_err) { return []; } }; const listCommand: SlashCommand = { name: 'list', description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { const chatDetails = await getSavedChatTags(context, false); const item: HistoryItemChatList = { type: MessageType.CHAT_LIST, chats: chatDetails, }; context.ui.addItem(item); }, }; const saveCommand: SlashCommand = { name: 'save', description: 'Save the current conversation as a checkpoint. Usage: /resume save ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { return { type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume save ', }; } const { logger } = context.services; const config = context.services.agentContext?.config; 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: theme.text.accent }, tag), ' already exists. Do you want to overwrite it?', ), originalInvocation: { raw: context.invocation?.raw || `/resume save ${tag}`, }, }; } } const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', messageType: 'error', content: 'No chat client available to save conversation.', }; } const history = chat.getHistory(); if (history.length > INITIAL_HISTORY_LENGTH) { const authType = config?.getContentGeneratorConfig()?.authType; await logger.saveCheckpoint({ history, authType }, tag); return { type: 'message', messageType: 'info', content: `Conversation checkpoint saved with tag: ${decodeTagName( tag, )}.`, }; } else { return { type: 'message', messageType: 'info', content: 'No conversation found to save.', }; } }, }; const resumeCheckpointCommand: SlashCommand = { name: 'resume', altNames: ['load'], description: 'Resume a conversation from a checkpoint. Usage: /resume resume ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args) => { const tag = args.trim(); if (!tag) { return { type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume resume ', }; } const { logger } = context.services; const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); const conversation = checkpoint.history; if (conversation.length === 0) { return { type: 'message', messageType: 'info', content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`, }; } const currentAuthType = config?.getContentGeneratorConfig()?.authType; if ( checkpoint.authType && currentAuthType && checkpoint.authType !== currentAuthType ) { return { type: 'message', messageType: 'error', content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`, }; } const rolemap: { [key: string]: MessageType } = { user: MessageType.USER, model: MessageType.GEMINI, }; const uiHistory: HistoryItemWithoutId[] = []; for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) { const text = item.parts ?.filter((m) => !!m.text) .map((m) => m.text) .join('') || ''; if (!text) { continue; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion uiHistory.push({ type: (item.role && rolemap[item.role]) || MessageType.GEMINI, text, } as HistoryItemWithoutId); } return { type: 'load_history', history: uiHistory, clientHistory: conversation, }; }, completion: async (context, partialArg) => { const chatDetails = await getSavedChatTags(context, true); return chatDetails .map((chat) => chat.name) .filter((name) => name.startsWith(partialArg)); }, }; const deleteCommand: SlashCommand = { name: 'delete', description: 'Delete a conversation checkpoint. Usage: /resume delete ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { return { type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume delete ', }; } const { logger } = context.services; await logger.initialize(); const deleted = await logger.deleteCheckpoint(tag); if (deleted) { return { type: 'message', messageType: 'info', content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`, }; } else { return { type: 'message', messageType: 'error', content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`, }; } }, completion: async (context, partialArg) => { const chatDetails = await getSavedChatTags(context, true); return chatDetails .map((chat) => chat.name) .filter((name) => name.startsWith(partialArg)); }, }; const shareCommand: SlashCommand = { name: 'share', description: 'Share the current conversation to a markdown or json file. Usage: /resume share ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { let filePathArg = args.trim(); if (!filePathArg) { filePathArg = `gemini-conversation-${Date.now()}.json`; } const filePath = path.resolve(filePathArg); const extension = path.extname(filePath); if (extension !== '.md' && extension !== '.json') { return { type: 'message', messageType: 'error', content: 'Invalid file format. Only .md and .json are supported.', }; } const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', messageType: 'error', content: 'No chat client available to share conversation.', }; } const history = chat.getHistory(); // An empty conversation has a hidden message that sets up the context for // the chat. Thus, to check whether a conversation has been started, we // can't check for length 0. if (history.length <= INITIAL_HISTORY_LENGTH) { return { type: 'message', messageType: 'info', content: 'No conversation found to share.', }; } try { await exportHistoryToFile({ history, filePath }); return { type: 'message', messageType: 'info', content: `Conversation shared to ${filePath}`, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { type: 'message', messageType: 'error', content: `Error sharing conversation: ${errorMessage}`, }; } }, }; export const debugCommand: SlashCommand = { name: 'debug', description: 'Export the most recent API request as a JSON payload', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { const req = context.services.agentContext?.config.getLatestApiRequest(); if (!req) { return { type: 'message', messageType: 'error', content: 'No recent API request found to export.', }; } const restPayload = convertToRestPayload(req); const filename = `gcli-request-${Date.now()}.json`; const filePath = path.join(process.cwd(), filename); try { await fsPromises.writeFile( filePath, JSON.stringify(restPayload, null, 2), ); return { type: 'message', messageType: 'info', content: `Debug API request saved to ${filename}`, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { type: 'message', messageType: 'error', content: `Error saving debug request: ${errorMessage}`, }; } }, }; export const checkpointSubCommands: SlashCommand[] = [ listCommand, saveCommand, resumeCheckpointCommand, deleteCommand, shareCommand, ]; const checkpointCompatibilityCommand: SlashCommand = { name: 'checkpoints', altNames: ['checkpoint'], description: 'Compatibility command for nested checkpoint operations', kind: CommandKind.BUILT_IN, hidden: true, autoExecute: false, subCommands: checkpointSubCommands, }; export const chatResumeSubCommands: SlashCommand[] = [ ...checkpointSubCommands.map((subCommand) => ({ ...subCommand, suggestionGroup: CHECKPOINT_MENU_GROUP, })), checkpointCompatibilityCommand, ]; export const chatCommand: SlashCommand = { name: 'chat', description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async () => ({ type: 'dialog', dialog: 'sessionBrowser', }), subCommands: chatResumeSubCommands, };