mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user