diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/services/CommandService.test.ts | 13 | ||||
| -rw-r--r-- | packages/cli/src/services/CommandService.ts | 2 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/chatCommand.test.ts | 277 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/chatCommand.ts | 197 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/types.ts | 15 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 167 |
6 files changed, 510 insertions, 161 deletions
diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index c309da34..b1f6e496 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -10,6 +10,7 @@ import { type SlashCommand } from '../ui/commands/types.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; +import { chatCommand } from '../ui/commands/chatCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; @@ -47,6 +48,8 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({ })); describe('CommandService', () => { + const subCommandLen = 10; + describe('when using default production loader', () => { let commandService: CommandService; @@ -70,13 +73,14 @@ describe('CommandService', () => { const tree = commandService.getCommands(); // Post-condition assertions - expect(tree.length).toBe(9); + expect(tree.length).toBe(subCommandLen); const commandNames = tree.map((cmd) => cmd.name); expect(commandNames).toContain('auth'); expect(commandNames).toContain('memory'); expect(commandNames).toContain('help'); expect(commandNames).toContain('clear'); + expect(commandNames).toContain('chat'); expect(commandNames).toContain('theme'); expect(commandNames).toContain('stats'); expect(commandNames).toContain('privacy'); @@ -87,14 +91,14 @@ describe('CommandService', () => { it('should overwrite any existing commands when called again', async () => { // Load once await commandService.loadCommands(); - expect(commandService.getCommands().length).toBe(9); + expect(commandService.getCommands().length).toBe(subCommandLen); // Load again await commandService.loadCommands(); const tree = commandService.getCommands(); // Should not append, but overwrite - expect(tree.length).toBe(9); + expect(tree.length).toBe(subCommandLen); }); }); @@ -106,10 +110,11 @@ describe('CommandService', () => { await commandService.loadCommands(); const loadedTree = commandService.getCommands(); - expect(loadedTree.length).toBe(9); + expect(loadedTree.length).toBe(subCommandLen); expect(loadedTree).toEqual([ aboutCommand, authCommand, + chatCommand, clearCommand, extensionsCommand, helpCommand, diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 379d0638..50f2c63a 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -10,6 +10,7 @@ import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; +import { chatCommand } from '../ui/commands/chatCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; @@ -18,6 +19,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [ aboutCommand, authCommand, + chatCommand, clearCommand, extensionsCommand, helpCommand, diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts new file mode 100644 index 00000000..5318c330 --- /dev/null +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -0,0 +1,277 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + Mocked, +} from 'vitest'; + +import { + type CommandContext, + MessageActionReturn, + SlashCommand, +} from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { Content } from '@google/genai'; +import { GeminiClient } from '@google/gemini-cli-core'; + +import * as fsPromises from 'fs/promises'; +import { chatCommand } from './chatCommand.js'; +import { Stats } from 'fs'; +import { HistoryItemWithoutId } from '../types.js'; + +vi.mock('fs/promises', () => ({ + stat: vi.fn(), + readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]), +})); + +describe('chatCommand', () => { + const mockFs = fsPromises as Mocked<typeof fsPromises>; + + let mockContext: CommandContext; + let mockGetChat: ReturnType<typeof vi.fn>; + let mockSaveCheckpoint: ReturnType<typeof vi.fn>; + let mockLoadCheckpoint: ReturnType<typeof vi.fn>; + let mockGetHistory: ReturnType<typeof vi.fn>; + + const getSubCommand = (name: 'list' | 'save' | 'resume'): SlashCommand => { + const subCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === name, + ); + if (!subCommand) { + throw new Error(`/memory ${name} command not found.`); + } + return subCommand; + }; + + beforeEach(() => { + mockGetHistory = vi.fn().mockReturnValue([]); + mockGetChat = vi.fn().mockResolvedValue({ + getHistory: mockGetHistory, + }); + mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined); + mockLoadCheckpoint = vi.fn().mockResolvedValue([]); + + mockContext = createMockCommandContext({ + services: { + config: { + getProjectTempDir: () => '/tmp/gemini', + getGeminiClient: () => + ({ + getChat: mockGetChat, + }) as unknown as GeminiClient, + }, + logger: { + saveCheckpoint: mockSaveCheckpoint, + loadCheckpoint: mockLoadCheckpoint, + initialize: vi.fn().mockResolvedValue(undefined), + }, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the correct main command definition', () => { + expect(chatCommand.name).toBe('chat'); + expect(chatCommand.description).toBe('Manage conversation history.'); + expect(chatCommand.subCommands).toHaveLength(3); + }); + + describe('list subcommand', () => { + let listCommand: SlashCommand; + + beforeEach(() => { + listCommand = getSubCommand('list'); + }); + + it('should inform when no checkpoints are found', async () => { + mockFs.readdir.mockImplementation( + (async (_: string): Promise<string[]> => + [] as string[]) as unknown as typeof fsPromises.readdir, + ); + const result = await listCommand?.action?.(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No saved conversation checkpoints found.', + }); + }); + + it('should list found checkpoints', async () => { + const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; + const date = new Date(); + + mockFs.readdir.mockImplementation( + (async (_: string): Promise<string[]> => + fakeFiles as string[]) as unknown as typeof fsPromises.readdir, + ); + mockFs.stat.mockImplementation((async (path: string): Promise<Stats> => { + if (path.endsWith('test1.json')) { + return { mtime: date } as Stats; + } + return { mtime: new Date(date.getTime() + 1000) } as Stats; + }) as unknown as typeof fsPromises.stat); + + const result = (await listCommand?.action?.( + mockContext, + '', + )) as MessageActionReturn; + + const content = result?.content ?? ''; + expect(result?.type).toBe('message'); + expect(content).toContain('List of saved conversations:'); + const index1 = content.indexOf('- \u001b[36mtest1\u001b[0m'); + const index2 = content.indexOf('- \u001b[36mtest2\u001b[0m'); + expect(index1).toBeGreaterThanOrEqual(0); + expect(index2).toBeGreaterThan(index1); + }); + }); + describe('save subcommand', () => { + let saveCommand: SlashCommand; + const tag = 'my-tag'; + beforeEach(() => { + saveCommand = getSubCommand('save'); + }); + + it('should return an error if tag is missing', async () => { + const result = await saveCommand?.action?.(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /chat save <tag>', + }); + }); + + it('should inform if conversation history is empty', async () => { + mockGetHistory.mockReturnValue([]); + const result = await saveCommand?.action?.(mockContext, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No conversation found to save.', + }); + }); + + it('should save the conversation', async () => { + const history: HistoryItemWithoutId[] = [ + { + type: 'user', + text: 'hello', + }, + ]; + mockGetHistory.mockReturnValue(history); + const result = await saveCommand?.action?.(mockContext, tag); + + expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation checkpoint saved with tag: ${tag}.`, + }); + }); + }); + + describe('resume subcommand', () => { + const goodTag = 'good-tag'; + const badTag = 'bad-tag'; + + let resumeCommand: SlashCommand; + beforeEach(() => { + resumeCommand = getSubCommand('resume'); + }); + + it('should return an error if tag is missing', async () => { + const result = await resumeCommand?.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /chat resume <tag>', + }); + }); + + it('should inform if checkpoint is not found', async () => { + mockLoadCheckpoint.mockResolvedValue([]); + + const result = await resumeCommand?.action?.(mockContext, badTag); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `No saved checkpoint found with tag: ${badTag}.`, + }); + }); + + it('should resume a conversation', async () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'hello gemini' }] }, + { role: 'model', parts: [{ text: 'hello world' }] }, + ]; + mockLoadCheckpoint.mockResolvedValue(conversation); + + const result = await resumeCommand?.action?.(mockContext, goodTag); + + expect(result).toEqual({ + type: 'load_history', + history: [ + { type: 'user', text: 'hello gemini' }, + { type: 'gemini', text: 'hello world' }, + ] as HistoryItemWithoutId[], + clientHistory: conversation, + }); + }); + + describe('completion', () => { + it('should provide completion suggestions', async () => { + const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; + mockFs.readdir.mockImplementation( + (async (_: string): Promise<string[]> => + fakeFiles as string[]) as unknown as typeof fsPromises.readdir, + ); + + mockFs.stat.mockImplementation( + (async (_: string): Promise<Stats> => + ({ + mtime: new Date(), + }) as Stats) as unknown as typeof fsPromises.stat, + ); + + const result = await resumeCommand?.completion?.(mockContext, 'a'); + + expect(result).toEqual(['alpha']); + }); + + it('should suggest filenames sorted by modified time (newest first)', async () => { + const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; + const date = new Date(); + mockFs.readdir.mockImplementation( + (async (_: string): Promise<string[]> => + fakeFiles as string[]) as unknown as typeof fsPromises.readdir, + ); + mockFs.stat.mockImplementation((async ( + path: string, + ): Promise<Stats> => { + if (path.endsWith('test1.json')) { + return { mtime: date } as Stats; + } + return { mtime: new Date(date.getTime() + 1000) } as Stats; + }) as unknown as typeof fsPromises.stat); + + const result = await resumeCommand?.completion?.(mockContext, ''); + // Sort items by last modified time (newest first) + expect(result).toEqual(['test2', 'test1']); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts new file mode 100644 index 00000000..fd56afbd --- /dev/null +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fsPromises from 'fs/promises'; +import { CommandContext, SlashCommand, MessageActionReturn } from './types.js'; +import path from 'path'; +import { HistoryItemWithoutId, MessageType } from '../types.js'; + +interface ChatDetail { + name: string; + mtime: Date; +} + +const getSavedChatTags = async ( + context: CommandContext, + mtSortDesc: boolean, +): Promise<ChatDetail[]> => { + const geminiDir = context.services.config?.getProjectTempDir(); + if (!geminiDir) { + return []; + } + try { + const file_head = 'checkpoint-'; + const file_tail = '.json'; + const files = await fsPromises.readdir(geminiDir); + const chatDetails: Array<{ name: string; mtime: Date }> = []; + + 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); + chatDetails.push({ + name: file.slice(file_head.length, -file_tail.length), + mtime: stats.mtime, + }); + } + } + + chatDetails.sort((a, b) => + mtSortDesc + ? b.mtime.getTime() - a.mtime.getTime() + : a.mtime.getTime() - b.mtime.getTime(), + ); + + return chatDetails; + } catch (_err) { + return []; + } +}; + +const listCommand: SlashCommand = { + name: 'list', + description: 'List saved conversation checkpoints', + action: async (context): Promise<MessageActionReturn> => { + const chatDetails = await getSavedChatTags(context, false); + if (chatDetails.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No saved conversation checkpoints found.', + }; + } + + let message = 'List of saved conversations:\n\n'; + for (const chat of chatDetails) { + message += ` - \u001b[36m${chat.name}\u001b[0m\n`; + } + message += `\n\u001b[90mNote: Newest last, oldest first\u001b[0m`; + return { + type: 'message', + messageType: 'info', + content: message, + }; + }, +}; + +const saveCommand: SlashCommand = { + name: 'save', + description: + 'Save the current conversation as a checkpoint. Usage: /chat save <tag>', + action: async (context, args): Promise<MessageActionReturn> => { + 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(); + 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 > 0) { + await logger.saveCheckpoint(history, tag); + return { + type: 'message', + messageType: 'info', + content: `Conversation checkpoint saved with tag: ${tag}.`, + }; + } else { + return { + type: 'message', + messageType: 'info', + content: 'No conversation found to save.', + }; + } + }, +}; + +const resumeCommand: SlashCommand = { + name: 'resume', + altName: 'load', + description: + 'Resume a conversation from a checkpoint. Usage: /chat resume <tag>', + 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: ${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)); + }, +}; + +export const chatCommand: SlashCommand = { + name: 'chat', + description: 'Manage conversation history.', + subCommands: [listCommand, saveCommand, resumeCommand], +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 68b22543..27db2be2 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Content } from '@google/genai'; +import { HistoryItemWithoutId } from '../types.js'; import { Config, GitService, Logger } from '@google/gemini-cli-core'; import { LoadedSettings } from '../../config/settings.js'; import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -69,10 +71,21 @@ export interface OpenDialogActionReturn { dialog: 'help' | 'auth' | 'theme' | 'privacy'; } +/** + * The return type for a command action that results in replacing + * the entire conversation history. + */ +export interface LoadHistoryActionReturn { + type: 'load_history'; + history: HistoryItemWithoutId[]; + clientHistory: Content[]; // The history for the generative client +} + export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn - | OpenDialogActionReturn; + | OpenDialogActionReturn + | LoadHistoryActionReturn; // The standardized contract for any command in the system. export interface SlashCommand { name: string; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 139de06e..181c4980 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -198,23 +198,6 @@ export const useSlashCommandProcessor = ( load(); }, [commandService]); - const savedChatTags = useCallback(async () => { - const geminiDir = config?.getProjectTempDir(); - if (!geminiDir) { - return []; - } - try { - const files = await fs.readdir(geminiDir); - return files - .filter( - (file) => file.startsWith('checkpoint-') && file.endsWith('.json'), - ) - .map((file) => file.replace('checkpoint-', '').replace('.json', '')); - } catch (_err) { - return []; - } - }, [config]); - // Define legacy commands // This list contains all commands that have NOT YET been migrated to the // new system. As commands are migrated, they are removed from this list. @@ -588,142 +571,7 @@ export const useSlashCommandProcessor = ( })(); }, }, - { - name: 'chat', - description: - 'Manage conversation history. Usage: /chat <list|save|resume> <tag>', - action: async (_mainCommand, subCommand, args) => { - const tag = (args || '').trim(); - const logger = new Logger(config?.getSessionId() || ''); - await logger.initialize(); - const chat = await config?.getGeminiClient()?.getChat(); - if (!chat) { - addMessage({ - type: MessageType.ERROR, - content: 'No chat client available for conversation status.', - timestamp: new Date(), - }); - return; - } - if (!subCommand) { - addMessage({ - type: MessageType.ERROR, - content: 'Missing command\nUsage: /chat <list|save|resume> <tag>', - timestamp: new Date(), - }); - return; - } - switch (subCommand) { - case 'save': { - if (!tag) { - addMessage({ - type: MessageType.ERROR, - content: 'Missing tag. Usage: /chat save <tag>', - timestamp: new Date(), - }); - return; - } - const history = chat.getHistory(); - if (history.length > 0) { - await logger.saveCheckpoint(chat?.getHistory() || [], tag); - addMessage({ - type: MessageType.INFO, - content: `Conversation checkpoint saved with tag: ${tag}.`, - timestamp: new Date(), - }); - } else { - addMessage({ - type: MessageType.INFO, - content: 'No conversation found to save.', - timestamp: new Date(), - }); - } - return; - } - case 'resume': - case 'restore': - case 'load': { - if (!tag) { - addMessage({ - type: MessageType.ERROR, - content: 'Missing tag. Usage: /chat resume <tag>', - timestamp: new Date(), - }); - return; - } - const conversation = await logger.loadCheckpoint(tag); - if (conversation.length === 0) { - addMessage({ - type: MessageType.INFO, - content: `No saved checkpoint found with tag: ${tag}.`, - timestamp: new Date(), - }); - return; - } - - clearItems(); - chat.clearHistory(); - const rolemap: { [key: string]: MessageType } = { - user: MessageType.USER, - model: MessageType.GEMINI, - }; - let hasSystemPrompt = false; - let i = 0; - for (const item of conversation) { - i += 1; - - // Add each item to history regardless of whether we display - // it. - chat.addHistory(item); - const text = - item.parts - ?.filter((m) => !!m.text) - .map((m) => m.text) - .join('') || ''; - if (!text) { - // Parsing Part[] back to various non-text output not yet implemented. - continue; - } - if (i === 1 && text.match(/context for our chat/)) { - hasSystemPrompt = true; - } - if (i > 2 || !hasSystemPrompt) { - addItem( - { - type: - (item.role && rolemap[item.role]) || MessageType.GEMINI, - text, - } as HistoryItemWithoutId, - i, - ); - } - } - console.clear(); - refreshStatic(); - return; - } - case 'list': - addMessage({ - type: MessageType.INFO, - content: - 'list of saved conversations: ' + - (await savedChatTags()).join(', '), - timestamp: new Date(), - }); - return; - default: - addMessage({ - type: MessageType.ERROR, - content: `Unknown /chat command: ${subCommand}. Available: list, save, resume`, - timestamp: new Date(), - }); - return; - } - }, - completion: async () => - (await savedChatTags()).map((tag) => 'resume ' + tag), - }, { name: 'quit', altName: 'exit', @@ -932,18 +780,14 @@ export const useSlashCommandProcessor = ( addMessage, openEditorDialog, toggleCorgiMode, - savedChatTags, config, showToolDescriptions, session, gitService, loadHistory, - addItem, setQuittingMessages, pendingCompressionItemRef, setPendingCompressionItem, - clearItems, - refreshStatic, ]); const handleSlashCommand = useCallback( @@ -1041,6 +885,16 @@ export const useSlashCommandProcessor = ( ); } } + case 'load_history': { + await config + ?.getGeminiClient() + ?.setHistory(result.clientHistory); + commandContext.ui.clear(); + result.history.forEach((item, index) => { + commandContext.ui.addItem(item, index); + }); + return { type: 'handled' }; + } default: { const unhandled: never = result; throw new Error(`Unhandled slash command result: ${unhandled}`); @@ -1109,6 +963,7 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; }, [ + config, addItem, setShowHelp, openAuthDialog, |
