| |
| |
| |
| |
| |
|
|
| 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<ChatDetail[]> => { |
| 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 { |
| return []; |
| } |
| }; |
|
|
| const listCommand: SlashCommand = { |
| name: 'list', |
| description: 'List saved manual conversation checkpoints', |
| kind: CommandKind.BUILT_IN, |
| autoExecute: true, |
| takesArgs: false, |
| action: async (context): Promise<void> => { |
| 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 <tag>', |
| kind: CommandKind.BUILT_IN, |
| autoExecute: false, |
| action: async (context, args): Promise<SlashCommandActionReturn | void> => { |
| const tag = args.trim(); |
| if (!tag) { |
| return { |
| type: 'message', |
| messageType: 'error', |
| content: 'Missing tag. Usage: /resume save <tag>', |
| }; |
| } |
|
|
| 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 <tag>', |
| 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 <tag>', |
| }; |
| } |
|
|
| 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; |
| } |
|
|
| |
| 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 <tag>', |
| kind: CommandKind.BUILT_IN, |
| autoExecute: true, |
| action: async (context, args): Promise<MessageActionReturn> => { |
| const tag = args.trim(); |
| if (!tag) { |
| return { |
| type: 'message', |
| messageType: 'error', |
| content: 'Missing tag. Usage: /resume delete <tag>', |
| }; |
| } |
|
|
| 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 <file>', |
| kind: CommandKind.BUILT_IN, |
| autoExecute: false, |
| action: async (context, args): Promise<MessageActionReturn> => { |
| 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(); |
|
|
| |
| |
| |
| 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<MessageActionReturn> => { |
| 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, |
| ]; |
|
|
| import { parseSlashCommand } from '../../utils/commands.js'; |
|
|
| export const chatCommand: SlashCommand = { |
| name: 'chat', |
| description: 'Browse auto-saved conversations and manage chat checkpoints', |
| kind: CommandKind.BUILT_IN, |
| autoExecute: true, |
| action: async (context, args) => { |
| if (args) { |
| const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands); |
| if (parsed.commandToExecute?.action) { |
| return parsed.commandToExecute.action(context, parsed.args); |
| } |
| } |
| return { |
| type: 'dialog', |
| dialog: 'sessionBrowser', |
| }; |
| }, |
| subCommands: chatResumeSubCommands, |
| }; |
|
|