mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
370 lines
9.8 KiB
TypeScript
370 lines
9.8 KiB
TypeScript
/**
|
|
* @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,
|
|
MessageActionReturn,
|
|
SlashCommandActionReturn,
|
|
} from './types.js';
|
|
import { CommandKind } from './types.js';
|
|
import { decodeTagName } from '@google/gemini-cli-core';
|
|
import path from 'node:path';
|
|
import type {
|
|
HistoryItemWithoutId,
|
|
HistoryItemChatList,
|
|
ChatDetail,
|
|
} from '../types.js';
|
|
import { MessageType } from '../types.js';
|
|
import type { Content } from '@google/genai';
|
|
|
|
const getSavedChatTags = async (
|
|
context: CommandContext,
|
|
mtSortDesc: boolean,
|
|
): Promise<ChatDetail[]> => {
|
|
const cfg = context.services.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 conversation checkpoints',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: async (context): Promise<void> => {
|
|
const chatDetails = await getSavedChatTags(context, false);
|
|
|
|
const item: HistoryItemChatList = {
|
|
type: MessageType.CHAT_LIST,
|
|
chats: chatDetails,
|
|
};
|
|
|
|
context.ui.addItem(item, Date.now());
|
|
},
|
|
};
|
|
|
|
const saveCommand: SlashCommand = {
|
|
name: 'save',
|
|
description:
|
|
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
|
const tag = args.trim();
|
|
if (!tag) {
|
|
return {
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Missing tag. Usage: /chat save <tag>',
|
|
};
|
|
}
|
|
|
|
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: theme.text.accent }, 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 {
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'No chat client available to save conversation.',
|
|
};
|
|
}
|
|
|
|
const history = chat.getHistory();
|
|
if (history.length > 2) {
|
|
await logger.saveCheckpoint(history, 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 resumeCommand: SlashCommand = {
|
|
name: 'resume',
|
|
altNames: ['load'],
|
|
description:
|
|
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: async (context, args) => {
|
|
const tag = args.trim();
|
|
if (!tag) {
|
|
return {
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Missing tag. Usage: /chat resume <tag>',
|
|
};
|
|
}
|
|
|
|
const { logger } = context.services;
|
|
await logger.initialize();
|
|
const conversation = await logger.loadCheckpoint(tag);
|
|
|
|
if (conversation.length === 0) {
|
|
return {
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`,
|
|
};
|
|
}
|
|
|
|
const rolemap: { [key: string]: MessageType } = {
|
|
user: MessageType.USER,
|
|
model: MessageType.GEMINI,
|
|
};
|
|
|
|
const uiHistory: HistoryItemWithoutId[] = [];
|
|
let hasSystemPrompt = false;
|
|
let i = 0;
|
|
|
|
for (const item of conversation) {
|
|
i += 1;
|
|
const text =
|
|
item.parts
|
|
?.filter((m) => !!m.text)
|
|
.map((m) => m.text)
|
|
.join('') || '';
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
if (i === 1 && text.match(/context for our chat/)) {
|
|
hasSystemPrompt = true;
|
|
}
|
|
if (i > 2 || !hasSystemPrompt) {
|
|
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: /chat delete <tag>',
|
|
kind: CommandKind.BUILT_IN,
|
|
action: async (context, args): Promise<MessageActionReturn> => {
|
|
const tag = args.trim();
|
|
if (!tag) {
|
|
return {
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Missing tag. Usage: /chat 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));
|
|
},
|
|
};
|
|
|
|
export function serializeHistoryToMarkdown(history: Content[]): string {
|
|
return history
|
|
.map((item) => {
|
|
const text =
|
|
item.parts
|
|
?.map((part) => {
|
|
if (part.text) {
|
|
return part.text;
|
|
}
|
|
if (part.functionCall) {
|
|
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
|
|
part.functionCall,
|
|
null,
|
|
2,
|
|
)}\n\`\`\``;
|
|
}
|
|
if (part.functionResponse) {
|
|
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
|
|
part.functionResponse,
|
|
null,
|
|
2,
|
|
)}\n\`\`\``;
|
|
}
|
|
return '';
|
|
})
|
|
.join('') || '';
|
|
const roleIcon = item.role === 'user' ? '🧑💻' : '✨';
|
|
return `${roleIcon} ## ${(item.role || 'model').toUpperCase()}\n\n${text}`;
|
|
})
|
|
.join('\n\n---\n\n');
|
|
}
|
|
|
|
const shareCommand: SlashCommand = {
|
|
name: 'share',
|
|
description:
|
|
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
|
|
kind: CommandKind.BUILT_IN,
|
|
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 = await context.services.config?.getGeminiClient()?.getChat();
|
|
if (!chat) {
|
|
return {
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'No chat client available to share conversation.',
|
|
};
|
|
}
|
|
|
|
const history = chat.getHistory();
|
|
|
|
// An empty conversation has two hidden messages that setup the context for
|
|
// the chat. Thus, to check whether a conversation has been started, we
|
|
// can't check for length 0.
|
|
if (history.length <= 2) {
|
|
return {
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content: 'No conversation found to share.',
|
|
};
|
|
}
|
|
|
|
let content = '';
|
|
if (extension === '.json') {
|
|
content = JSON.stringify(history, null, 2);
|
|
} else {
|
|
content = serializeHistoryToMarkdown(history);
|
|
}
|
|
|
|
try {
|
|
await fsPromises.writeFile(filePath, content);
|
|
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 chatCommand: SlashCommand = {
|
|
name: 'chat',
|
|
description: 'Manage conversation history',
|
|
kind: CommandKind.BUILT_IN,
|
|
subCommands: [
|
|
listCommand,
|
|
saveCommand,
|
|
resumeCommand,
|
|
deleteCommand,
|
|
shareCommand,
|
|
],
|
|
};
|