mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
refactor: Optimize the display information of "/chat list" and "/chat resume" (#2857)
Co-authored-by: Ben Guo <hundunben@gmail.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { type SlashCommand } from '../ui/commands/types.js';
|
|||||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||||
|
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||||
import { authCommand } from '../ui/commands/authCommand.js';
|
import { authCommand } from '../ui/commands/authCommand.js';
|
||||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||||
@@ -47,6 +48,8 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('CommandService', () => {
|
describe('CommandService', () => {
|
||||||
|
const subCommandLen = 10;
|
||||||
|
|
||||||
describe('when using default production loader', () => {
|
describe('when using default production loader', () => {
|
||||||
let commandService: CommandService;
|
let commandService: CommandService;
|
||||||
|
|
||||||
@@ -70,13 +73,14 @@ describe('CommandService', () => {
|
|||||||
const tree = commandService.getCommands();
|
const tree = commandService.getCommands();
|
||||||
|
|
||||||
// Post-condition assertions
|
// Post-condition assertions
|
||||||
expect(tree.length).toBe(9);
|
expect(tree.length).toBe(subCommandLen);
|
||||||
|
|
||||||
const commandNames = tree.map((cmd) => cmd.name);
|
const commandNames = tree.map((cmd) => cmd.name);
|
||||||
expect(commandNames).toContain('auth');
|
expect(commandNames).toContain('auth');
|
||||||
expect(commandNames).toContain('memory');
|
expect(commandNames).toContain('memory');
|
||||||
expect(commandNames).toContain('help');
|
expect(commandNames).toContain('help');
|
||||||
expect(commandNames).toContain('clear');
|
expect(commandNames).toContain('clear');
|
||||||
|
expect(commandNames).toContain('chat');
|
||||||
expect(commandNames).toContain('theme');
|
expect(commandNames).toContain('theme');
|
||||||
expect(commandNames).toContain('stats');
|
expect(commandNames).toContain('stats');
|
||||||
expect(commandNames).toContain('privacy');
|
expect(commandNames).toContain('privacy');
|
||||||
@@ -87,14 +91,14 @@ describe('CommandService', () => {
|
|||||||
it('should overwrite any existing commands when called again', async () => {
|
it('should overwrite any existing commands when called again', async () => {
|
||||||
// Load once
|
// Load once
|
||||||
await commandService.loadCommands();
|
await commandService.loadCommands();
|
||||||
expect(commandService.getCommands().length).toBe(9);
|
expect(commandService.getCommands().length).toBe(subCommandLen);
|
||||||
|
|
||||||
// Load again
|
// Load again
|
||||||
await commandService.loadCommands();
|
await commandService.loadCommands();
|
||||||
const tree = commandService.getCommands();
|
const tree = commandService.getCommands();
|
||||||
|
|
||||||
// Should not append, but overwrite
|
// Should not append, but overwrite
|
||||||
expect(tree.length).toBe(9);
|
expect(tree.length).toBe(subCommandLen);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,10 +110,11 @@ describe('CommandService', () => {
|
|||||||
await commandService.loadCommands();
|
await commandService.loadCommands();
|
||||||
|
|
||||||
const loadedTree = commandService.getCommands();
|
const loadedTree = commandService.getCommands();
|
||||||
expect(loadedTree.length).toBe(9);
|
expect(loadedTree.length).toBe(subCommandLen);
|
||||||
expect(loadedTree).toEqual([
|
expect(loadedTree).toEqual([
|
||||||
aboutCommand,
|
aboutCommand,
|
||||||
authCommand,
|
authCommand,
|
||||||
|
chatCommand,
|
||||||
clearCommand,
|
clearCommand,
|
||||||
extensionsCommand,
|
extensionsCommand,
|
||||||
helpCommand,
|
helpCommand,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { helpCommand } from '../ui/commands/helpCommand.js';
|
|||||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||||
import { authCommand } from '../ui/commands/authCommand.js';
|
import { authCommand } from '../ui/commands/authCommand.js';
|
||||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||||
|
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||||
@@ -18,6 +19,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
|||||||
const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
|
const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
|
||||||
aboutCommand,
|
aboutCommand,
|
||||||
authCommand,
|
authCommand,
|
||||||
|
chatCommand,
|
||||||
clearCommand,
|
clearCommand,
|
||||||
extensionsCommand,
|
extensionsCommand,
|
||||||
helpCommand,
|
helpCommand,
|
||||||
|
|||||||
277
packages/cli/src/ui/commands/chatCommand.test.ts
Normal file
277
packages/cli/src/ui/commands/chatCommand.test.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
197
packages/cli/src/ui/commands/chatCommand.ts
Normal file
197
packages/cli/src/ui/commands/chatCommand.ts
Normal file
@@ -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],
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { Config, GitService, Logger } from '@google/gemini-cli-core';
|
||||||
import { LoadedSettings } from '../../config/settings.js';
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
@@ -69,10 +71,21 @@ export interface OpenDialogActionReturn {
|
|||||||
dialog: 'help' | 'auth' | 'theme' | 'privacy';
|
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 =
|
export type SlashCommandActionReturn =
|
||||||
| ToolActionReturn
|
| ToolActionReturn
|
||||||
| MessageActionReturn
|
| MessageActionReturn
|
||||||
| OpenDialogActionReturn;
|
| OpenDialogActionReturn
|
||||||
|
| LoadHistoryActionReturn;
|
||||||
// The standardized contract for any command in the system.
|
// The standardized contract for any command in the system.
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -198,23 +198,6 @@ export const useSlashCommandProcessor = (
|
|||||||
load();
|
load();
|
||||||
}, [commandService]);
|
}, [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
|
// Define legacy commands
|
||||||
// This list contains all commands that have NOT YET been migrated to the
|
// 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.
|
// 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',
|
name: 'quit',
|
||||||
altName: 'exit',
|
altName: 'exit',
|
||||||
@@ -932,18 +780,14 @@ export const useSlashCommandProcessor = (
|
|||||||
addMessage,
|
addMessage,
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
toggleCorgiMode,
|
toggleCorgiMode,
|
||||||
savedChatTags,
|
|
||||||
config,
|
config,
|
||||||
showToolDescriptions,
|
showToolDescriptions,
|
||||||
session,
|
session,
|
||||||
gitService,
|
gitService,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
addItem,
|
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
pendingCompressionItemRef,
|
pendingCompressionItemRef,
|
||||||
setPendingCompressionItem,
|
setPendingCompressionItem,
|
||||||
clearItems,
|
|
||||||
refreshStatic,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSlashCommand = useCallback(
|
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: {
|
default: {
|
||||||
const unhandled: never = result;
|
const unhandled: never = result;
|
||||||
throw new Error(`Unhandled slash command result: ${unhandled}`);
|
throw new Error(`Unhandled slash command result: ${unhandled}`);
|
||||||
@@ -1109,6 +963,7 @@ export const useSlashCommandProcessor = (
|
|||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
config,
|
||||||
addItem,
|
addItem,
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
|
|||||||
Reference in New Issue
Block a user