Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin 2025-12-03 18:04:48 +08:00 committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 9257 additions and 4039 deletions

View file

@ -40,6 +40,7 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import process from 'node:process';
@ -196,7 +197,6 @@ export const AppContainer = (props: AppContainerProps) => {
const [isConfigInitialized, setConfigInitialized] = useState(false);
const logger = useLogger(config.storage);
const [userMessages, setUserMessages] = useState<string[]>([]);
// Terminal and layout hooks
@ -206,6 +206,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
const logger = useLogger(config.storage, sessionStats.sessionId);
const branchName = useGitBranchName(config.getTargetDir());
// Layout measurements
@ -216,17 +217,28 @@ export const AppContainer = (props: AppContainerProps) => {
const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3;
// Initialize config (runs once on mount)
useEffect(() => {
(async () => {
// Note: the program will not work if this fails so let errors be
// handled by the global catch.
await config.initialize();
setConfigInitialized(true);
const resumedSessionData = config.getResumedSessionData();
if (resumedSessionData) {
const historyItems = buildResumedHistoryItems(
resumedSessionData,
config,
);
historyManager.loadHistory(historyItems);
}
})();
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useEffect(
@ -522,6 +534,7 @@ export const AppContainer = (props: AppContainerProps) => {
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
logger,
);
// Vision switch handlers

View file

@ -1,701 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mocked } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type {
MessageActionReturn,
SlashCommand,
CommandContext,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
}));
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 mockDeleteCheckpoint: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/chat ${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([]);
mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
mockContext = createMockCommandContext({
services: {
config: {
getProjectRoot: () => '/project/root',
getGeminiClient: () =>
({
getChat: mockGetChat,
}) as unknown as GeminiClient,
storage: {
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
},
},
logger: {
saveCheckpoint: mockSaveCheckpoint,
loadCheckpoint: mockLoadCheckpoint,
deleteCheckpoint: mockDeleteCheckpoint,
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(5);
});
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 isoDate = date
.toISOString()
.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : '';
expect(content).toContain(formattedDate);
const index1 = content.indexOf('- test1');
const index2 = content.indexOf('- test2');
expect(index1).toBeGreaterThanOrEqual(0);
expect(index2).toBeGreaterThan(index1);
});
it('should handle invalid date formats gracefully', async () => {
const fakeFiles = ['checkpoint-baddate.json'];
const badDate = {
toISOString: () => 'an-invalid-date-string',
} as Date;
mockFs.readdir.mockResolvedValue(fakeFiles);
mockFs.stat.mockResolvedValue({ mtime: badDate } as Stats);
const result = (await listCommand?.action?.(
mockContext,
'',
)) as MessageActionReturn;
const content = result?.content ?? '';
expect(content).toContain('(saved on Invalid Date)');
});
});
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
});
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 or only contains system context', async () => {
mockGetHistory.mockReturnValue([]);
let result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
});
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
]);
result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
});
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'Hello, how are you?' }] },
]);
result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
it('should return confirm_action if checkpoint already exists', async () => {
mockCheckpointExists.mockResolvedValue(true);
mockContext.invocation = {
raw: `/chat save ${tag}`,
name: 'save',
args: tag,
};
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'confirm_action',
originalInvocation: { raw: `/chat save ${tag}` },
});
// Check that prompt is a React element
expect(result).toHaveProperty('prompt');
});
it('should save the conversation if overwrite is confirmed', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
mockGetHistory.mockReturnValue(history);
mockContext.overwriteConfirmed = true;
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
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']);
});
});
});
describe('delete subcommand', () => {
let deleteCommand: SlashCommand;
const tag = 'my-tag';
beforeEach(() => {
deleteCommand = getSubCommand('delete');
});
it('should return an error if tag is missing', async () => {
const result = await deleteCommand?.action?.(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
});
});
it('should return an error if checkpoint is not found', async () => {
mockDeleteCheckpoint.mockResolvedValue(false);
const result = await deleteCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
});
});
it('should delete the conversation', async () => {
const result = await deleteCommand?.action?.(mockContext, tag);
expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
});
});
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 deleteCommand?.completion?.(mockContext, 'a');
expect(result).toEqual(['alpha']);
});
});
});
describe('share subcommand', () => {
let shareCommand: SlashCommand;
const mockHistory = [
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
beforeEach(() => {
shareCommand = getSubCommand('share');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
);
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockGetHistory.mockReturnValue(mockHistory);
mockFs.writeFile.mockClear();
});
it('should default to a json file if no path is provided', async () => {
const result = await shareCommand?.action?.(mockContext, '');
const expectedPath = path.join(
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a JSON file', async () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a Markdown file', async () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `🧑‍💻 ## USER
context
---
## MODEL
context response
---
🧑💻 ## USER
Hello
---
## MODEL
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
});
});
it('should inform if there is no conversation to share', async () => {
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
});
});
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${error.message}`,
});
});
it('should output valid JSON schema', async () => {
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
});
});
it('should output correct markdown format', async () => {
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = actualContent.split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry, index) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`${roleIcon} ## ${role.toUpperCase()}\n\n${text}`);
});
});
});
describe('serializeHistoryToMarkdown', () => {
it('should correctly serialize chat history to Markdown with icons', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'🧑‍💻 ## USER\n\nHello\n\n---\n\n' +
'✨ ## MODEL\n\nHi there!\n\n---\n\n' +
'🧑‍💻 ## USER\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle empty history', () => {
const history: Content[] = [];
const result = serializeHistoryToMarkdown(history);
expect(result).toBe('');
});
it('should handle items with no text parts', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
## MODEL
---
🧑💻 ## USER
How are you?`;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should correctly serialize function calls and responses', () => {
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Please call a function.' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'my-function',
args: { arg1: 'value1' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'my-function',
response: { result: 'success' },
},
},
],
},
];
const expectedMarkdown = `🧑‍💻 ## USER
Please call a function.
---
## MODEL
**Tool Command**:
\`\`\`json
{
"name": "my-function",
"args": {
"arg1": "value1"
}
}
\`\`\`
---
🧑💻 ## USER
**Tool Response**:
\`\`\`json
{
"name": "my-function",
"response": {
"result": "success"
}
}
\`\`\``;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle items with undefined role', () => {
const history: Array<Partial<Content>> = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ parts: [{ text: 'Hi there!' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
## MODEL
Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
});
});

View file

@ -1,419 +0,0 @@
/**
* @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 type {
CommandContext,
SlashCommand,
MessageActionReturn,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { decodeTagName } from '@qwen-code/qwen-code-core';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
import { t } from '../../i18n/index.js';
interface ChatDetail {
name: string;
mtime: Date;
}
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: 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);
const tagName = file.slice(file_head.length, -file_tail.length);
chatDetails.push({
name: decodeTagName(tagName),
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',
get description() {
return t('List saved conversation checkpoints');
},
kind: CommandKind.BUILT_IN,
action: async (context): Promise<MessageActionReturn> => {
const chatDetails = await getSavedChatTags(context, false);
if (chatDetails.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t('No saved conversation checkpoints found.'),
};
}
const maxNameLength = Math.max(
...chatDetails.map((chat) => chat.name.length),
);
let message = t('List of saved conversations:') + '\n\n';
for (const chat of chatDetails) {
const paddedName = chat.name.padEnd(maxNameLength, ' ');
const isoString = chat.mtime.toISOString();
const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
}
message += `\n${t('Note: Newest last, oldest first')}`;
return {
type: 'message',
messageType: 'info',
content: message,
};
},
};
const saveCommand: SlashCommand = {
name: 'save',
get description() {
return t(
'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: t('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,
t(
'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?',
{
tag,
},
),
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
},
};
}
}
const chat = await config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: t('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: t('Conversation checkpoint saved with tag: {{tag}}.', {
tag: decodeTagName(tag),
}),
};
} else {
return {
type: 'message',
messageType: 'info',
content: t('No conversation found to save.'),
};
}
},
};
const resumeCommand: SlashCommand = {
name: 'resume',
altNames: ['load'],
get description() {
return t(
'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: t('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: t('No saved checkpoint found with tag: {{tag}}.', {
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',
get description() {
return t('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: t('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: t("Conversation checkpoint '{{tag}}' has been deleted.", {
tag: decodeTagName(tag),
}),
};
} else {
return {
type: 'message',
messageType: 'error',
content: t("Error: No checkpoint found with tag '{{tag}}'.", {
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',
get description() {
return t(
'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: t('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: t('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: t('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: t('Conversation shared to {{filePath}}', {
filePath,
}),
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: t('Error sharing conversation: {{error}}', {
error: errorMessage,
}),
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
get description() {
return t('Manage conversation history.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
listCommand,
saveCommand,
resumeCommand,
deleteCommand,
shareCommand,
],
};

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
@ -16,20 +15,21 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
return {
...actual,
uiTelemetryService: {
setLastPromptTokenCount: vi.fn(),
reset: vi.fn(),
},
};
});
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockStartNewSession: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
vi.clearAllMocks();
mockContext = createMockCommandContext({
@ -39,12 +39,16 @@ describe('clearCommand', () => {
({
resetChat: mockResetChat,
}) as unknown as GeminiClient,
startNewSession: mockStartNewSession,
},
},
session: {
startNewSession: vi.fn(),
},
});
});
it('should set debug message, reset chat, reset telemetry, and clear UI when config is available', async () => {
it('should set debug message, start a new session, reset chat, and clear UI when config is available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
@ -52,28 +56,23 @@ describe('clearCommand', () => {
await clearCommand.action(mockContext, '');
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal and resetting chat.',
'Starting a new session, resetting chat, and clearing terminal.',
);
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
expect(mockContext.session.startNewSession).toHaveBeenCalledWith(
'new-session-id',
);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
// Check the order of operations.
const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
.invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const resetTelemetryOrder = (
uiTelemetryService.setLastPromptTokenCount as Mock
).mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0];
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
expect(resetChatOrder).toBeLessThan(resetTelemetryOrder);
expect(resetTelemetryOrder).toBeLessThan(clearOrder);
// Check that all expected operations were called
expect(mockContext.ui.setDebugMessage).toHaveBeenCalled();
expect(mockStartNewSession).toHaveBeenCalled();
expect(mockContext.session.startNewSession).toHaveBeenCalled();
expect(mockResetChat).toHaveBeenCalled();
expect(mockContext.ui.clear).toHaveBeenCalled();
});
it('should not attempt to reset chat if config service is not available', async () => {
@ -85,16 +84,17 @@ describe('clearCommand', () => {
services: {
config: null,
},
session: {
startNewSession: vi.fn(),
},
});
await clearCommand.action(nullConfigContext, '');
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal.',
'Starting a new session and clearing.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
});
});

View file

@ -4,30 +4,46 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
export const clearCommand: SlashCommand = {
name: 'clear',
altNames: ['reset', 'new'],
get description() {
return t('clear the screen and conversation history');
return t('Clear conversation history and free up context');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient();
const { config } = context.services;
if (geminiClient) {
context.ui.setDebugMessage(t('Clearing terminal and resetting chat.'));
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
if (config) {
const newSessionId = config.startNewSession();
// Reset UI telemetry metrics for the new session
uiTelemetryService.reset();
if (newSessionId && context.session.startNewSession) {
context.session.startNewSession(newSessionId);
}
const geminiClient = config.getGeminiClient();
if (geminiClient) {
context.ui.setDebugMessage(
t('Starting a new session, resetting chat, and clearing terminal.'),
);
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
} else {
context.ui.setDebugMessage(t('Clearing terminal.'));
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
},
};

View file

@ -37,7 +37,7 @@ export interface CommandContext {
config: Config | null;
settings: LoadedSettings;
git: GitService | undefined;
logger: Logger;
logger: Logger | null;
};
// UI state and history management
ui: {
@ -78,6 +78,8 @@ export interface CommandContext {
stats: SessionStatsState;
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
/** Reset session metrics and prompt counters for a fresh session. */
startNewSession?: (sessionId: string) => void;
};
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
@ -214,7 +216,7 @@ export interface SlashCommand {
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>;
// Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
// Provides argument completion
completion?: (
context: CommandContext,
partialArg: string,

View file

@ -135,8 +135,6 @@ export const DialogManager = ({
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
} else if (choice === QuitChoice.QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'save_and_quit');
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(
true,

View file

@ -17,6 +17,7 @@ const mockCommands: readonly SlashCommand[] = [
name: 'test',
description: 'A test command',
kind: CommandKind.BUILT_IN,
altNames: ['alias-one', 'alias-two'],
},
{
name: 'hidden',
@ -60,4 +61,11 @@ describe('Help Component', () => {
expect(output).toContain('visible-child');
expect(output).not.toContain('hidden-child');
});
it('should render alt names for commands when available', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('/test (alias-one, alias-two)');
});
});

View file

@ -67,7 +67,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
/{command.name}
{formatCommandLabel(command, '/')}
</Text>
{command.kind === CommandKind.MCP_PROMPT && (
<Text color={theme.text.secondary}> [MCP]</Text>
@ -81,7 +81,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text key={subCommand.name} color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
{subCommand.name}
{formatCommandLabel(subCommand)}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
@ -171,3 +171,17 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
</Box>
);
/**
* Builds a display label for a slash command, including any alternate names.
*/
function formatCommandLabel(command: SlashCommand, prefix = ''): string {
const altNames = command.altNames?.filter(Boolean);
const baseLabel = `${prefix}${command.name}`;
if (!altNames || altNames.length === 0) {
return baseLabel;
}
return `${baseLabel} (${altNames.join(', ')})`;
}

View file

@ -66,20 +66,6 @@ const mockSlashCommands: SlashCommand[] = [
},
],
},
{
name: 'chat',
description: 'Manage chats',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
];
describe('InputPrompt', () => {
@ -571,14 +557,14 @@ describe('InputPrompt', () => {
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
// SCENARIO: /memory add fi- -> Tab
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/chat resume fi-');
props.buffer.setText('/memory add fi-');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();

View file

@ -17,7 +17,6 @@ import { t } from '../../i18n/index.js';
export enum QuitChoice {
CANCEL = 'cancel',
QUIT = 'quit',
SAVE_AND_QUIT = 'save_and_quit',
SUMMARY_AND_QUIT = 'summary_and_quit',
}
@ -48,11 +47,6 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
label: t('Generate summary and quit (/summary)'),
value: QuitChoice.SUMMARY_AND_QUIT,
},
{
key: 'save-and-quit',
label: t('Save conversation and quit (/chat save)'),
value: QuitChoice.SAVE_AND_QUIT,
},
{
key: 'cancel',
label: t('Cancel (stay in application)'),

View file

@ -0,0 +1,436 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import {
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
const PAGE_SIZE = 20;
interface SessionPickerProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
}
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
if (maxWidth <= 3) return text.slice(0, maxWidth);
return text.slice(0, maxWidth - 3) + '...';
}
function SessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
}: SessionPickerProps): React.JSX.Element {
const { exit } = useApp();
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<{
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const isLoadingMoreRef = useRef(false);
const [filterByBranch, setFilterByBranch] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Update terminal size on resize
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Filter sessions by current branch if filter is enabled
const filteredSessions =
filterByBranch && currentBranch
? sessionState.sessions.filter(
(session) => session.gitBranch === currentBranch,
)
: sessionState.sessions;
const hasSentinel = sessionState.hasMore;
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
}, [filterByBranch]);
const loadMoreSessions = useCallback(async () => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Calculate visible items
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
// On average, this is ~3 lines per item, but the last item has no margin
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
// Calculate scroll offset
const scrollOffset = (() => {
if (filteredSessions.length <= maxVisibleItems) return 0;
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
})();
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Sentinel (invisible) sits after the last session item; consider it visible
// once the viewport reaches the final real item.
const sentinelVisible =
hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length;
// Load more when sentinel enters view or when filtered list is empty.
useEffect(() => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
const shouldLoadMore =
filteredSessions.length === 0 ||
sentinelVisible ||
isLoadingMoreRef.current;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
filteredSessions.length,
loadMoreSessions,
sessionState.hasMore,
sentinelVisible,
]);
// Handle keyboard input
useInput((input, key) => {
// Ignore input if already exiting
if (isExiting) {
return;
}
// Escape or Ctrl+C to cancel
if (key.escape || (key.ctrl && input === 'c')) {
setIsExiting(true);
onCancel();
exit();
return;
}
if (key.return) {
const session = filteredSessions[selectedIndex];
if (session) {
setIsExiting(true);
onSelect(session.sessionId);
exit();
}
return;
}
if (key.upArrow || input === 'k') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow || input === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) =>
Math.min(filteredSessions.length - 1, prev + 1),
);
return;
}
if (input === 'b' || input === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
});
// Filtered sessions may have changed, ensure selectedIndex is valid
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4; // Account for " " prefix
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<Box
flexDirection="column"
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Main container with single border */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
Resume Session
</Text>
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleSessions.length - 1;
const timeAgo = formatRelativeTime(session.mtime);
const messageText =
session.messageCount === 1
? '1 message'
: `${session.messageCount} messages`;
// Show scroll indicator on first/last visible items
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
// Determine the prefix: selector takes priority over scroll indicator
const prefix = isSelected
? ' '
: showUpIndicator
? '↑ '
: showDownIndicator
? '↓ '
: ' ';
return (
<Box
key={session.sessionId}
flexDirection="column"
marginBottom={isLast ? 0 : 1}
>
{/* First line: prefix (selector or scroll indicator) + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
>
{prefix}
</Text>
<Text
bold={isSelected}
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{truncateText(
session.prompt || '(empty prompt)',
promptMaxWidth,
)}
</Text>
</Box>
{/* Second line: metadata (aligned with prompt text) */}
<Box>
<Text>{' '}</Text>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
})
)}
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Footer with keyboard shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text
bold={filterByBranch}
color={filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{' to toggle branch · '}
</>
)}
{'↑↓ to navigate · Esc to cancel'}
</Text>
</Box>
</Box>
</Box>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
const currentBranch = getGitBranch(cwd);
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<SessionPicker
sessionService={sessionService}
currentBranch={currentBranch}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
/>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}

View file

@ -78,10 +78,11 @@ export function SuggestionsDisplay({
const isActive = originalIndex === activeIndex;
const isExpanded = originalIndex === expandedIndex;
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const displayLabel = suggestion.label ?? suggestion.value;
const isLong = displayLabel.length >= MAX_WIDTH;
const labelElement = (
<PrepareLabel
label={suggestion.value}
label={displayLabel}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
textColor={textColor}

View file

@ -84,6 +84,7 @@ describe('SessionStatsContext', () => {
accept: 1,
reject: 0,
modify: 0,
auto_accept: 0,
},
byName: {
'test-tool': {
@ -95,10 +96,15 @@ describe('SessionStatsContext', () => {
accept: 1,
reject: 0,
modify: 0,
auto_accept: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
act(() => {
@ -152,9 +158,13 @@ describe('SessionStatsContext', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
act(() => {

View file

@ -19,7 +19,7 @@ import type {
ModelMetrics,
ToolCallStats,
} from '@qwen-code/qwen-code-core';
import { uiTelemetryService, sessionId } from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
export enum ToolCallDecision {
ACCEPT = 'accept',
@ -168,6 +168,7 @@ export interface ComputedSessionStats {
// and the functions to update it.
interface SessionStatsContextValue {
stats: SessionStatsState;
startNewSession: (sessionId: string) => void;
startNewPrompt: () => void;
getPromptCount: () => number;
}
@ -178,18 +179,23 @@ const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
undefined,
);
const createDefaultStats = (sessionId: string = ''): SessionStatsState => ({
sessionId,
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 0,
});
// --- Provider Component ---
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [stats, setStats] = useState<SessionStatsState>({
sessionId,
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 0,
});
export const SessionStatsProvider: React.FC<{
sessionId?: string;
children: React.ReactNode;
}> = ({ sessionId, children }) => {
const [stats, setStats] = useState<SessionStatsState>(() =>
createDefaultStats(sessionId ?? ''),
);
useEffect(() => {
const handleUpdate = ({
@ -226,6 +232,13 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
};
}, []);
const startNewSession = useCallback((sessionId: string) => {
setStats(() => ({
...createDefaultStats(sessionId),
lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
}));
}, []);
const startNewPrompt = useCallback(() => {
setStats((prevState) => ({
...prevState,
@ -241,10 +254,11 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
const value = useMemo(
() => ({
stats,
startNewSession,
startNewPrompt,
getPromptCount,
}),
[stats, startNewPrompt, getPromptCount],
[stats, startNewSession, startNewPrompt, getPromptCount],
);
return (

View file

@ -110,6 +110,9 @@ describe('useSlashCommandProcessor', () => {
const mockSetQuittingMessages = vi.fn();
const mockConfig = makeFakeConfig({});
mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
recordSlashCommand: vi.fn(),
});
const mockSettings = {} as LoadedSettings;
beforeEach(() => {
@ -305,11 +308,15 @@ describe('useSlashCommandProcessor', () => {
expect(childAction).toHaveBeenCalledWith(
expect.objectContaining({
invocation: expect.objectContaining({
name: 'child',
args: 'with args',
}),
services: expect.objectContaining({
config: mockConfig,
}),
ui: expect.objectContaining({
addItem: mockAddItem,
addItem: expect.any(Function),
}),
}),
'with args',

View file

@ -6,17 +6,15 @@
import { useCallback, useMemo, useEffect, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
type Logger,
type Config,
GitService,
Logger,
logSlashCommand,
makeSlashCommandEvent,
SlashCommandStatus,
ToolConfirmationOutcome,
Storage,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
@ -41,6 +39,27 @@ import {
type ExtensionUpdateStatus,
} from '../state/extensions.js';
type SerializableHistoryItem = Record<string, unknown>;
function serializeHistoryItemForRecording(
item: Omit<HistoryItem, 'id'>,
): SerializableHistoryItem {
const clone: SerializableHistoryItem = { ...item };
if ('timestamp' in clone && clone['timestamp'] instanceof Date) {
clone['timestamp'] = clone['timestamp'].toISOString();
}
return clone;
}
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'quit',
'quit-confirm',
'exit',
'clear',
'reset',
'new',
]);
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openThemeDialog: () => void;
@ -75,8 +94,9 @@ export const useSlashCommandProcessor = (
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
logger: Logger | null,
) => {
const session = useSessionStats();
const { stats: sessionStats, startNewSession } = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const [reloadTrigger, setReloadTrigger] = useState(0);
@ -110,16 +130,6 @@ export const useSlashCommandProcessor = (
return new GitService(config.getProjectRoot(), config.storage);
}, [config]);
const logger = useMemo(() => {
const l = new Logger(
config?.getSessionId() || '',
config?.storage ?? new Storage(process.cwd()),
);
// The logger's initialize is async, but we can create the instance
// synchronously. Commands that use it will await its initialization.
return l;
}, [config]);
const [pendingItem, setPendingItem] = useState<HistoryItemWithoutId | null>(
null,
);
@ -218,8 +228,9 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
},
session: {
stats: session.stats,
stats: sessionStats,
sessionShellAllowlist,
startNewSession,
},
}),
[
@ -231,7 +242,8 @@ export const useSlashCommandProcessor = (
addItem,
clearItems,
refreshStatic,
session.stats,
sessionStats,
startNewSession,
actions,
pendingItem,
setPendingItem,
@ -302,10 +314,25 @@ export const useSlashCommandProcessor = (
return false;
}
const recordedItems: Array<Omit<HistoryItem, 'id'>> = [];
const recordItem = (item: Omit<HistoryItem, 'id'>) => {
recordedItems.push(item);
};
const addItemWithRecording: UseHistoryManagerReturn['addItem'] = (
item,
timestamp,
) => {
recordItem(item);
return addItem(item, timestamp);
};
setIsProcessing(true);
const userMessageTimestamp = Date.now();
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
addItemWithRecording(
{ type: MessageType.USER, text: trimmed },
userMessageTimestamp,
);
let hasError = false;
const {
@ -324,6 +351,10 @@ export const useSlashCommandProcessor = (
if (commandToExecute.action) {
const fullCommandContext: CommandContext = {
...commandContext,
ui: {
...commandContext.ui,
addItem: addItemWithRecording,
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
@ -428,15 +459,7 @@ export const useSlashCommandProcessor = (
return;
}
if (shouldQuit) {
if (action === 'save_and_quit') {
// First save conversation with auto-generated tag, then quit
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-');
const autoSaveTag = `auto-save chat ${timestamp}`;
handleSlashCommand(`/chat save "${autoSaveTag}"`);
setTimeout(() => handleSlashCommand('/quit'), 100);
} else if (action === 'summary_and_quit') {
if (action === 'summary_and_quit') {
// Generate summary and then quit
handleSlashCommand('/summary')
.then(() => {
@ -447,7 +470,7 @@ export const useSlashCommandProcessor = (
})
.catch((error) => {
// If summary fails, still quit but show error
addItem(
addItemWithRecording(
{
type: 'error',
text: `Failed to generate summary before quit: ${
@ -466,7 +489,7 @@ export const useSlashCommandProcessor = (
} else {
// Just quit immediately - trigger the actual quit action
const now = Date.now();
const { sessionStartTime } = session.stats;
const { sessionStartTime } = sessionStats;
const wallDuration = now - sessionStartTime.getTime();
actions.quit([
@ -550,7 +573,7 @@ export const useSlashCommandProcessor = (
});
if (!confirmed) {
addItem(
addItemWithRecording(
{
type: MessageType.INFO,
text: 'Operation cancelled.',
@ -606,7 +629,7 @@ export const useSlashCommandProcessor = (
});
logSlashCommand(config, event);
}
addItem(
addItemWithRecording(
{
type: MessageType.ERROR,
text: e instanceof Error ? e.message : String(e),
@ -615,6 +638,38 @@ export const useSlashCommandProcessor = (
);
return { type: 'handled' };
} finally {
if (config?.getChatRecordingService) {
const chatRecorder = config.getChatRecordingService();
const primaryCommand =
resolvedCommandPath[0] ||
trimmed.replace(/^[/?]/, '').split(/\s+/)[0] ||
trimmed;
const shouldRecord =
!SLASH_COMMANDS_SKIP_RECORDING.has(primaryCommand);
try {
if (shouldRecord) {
chatRecorder?.recordSlashCommand({
phase: 'invocation',
rawCommand: trimmed,
});
const outputItems = recordedItems
.filter((item) => item.type !== 'user')
.map(serializeHistoryItemForRecording);
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: trimmed,
outputHistoryItems: outputItems,
});
}
} catch (recordError) {
if (config.getDebugMode()) {
console.error(
'[slashCommand] Failed to record slash command:',
recordError,
);
}
}
}
if (config && resolvedCommandPath[0] && !hasError) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
@ -637,7 +692,7 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
session.stats,
sessionStats,
],
);

View file

@ -152,6 +152,9 @@ vi.mock('../contexts/SessionContext.js', () => ({
startNewPrompt: mockStartNewPrompt,
addUsage: mockAddUsage,
getPromptCount: vi.fn(() => 5),
stats: {
sessionId: 'test-session-id',
},
})),
}));
@ -514,6 +517,7 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
);
});
@ -840,6 +844,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
{ isContinuation: true },
);
});
@ -1165,6 +1170,7 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@ -1191,6 +1197,7 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@ -1209,6 +1216,7 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@ -1227,6 +1235,7 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@ -2151,6 +2160,7 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined, // Argument 4: Options (undefined for normal prompts)
);
});
@ -2509,6 +2519,7 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
// Verify only the first query was added to history
@ -2560,12 +2571,14 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
@ -2588,6 +2601,7 @@ describe('useGeminiStream', () => {
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});

View file

@ -124,9 +124,13 @@ export const useGeminiStream = (
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const {
startNewPrompt,
getPromptCount,
stats: sessionStates,
} = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
const logger = useLogger(storage, sessionStates.sessionId);
const gitService = useMemo(() => {
if (!config.getProjectRoot()) {
return;
@ -849,21 +853,24 @@ export const useGeminiStream = (
const finalQueryToSend = queryToSend;
if (!options?.isContinuation) {
// trigger new prompt event for session stats in CLI
startNewPrompt();
// log user prompt event for telemetry, only text prompts for now
if (typeof queryToSend === 'string') {
// logging the text prompts only for now
const promptText = queryToSend;
logUserPrompt(
config,
new UserPromptEvent(
promptText.length,
queryToSend.length,
prompt_id,
config.getContentGeneratorConfig()?.authType,
promptText,
queryToSend,
),
);
}
startNewPrompt();
setThought(null); // Reset thought when starting a new prompt
// Reset thought when starting a new prompt
setThought(null);
}
setIsResponding(true);
@ -874,6 +881,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
options,
);
const processingStatus = await processGeminiStreamEvents(
stream,

View file

@ -6,15 +6,19 @@
import { useState, useEffect } from 'react';
import type { Storage } from '@qwen-code/qwen-code-core';
import { sessionId, Logger } from '@qwen-code/qwen-code-core';
import { Logger } from '@qwen-code/qwen-code-core';
/**
* Hook to manage the logger instance.
*/
export const useLogger = (storage: Storage) => {
export const useLogger = (storage: Storage, sessionId: string) => {
const [logger, setLogger] = useState<Logger | null>(null);
useEffect(() => {
if (!sessionId) {
return;
}
const newLogger = new Logger(sessionId, storage);
/**
* Start async initialization, no need to await. Using await slows down the
@ -27,7 +31,7 @@ export const useLogger = (storage: Storage) => {
setLogger(newLogger);
})
.catch(() => {});
}, [storage]);
}, [storage, sessionId]);
return logger;
};

View file

@ -21,8 +21,6 @@ export const useQuitConfirmation = () => {
return { shouldQuit: false, action: 'cancel' };
} else if (choice === QuitChoice.QUIT) {
return { shouldQuit: true, action: 'quit' };
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
return { shouldQuit: true, action: 'save_and_quit' };
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
return { shouldQuit: true, action: 'summary_and_quit' };
}

View file

@ -133,14 +133,14 @@ export function useReactToolScheduler(
const scheduler = useMemo(
() =>
new CoreToolScheduler({
config,
chatRecordingService: config.getChatRecordingService(),
outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor,
config,
onEditorClose,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
}),
[
config,
outputUpdateHandler,

View file

@ -186,7 +186,11 @@ describe('useSlashCompletion', () => {
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
}),
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
createTestCommand({
name: 'clear',
altNames: ['reset', 'new'],
description: 'Clear the screen',
}),
createTestCommand({
name: 'memory',
description: 'Manage memory',
@ -207,7 +211,13 @@ describe('useSlashCompletion', () => {
expect(result.current.suggestions.length).toBe(slashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
expect.arrayContaining([
'help (?)',
'clear (reset, new)',
'memory',
'chat',
'stats (usage)',
]),
);
});
@ -256,7 +266,7 @@ describe('useSlashCompletion', () => {
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'stats',
label: 'stats (usage)',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
commandKind: CommandKind.BUILT_IN,
@ -512,11 +522,7 @@ describe('useSlashCompletion', () => {
describe('Argument Completion', () => {
it('should call the command.completion function for argument suggestions', async () => {
const availableTags = [
'my-chat-tag-1',
'my-chat-tag-2',
'another-channel',
];
const availableTags = ['--project', '--global'];
const mockCompletionFn = vi
.fn()
.mockImplementation(
@ -526,12 +532,12 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'memory',
description: 'Manage memory',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'show',
description: 'Show memory',
completion: mockCompletionFn,
}),
],
@ -541,7 +547,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume my-ch',
'/memory show --project',
slashCommands,
mockCommandContext,
),
@ -551,19 +557,18 @@ describe('useSlashCompletion', () => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume my-ch',
name: 'resume',
args: 'my-ch',
raw: '/memory show --project',
name: 'show',
args: '--project',
},
}),
'my-ch',
'--project',
);
});
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
{ label: '--project', value: '--project' },
]);
});
});
@ -575,12 +580,12 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'workspace',
description: 'Manage workspaces',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'switch',
description: 'Switch workspace',
completion: mockCompletionFn,
}),
],
@ -590,7 +595,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
'/workspace switch ',
slashCommands,
mockCommandContext,
),
@ -600,8 +605,8 @@ describe('useSlashCompletion', () => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume',
name: 'resume',
raw: '/workspace switch',
name: 'switch',
args: '',
},
}),
@ -618,12 +623,12 @@ describe('useSlashCompletion', () => {
const completionFn = vi.fn().mockResolvedValue(null);
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'workspace',
description: 'Manage workspaces',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'switch',
description: 'Switch workspace',
completion: completionFn,
}),
],
@ -633,7 +638,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
'/workspace switch ',
slashCommands,
mockCommandContext,
),

View file

@ -282,7 +282,7 @@ function useCommandSuggestions(
if (!signal.aborted) {
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
label: formatSlashCommandLabel(cmd),
value: cmd.name,
description: cmd.description,
commandKind: cmd.kind,
@ -525,3 +525,14 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
completionEnd,
};
}
function formatSlashCommandLabel(command: SlashCommand): string {
const baseLabel = command.name;
const altNames = command.altNames?.filter(Boolean);
if (!altNames || altNames.length === 0) {
return baseLabel;
}
return `${baseLabel} (${altNames.join(', ')})`;
}

View file

@ -68,6 +68,7 @@ const mockConfig = {
getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockTool = new MockTool({

View file

@ -4,10 +4,95 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatDuration, formatMemoryUsage } from './formatters.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
formatDuration,
formatMemoryUsage,
formatRelativeTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatRelativeTime', () => {
const NOW = 1700000000000; // Fixed timestamp for testing
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return "just now" for timestamps less than a minute ago', () => {
expect(formatRelativeTime(NOW - 30 * 1000)).toBe('just now');
expect(formatRelativeTime(NOW - 59 * 1000)).toBe('just now');
});
it('should return "1 minute ago" for exactly one minute', () => {
expect(formatRelativeTime(NOW - 60 * 1000)).toBe('1 minute ago');
});
it('should return plural minutes for multiple minutes', () => {
expect(formatRelativeTime(NOW - 5 * 60 * 1000)).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 30 * 60 * 1000)).toBe('30 minutes ago');
});
it('should return "1 hour ago" for exactly one hour', () => {
expect(formatRelativeTime(NOW - 60 * 60 * 1000)).toBe('1 hour ago');
});
it('should return plural hours for multiple hours', () => {
expect(formatRelativeTime(NOW - 3 * 60 * 60 * 1000)).toBe('3 hours ago');
expect(formatRelativeTime(NOW - 23 * 60 * 60 * 1000)).toBe(
'23 hours ago',
);
});
it('should return "1 day ago" for exactly one day', () => {
expect(formatRelativeTime(NOW - 24 * 60 * 60 * 1000)).toBe('1 day ago');
});
it('should return plural days for multiple days', () => {
expect(formatRelativeTime(NOW - 3 * 24 * 60 * 60 * 1000)).toBe(
'3 days ago',
);
expect(formatRelativeTime(NOW - 6 * 24 * 60 * 60 * 1000)).toBe(
'6 days ago',
);
});
it('should return "1 week ago" for exactly one week', () => {
expect(formatRelativeTime(NOW - 7 * 24 * 60 * 60 * 1000)).toBe(
'1 week ago',
);
});
it('should return plural weeks for multiple weeks', () => {
expect(formatRelativeTime(NOW - 14 * 24 * 60 * 60 * 1000)).toBe(
'2 weeks ago',
);
expect(formatRelativeTime(NOW - 21 * 24 * 60 * 60 * 1000)).toBe(
'3 weeks ago',
);
});
it('should return "1 month ago" for exactly one month (30 days)', () => {
expect(formatRelativeTime(NOW - 30 * 24 * 60 * 60 * 1000)).toBe(
'1 month ago',
);
});
it('should return plural months for multiple months', () => {
expect(formatRelativeTime(NOW - 60 * 24 * 60 * 60 * 1000)).toBe(
'2 months ago',
);
expect(formatRelativeTime(NOW - 90 * 24 * 60 * 60 * 1000)).toBe(
'3 months ago',
);
});
});
describe('formatMemoryUsage', () => {
it('should format bytes into KB', () => {
expect(formatMemoryUsage(12345)).toBe('12.1 KB');

View file

@ -21,6 +21,40 @@ export const formatMemoryUsage = (bytes: number): string => {
* @param milliseconds The duration in milliseconds.
* @returns A formatted string representing the duration.
*/
/**
* Formats a timestamp into a human-readable relative time string.
* @param timestamp The timestamp in milliseconds since epoch.
* @returns A formatted string like "just now", "5 minutes ago", "2 days ago".
*/
export const formatRelativeTime = (timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (months > 0) {
return months === 1 ? '1 month ago' : `${months} months ago`;
}
if (weeks > 0) {
return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
}
if (days > 0) {
return days === 1 ? '1 day ago' : `${days} days ago`;
}
if (hours > 0) {
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
}
if (minutes > 0) {
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
}
return 'just now';
};
export const formatDuration = (milliseconds: number): string => {
if (milliseconds <= 0) {
return '0s';

View file

@ -0,0 +1,279 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildResumedHistoryItems } from './resumeHistoryUtils.js';
import { ToolCallStatus } from '../types.js';
import type {
AnyDeclarativeTool,
Config,
ConversationRecord,
ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
const makeConfig = (tools: Record<string, AnyDeclarativeTool>) =>
({
getToolRegistry: () => ({
getTool: (name: string) => tools[name],
}),
}) as unknown as Config;
describe('resumeHistoryUtils', () => {
let mockTool: AnyDeclarativeTool;
beforeEach(() => {
const mockInvocation = {
getDescription: () => 'Mocked description',
};
mockTool = {
name: 'replace',
displayName: 'Replace',
description: 'Replace text',
build: vi.fn().mockReturnValue(mockInvocation),
} as unknown as AnyDeclarativeTool;
});
it('converts conversation into history items with incremental ids', () => {
const conversation = {
messages: [
{
type: 'user',
message: { parts: [{ text: 'Hello' } as Part] },
},
{
type: 'assistant',
message: {
parts: [
{ text: 'Hi there' } as Part,
{
functionCall: {
id: 'call-1',
name: 'replace',
args: { old: 'a', new: 'b' },
},
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'call-1',
resultDisplay: 'All set',
status: 'success',
},
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const baseTimestamp = 1_000;
const items = buildResumedHistoryItems(
session,
makeConfig({ replace: mockTool }),
baseTimestamp,
);
expect(items).toEqual([
{ id: baseTimestamp + 1, type: 'user', text: 'Hello' },
{ id: baseTimestamp + 2, type: 'gemini', text: 'Hi there' },
{
id: baseTimestamp + 3,
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'Replace',
description: 'Mocked description',
resultDisplay: 'All set',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
},
]);
});
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
text: 'should be skipped',
thought: { subject: 'hidden' },
} as unknown as Part,
{ text: 'visible text' } as Part,
{
functionCall: {
id: 'missing-call',
name: 'unknown_tool',
args: { foo: 'bar' },
},
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'missing-call',
resultDisplay: { summary: 'failure' },
status: 'error',
},
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(session, makeConfig({}));
expect(items).toEqual([
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
{
id: expect.any(Number),
type: 'tool_group',
tools: [
{
callId: 'missing-call',
name: 'unknown_tool',
description: '',
resultDisplay: { summary: 'failure' },
status: ToolCallStatus.Error,
confirmationDetails: undefined,
},
],
},
]);
});
it('flushes pending tool groups before subsequent user messages', () => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
functionCall: {
id: 'call-2',
name: 'replace',
args: { target: 'a' },
},
} as unknown as Part,
],
},
},
{
type: 'user',
message: { parts: [{ text: 'next user message' } as Part] },
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(
session,
makeConfig({ replace: mockTool }),
10,
);
expect(items[0]).toEqual({
id: 11,
type: 'tool_group',
tools: [
{
callId: 'call-2',
name: 'Replace',
description: 'Mocked description',
resultDisplay: undefined,
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
});
expect(items[1]).toEqual({
id: 12,
type: 'user',
text: 'next user message',
});
});
it('replays slash command history items (e.g., /about) on resume', () => {
const conversation = {
messages: [
{
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'invocation',
rawCommand: '/about',
},
},
{
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'result',
rawCommand: '/about',
outputHistoryItems: [
{
type: 'about',
systemInfo: {
cliVersion: '1.2.3',
osPlatform: 'darwin',
osArch: 'arm64',
osRelease: 'test',
nodeVersion: '20.x',
npmVersion: '10.x',
sandboxEnv: 'none',
modelVersion: 'qwen',
selectedAuthType: 'none',
ideClient: 'none',
sessionId: 'abc',
memoryUsage: '0 MB',
},
},
],
},
},
{
type: 'assistant',
message: { parts: [{ text: 'Follow-up' } as Part] },
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(session, makeConfig({}), 5);
expect(items).toEqual([
{ id: 6, type: 'user', text: '/about' },
{
id: 7,
type: 'about',
systemInfo: expect.objectContaining({ cliVersion: '1.2.3' }),
},
{ id: 8, type: 'gemini', text: 'Follow-up' },
]);
});
});

View file

@ -0,0 +1,299 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part, FunctionCall } from '@google/genai';
import type {
ResumedSessionData,
ConversationRecord,
Config,
AnyDeclarativeTool,
ToolResultDisplay,
SlashCommandRecordPayload,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { ToolCallStatus } from '../types.js';
/**
* Extracts text content from a Content object's parts.
*/
function extractTextFromParts(parts: Part[] | undefined): string {
if (!parts) return '';
const textParts: string[] = [];
for (const part of parts) {
if ('text' in part && part.text) {
// Skip thought parts - they have a 'thought' property
if (!('thought' in part && part.thought)) {
textParts.push(part.text);
}
}
}
return textParts.join('\n');
}
/**
* Extracts function calls from a Content object's parts.
*/
function extractFunctionCalls(
parts: Part[] | undefined,
): Array<{ id: string; name: string; args: Record<string, unknown> }> {
if (!parts) return [];
const calls: Array<{
id: string;
name: string;
args: Record<string, unknown>;
}> = [];
for (const part of parts) {
if ('functionCall' in part && part.functionCall) {
const fc = part.functionCall as FunctionCall;
calls.push({
id: fc.id || `call-${calls.length}`,
name: fc.name || 'unknown',
args: (fc.args as Record<string, unknown>) || {},
});
}
}
return calls;
}
function getTool(config: Config, name: string): AnyDeclarativeTool | undefined {
const toolRegistry = config.getToolRegistry();
return toolRegistry.getTool(name);
}
/**
* Formats a tool description from its name and arguments using actual tool instances.
* This ensures we get the exact same descriptions as during normal operation.
*/
function formatToolDescription(
tool: AnyDeclarativeTool,
args: Record<string, unknown>,
): string {
try {
// Create tool invocation instance and get description
const invocation = tool.build(args);
return invocation.getDescription();
} catch {
return '';
}
}
/**
* Restores a HistoryItemWithoutId from the serialized shape stored in
* SlashCommandRecordPayload.outputHistoryItems.
*/
function restoreHistoryItem(raw: unknown): HistoryItemWithoutId | undefined {
if (!raw || typeof raw !== 'object') {
return;
}
const clone = { ...(raw as Record<string, unknown>) };
if ('timestamp' in clone) {
const ts = clone['timestamp'];
if (typeof ts === 'string' || typeof ts === 'number') {
clone['timestamp'] = new Date(ts);
}
}
if (typeof clone['type'] !== 'string') {
return;
}
return clone as unknown as HistoryItemWithoutId;
}
/**
* Converts ChatRecord messages to UI history items for display.
*
* This function transforms the raw ChatRecords into a format suitable
* for the CLI's HistoryItemDisplay component.
*
* @param conversation The conversation record from a resumed session
* @param config The config object for accessing tool registry
* @returns Array of history items for UI display
*/
function convertToHistoryItems(
conversation: ConversationRecord,
config: Config,
): HistoryItemWithoutId[] {
const items: HistoryItemWithoutId[] = [];
// Track pending tool calls for grouping with results
const pendingToolCalls = new Map<
string,
{ name: string; args: Record<string, unknown> }
>();
let currentToolGroup: Array<{
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: ToolCallStatus;
confirmationDetails: undefined;
}> = [];
for (const record of conversation.messages) {
if (record.type === 'system') {
if (record.subtype === 'slash_command') {
// Flush any pending tool group to avoid mixing contexts.
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
const payload = record.systemPayload as
| SlashCommandRecordPayload
| undefined;
if (!payload) continue;
if (payload.phase === 'invocation' && payload.rawCommand) {
items.push({ type: 'user', text: payload.rawCommand });
}
if (payload.phase === 'result') {
const outputs = payload.outputHistoryItems ?? [];
for (const raw of outputs) {
const restored = restoreHistoryItem(raw);
if (restored) {
items.push(restored);
}
}
}
}
continue;
}
switch (record.type) {
case 'user': {
// Flush any pending tool group before user message
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
const text = extractTextFromParts(record.message?.parts as Part[]);
if (text) {
items.push({ type: 'user', text });
}
break;
}
case 'assistant': {
const parts = record.message?.parts as Part[] | undefined;
// Extract text content (non-function-call, non-thought)
const text = extractTextFromParts(parts);
// Extract function calls
const functionCalls = extractFunctionCalls(parts);
// If there's text content, add it as a gemini message
if (text) {
// Flush any pending tool group before text
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
items.push({ type: 'gemini', text });
}
// Track function calls for pairing with results
for (const fc of functionCalls) {
const tool = getTool(config, fc.name);
pendingToolCalls.set(fc.id, { name: fc.name, args: fc.args });
// Add placeholder tool call to current group
currentToolGroup.push({
callId: fc.id,
name: tool?.displayName || fc.name,
description: tool ? formatToolDescription(tool, fc.args) : '',
resultDisplay: undefined,
status: ToolCallStatus.Success, // Will be updated by tool_result
confirmationDetails: undefined,
});
}
break;
}
case 'tool_result': {
// Update the corresponding tool call in the current group
if (record.toolCallResult) {
const callId = record.toolCallResult.callId;
const toolCall = currentToolGroup.find((t) => t.callId === callId);
if (toolCall) {
// Preserve the resultDisplay as-is - it can be a string or structured object
const rawDisplay = record.toolCallResult.resultDisplay;
toolCall.resultDisplay = rawDisplay;
// Check if status exists and use it
const rawStatus = (
record.toolCallResult as Record<string, unknown>
)['status'] as string | undefined;
toolCall.status =
rawStatus === 'error'
? ToolCallStatus.Error
: ToolCallStatus.Success;
}
pendingToolCalls.delete(callId || '');
}
break;
}
default:
// Skip unknown record types
break;
}
}
// Flush any remaining tool group
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: currentToolGroup,
});
}
return items;
}
/**
* Builds the complete UI history items for a resumed session.
*
* This function takes the resumed session data, converts it to UI history format,
* and assigns unique IDs to each item for use with loadHistory.
*
* @param sessionData The resumed session data from SessionService
* @param config The config object for accessing tool registry
* @param baseTimestamp Base timestamp for generating unique IDs
* @returns Array of HistoryItem with proper IDs
*/
export function buildResumedHistoryItems(
sessionData: ResumedSessionData,
config: Config,
baseTimestamp: number = Date.now(),
): HistoryItem[] {
const items: HistoryItem[] = [];
let idCounter = 1;
const getNextId = (): number => baseTimestamp + idCounter++;
// Convert conversation directly to history items
const historyItems = convertToHistoryItems(sessionData.conversation, config);
for (const item of historyItems) {
items.push({
...item,
id: getNextId(),
} as HistoryItem);
}
return items;
}