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:
Ben Guo 2025-07-16 08:47:56 +08:00 committed by GitHub
parent 1d67b41ccd
commit e88b9362dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 510 additions and 161 deletions

View File

@ -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,

View File

@ -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,

View 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']);
});
});
});
});

View 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],
};

View File

@ -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;

View File

@ -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,