mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 21:50:52 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
parent
a7abd8d09f
commit
0a75d85ac9
114 changed files with 9257 additions and 4039 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(', ')})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)'),
|
||||
|
|
|
|||
436
packages/cli/src/ui/components/ResumeSessionPicker.tsx
Normal file
436
packages/cli/src/ui/components/ResumeSessionPicker.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(', ')})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
279
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Normal file
279
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
299
packages/cli/src/ui/utils/resumeHistoryUtils.ts
Normal file
299
packages/cli/src/ui/utils/resumeHistoryUtils.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue