mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge branch 'main' of github.com:QwenLM/qwen-code into mingholy/feat/cli-sdk
This commit is contained in:
@@ -872,6 +872,7 @@ export async function loadCliConfig(
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
vlmSwitchMode,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
|
||||
@@ -131,6 +131,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
vlmSwitchMode: 'experimental.vlmSwitchMode',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ChatCompressionSettings,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -549,6 +550,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: true,
|
||||
},
|
||||
skipStartupContext: {
|
||||
type: 'boolean',
|
||||
label: 'Skip Startup Context',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Avoid sending the workspace startup context at the beginning of each session.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableOpenAILogging: {
|
||||
type: 'boolean',
|
||||
label: 'Enable OpenAI Logging',
|
||||
@@ -820,14 +831,20 @@ const SETTINGS_SCHEMA = {
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'string',
|
||||
label: 'Default Approval Mode',
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
default: ApprovalMode.DEFAULT,
|
||||
description:
|
||||
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.',
|
||||
'Approval mode for tool usage. Controls how tools are approved before execution.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: ApprovalMode.PLAN, label: 'Plan' },
|
||||
{ value: ApprovalMode.DEFAULT, label: 'Default' },
|
||||
{ value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' },
|
||||
{ value: ApprovalMode.YOLO, label: 'YOLO' },
|
||||
],
|
||||
},
|
||||
discoveryCommand: {
|
||||
type: 'string',
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { CommandKind } from './ui/commands/types.js';
|
||||
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
@@ -799,6 +800,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'testcommand',
|
||||
description: 'a test command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Prompt from command' }],
|
||||
@@ -838,6 +840,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
description: 'a command that needs confirmation',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'confirm_shell_commands',
|
||||
commands: ['rm -rf /'],
|
||||
@@ -893,6 +896,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'unhandled type',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'unhandled',
|
||||
}),
|
||||
@@ -919,6 +923,7 @@ describe('runNonInteractive', () => {
|
||||
const mockCommand = {
|
||||
name: 'testargs',
|
||||
description: 'a test command',
|
||||
kind: CommandKind.FILE,
|
||||
action: mockAction,
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
@@ -13,15 +13,56 @@ import {
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { CommandService } from './services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||
import type { CommandContext } from './ui/commands/types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './ui/commands/types.js';
|
||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||
|
||||
/**
|
||||
* Filters commands based on the allowed built-in command names.
|
||||
*
|
||||
* - Always includes FILE commands
|
||||
* - Only includes BUILT_IN commands if their name is in the allowed set
|
||||
* - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode
|
||||
*
|
||||
* @param commands All loaded commands
|
||||
* @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed)
|
||||
* @returns Filtered commands
|
||||
*/
|
||||
function filterCommandsForNonInteractive(
|
||||
commands: readonly SlashCommand[],
|
||||
allowedBuiltinCommandNames: Set<string>,
|
||||
): SlashCommand[] {
|
||||
return commands.filter((cmd) => {
|
||||
if (cmd.kind === CommandKind.FILE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Built-in commands: only include if in the allowed list
|
||||
if (cmd.kind === CommandKind.BUILT_IN) {
|
||||
return allowedBuiltinCommandNames.has(cmd.name);
|
||||
}
|
||||
|
||||
// Exclude other types (e.g., MCP_PROMPT) in non-interactive mode
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a slash command in a non-interactive environment.
|
||||
*
|
||||
* @param rawQuery The raw query string (should start with '/')
|
||||
* @param abortController Controller to cancel the operation
|
||||
* @param config The configuration object
|
||||
* @param settings The loaded settings
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* @returns A Promise that resolves to `PartListUnion` if a valid command is
|
||||
* found and results in a prompt, or `undefined` otherwise.
|
||||
* @throws {FatalInputError} if the command result is not supported in
|
||||
@@ -32,21 +73,35 @@ export const handleSlashCommand = async (
|
||||
abortController: AbortController,
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<PartListUnion | undefined> => {
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only custom commands are supported for now.
|
||||
const loaders = [new FileCommandLoader(config)];
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
|
||||
const commandService = await CommandService.create(
|
||||
loaders,
|
||||
abortController.signal,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
|
||||
const { commandToExecute, args } = parseSlashCommand(
|
||||
rawQuery,
|
||||
filteredCommands,
|
||||
);
|
||||
|
||||
if (commandToExecute) {
|
||||
if (commandToExecute.action) {
|
||||
@@ -107,3 +162,44 @@ export const handleSlashCommand = async (
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all available slash commands for the current configuration.
|
||||
*
|
||||
* @param config The configuration object
|
||||
* @param settings The loaded settings
|
||||
* @param abortSignal Signal to cancel the loading process
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. If not provided or empty, only file commands are available.
|
||||
* @returns A Promise that resolves to an array of SlashCommand objects
|
||||
*/
|
||||
export const getAvailableCommands = async (
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
abortSignal: AbortSignal,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<SlashCommand[]> => {
|
||||
try {
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
|
||||
const commandService = await CommandService.create(loaders, abortSignal);
|
||||
const commands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
// Filter out hidden commands
|
||||
return filteredCommands.filter((cmd) => !cmd.hidden);
|
||||
} catch (error) {
|
||||
// Handle errors gracefully - log and return empty array
|
||||
console.error('Error loading available commands:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
@@ -96,6 +97,7 @@ import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
|
||||
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
@@ -335,6 +337,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
initializationResult.themeError,
|
||||
);
|
||||
|
||||
const {
|
||||
isApprovalModeDialogOpen,
|
||||
openApprovalModeDialog,
|
||||
handleApprovalModeSelect,
|
||||
} = useApprovalModeCommand(settings, config);
|
||||
|
||||
const {
|
||||
setAuthState,
|
||||
authError,
|
||||
@@ -470,6 +478,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
setQuittingMessages(messages);
|
||||
setTimeout(async () => {
|
||||
@@ -495,6 +504,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setCorgiMode,
|
||||
dispatchExtensionStateUpdate,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
showQuitConfirmation,
|
||||
openSubagentCreateDialog,
|
||||
@@ -551,6 +561,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[visionSwitchResolver],
|
||||
);
|
||||
|
||||
// onDebugMessage should log to console, not update footer debugMessage
|
||||
const onDebugMessage = useCallback((message: string) => {
|
||||
console.debug(message);
|
||||
}, []);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
historyManager.addItem(
|
||||
{
|
||||
@@ -628,7 +643,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
historyManager.addItem,
|
||||
config,
|
||||
settings,
|
||||
setDebugMessage,
|
||||
onDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
() => settings.merged.general?.preferredEditor as EditorType,
|
||||
@@ -930,10 +945,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
settings.merged.ui?.customWittyPhrases,
|
||||
);
|
||||
|
||||
useAttentionNotifications({
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
const { closeAnyOpenDialog } = useDialogClose({
|
||||
isThemeDialogOpen,
|
||||
handleThemeSelect,
|
||||
isApprovalModeDialogOpen,
|
||||
handleApprovalModeSelect,
|
||||
isAuthDialogOpen,
|
||||
handleAuthSelect,
|
||||
selectedAuthType: settings.merged.security?.auth?.selectedType,
|
||||
@@ -1183,7 +1206,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen;
|
||||
isAgentsManagerDialogOpen ||
|
||||
isApprovalModeDialogOpen;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1214,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isSettingsDialogOpen,
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1308,6 +1333,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isSettingsDialogOpen,
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1388,6 +1414,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
() => ({
|
||||
handleThemeSelect,
|
||||
handleThemeHighlight,
|
||||
handleApprovalModeSelect,
|
||||
handleAuthSelect,
|
||||
setAuthState,
|
||||
onAuthError,
|
||||
@@ -1423,6 +1450,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[
|
||||
handleThemeSelect,
|
||||
handleThemeHighlight,
|
||||
handleApprovalModeSelect,
|
||||
handleAuthSelect,
|
||||
setAuthState,
|
||||
onAuthError,
|
||||
|
||||
@@ -78,20 +78,17 @@ export function AuthDialog({
|
||||
);
|
||||
|
||||
const handleAuthSelect = (authMethod: AuthType) => {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
if (error) {
|
||||
if (
|
||||
authMethod === AuthType.USE_OPENAI &&
|
||||
!process.env['OPENAI_API_KEY']
|
||||
) {
|
||||
setShowOpenAIKeyPrompt(true);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage(error);
|
||||
}
|
||||
} else {
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
setShowOpenAIKeyPrompt(true);
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
} else {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,10 +134,23 @@ export function AuthDialog({
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (showOpenAIKeyPrompt) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
onSubmit={handleOpenAIKeySubmit}
|
||||
onCancel={handleOpenAIKeyCancel}
|
||||
/>
|
||||
|
||||
@@ -8,38 +8,22 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { aboutCommand } from './aboutCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as versionUtils from '../../utils/version.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as systemInfoUtils from '../../utils/systemInfo.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../utils/systemInfo.js');
|
||||
|
||||
describe('aboutCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -56,21 +40,25 @@ describe('aboutCommand', () => {
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
|
||||
'test-model',
|
||||
);
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -81,30 +69,55 @@ describe('aboutCommand', () => {
|
||||
});
|
||||
|
||||
it('should call addItem with all version info', async () => {
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
expect.objectContaining({
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: 'test-ide',
|
||||
},
|
||||
systemInfo: expect.objectContaining({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the correct sandbox environment variable', async () => {
|
||||
process.env['SANDBOX'] = 'gemini-sandbox';
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -113,15 +126,32 @@ describe('aboutCommand', () => {
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
type: MessageType.ABOUT,
|
||||
systemInfo: expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show sandbox-exec profile when applicable', async () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -130,18 +160,31 @@ describe('aboutCommand', () => {
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
systemInfo: expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show ide client when it is not detected', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -151,13 +194,87 @@ describe('aboutCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: '',
|
||||
systemInfo: expect.objectContaining({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show unknown npmVersion when npm command fails', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: 'unknown',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemInfo: expect.objectContaining({
|
||||
npmVersion: 'unknown',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show unknown sessionId when config is not available', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'Unknown',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'unknown',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemInfo: expect.objectContaining({
|
||||
sessionId: 'unknown',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -4,53 +4,23 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import type { CommandContext, SlashCommand } from './types.js';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import process from 'node:process';
|
||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
description: 'show version info',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'];
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
ideClient,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
context.ui.addItem(aboutItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -4,492 +4,68 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { approvalModeCommand } from './approvalModeCommand.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type MessageActionReturn,
|
||||
type OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
describe('approvalModeCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let setApprovalModeMock: ReturnType<typeof vi.fn>;
|
||||
let setSettingsValueMock: ReturnType<typeof vi.fn>;
|
||||
const originalEnv = { ...process.env };
|
||||
const userSettingsPath = '/mock/user/settings.json';
|
||||
const projectSettingsPath = '/mock/project/settings.json';
|
||||
const userSettingsFile = { path: userSettingsPath, settings: {} };
|
||||
const projectSettingsFile = { path: projectSettingsPath, settings: {} };
|
||||
|
||||
const getModeSubCommand = (mode: ApprovalMode) =>
|
||||
approvalModeCommand.subCommands?.find((cmd) => cmd.name === mode);
|
||||
|
||||
const getScopeSubCommand = (
|
||||
mode: ApprovalMode,
|
||||
scope: '--session' | '--user' | '--project',
|
||||
) => getModeSubCommand(mode)?.subCommands?.find((cmd) => cmd.name === scope);
|
||||
|
||||
beforeEach(() => {
|
||||
setApprovalModeMock = vi.fn();
|
||||
setSettingsValueMock = vi.fn();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
setApprovalMode: setApprovalModeMock,
|
||||
getApprovalMode: () => 'default',
|
||||
setApprovalMode: () => {},
|
||||
},
|
||||
settings: {
|
||||
merged: {},
|
||||
setValue: setSettingsValueMock,
|
||||
forScope: vi
|
||||
.fn()
|
||||
.mockImplementation((scope: SettingScope) =>
|
||||
scope === SettingScope.User
|
||||
? userSettingsFile
|
||||
: scope === SettingScope.Workspace
|
||||
? projectSettingsFile
|
||||
: { path: '', settings: {} },
|
||||
),
|
||||
setValue: () => {},
|
||||
forScope: () => ({}),
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct command properties', () => {
|
||||
it('should have correct metadata', () => {
|
||||
expect(approvalModeCommand.name).toBe('approval-mode');
|
||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
expect(approvalModeCommand.description).toBe(
|
||||
'View or change the approval mode for tool usage',
|
||||
);
|
||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should show current mode, options, and usage when no arguments provided', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
it('should open approval mode dialog when invoked', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
)) as OpenDialogActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
const expectedMessage = [
|
||||
'Current approval mode: default',
|
||||
'',
|
||||
'Available approval modes:',
|
||||
' - plan: Plan mode - Analyze only, do not modify files or execute commands',
|
||||
' - default: Default mode - Require approval for file edits or shell commands',
|
||||
' - auto-edit: Auto-edit mode - Automatically approve file edits',
|
||||
' - yolo: YOLO mode - Automatically approve all tools',
|
||||
'',
|
||||
'Usage: /approval-mode <mode> [--session|--user|--project]',
|
||||
].join('\n');
|
||||
expect(result.content).toBe(expectedMessage);
|
||||
expect(result.type).toBe('dialog');
|
||||
expect(result.dialog).toBe('approval-mode');
|
||||
});
|
||||
|
||||
it('should display error when config is not available', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
it('should open approval mode dialog with arguments (ignored)', async () => {
|
||||
const result = (await approvalModeCommand.action?.(
|
||||
mockContext,
|
||||
'some arguments',
|
||||
)) as OpenDialogActionReturn;
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
nullConfigContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toBe('Configuration not available.');
|
||||
expect(result.type).toBe('dialog');
|
||||
expect(result.dialog).toBe('approval-mode');
|
||||
});
|
||||
|
||||
it('should change approval mode when valid mode is provided', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'plan',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toBe('Approval mode changed to: plan');
|
||||
it('should not have subcommands', () => {
|
||||
expect(approvalModeCommand.subCommands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept canonical auto-edit mode value', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'auto-edit',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toBe('Approval mode changed to: auto-edit');
|
||||
});
|
||||
|
||||
it('should accept auto-edit alias for compatibility', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'auto-edit',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('Approval mode changed to: auto-edit');
|
||||
});
|
||||
|
||||
it('should display error when invalid mode is provided', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'invalid',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('Invalid approval mode: invalid');
|
||||
expect(result.content).toContain('Available approval modes:');
|
||||
expect(result.content).toContain(
|
||||
'Usage: /approval-mode <mode> [--session|--user|--project]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error when setApprovalMode throws an error', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const errorMessage = 'Failed to set approval mode';
|
||||
mockContext.services.config!.setApprovalMode = vi
|
||||
.fn()
|
||||
.mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'plan',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toBe(
|
||||
`Failed to change approval mode: ${errorMessage}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow selecting auto-edit with user scope via nested subcommands', async () => {
|
||||
if (!approvalModeCommand.subCommands) {
|
||||
throw new Error('approvalModeCommand must have subCommands.');
|
||||
}
|
||||
|
||||
const userSubCommand = getScopeSubCommand(ApprovalMode.AUTO_EDIT, '--user');
|
||||
if (!userSubCommand?.action) {
|
||||
throw new Error('--user scope subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await userSubCommand.action(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
expect(setSettingsValueMock).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'approvalMode',
|
||||
'auto-edit',
|
||||
);
|
||||
expect(result.content).toBe(
|
||||
`Approval mode changed to: auto-edit (saved to user settings at ${userSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow selecting plan with project scope via nested subcommands', async () => {
|
||||
if (!approvalModeCommand.subCommands) {
|
||||
throw new Error('approvalModeCommand must have subCommands.');
|
||||
}
|
||||
|
||||
const projectSubCommand = getScopeSubCommand(
|
||||
ApprovalMode.PLAN,
|
||||
'--project',
|
||||
);
|
||||
if (!projectSubCommand?.action) {
|
||||
throw new Error('--project scope subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await projectSubCommand.action(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
expect(setSettingsValueMock).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'approvalMode',
|
||||
'plan',
|
||||
);
|
||||
expect(result.content).toBe(
|
||||
`Approval mode changed to: plan (saved to project settings at ${projectSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow selecting plan with session scope via nested subcommands', async () => {
|
||||
if (!approvalModeCommand.subCommands) {
|
||||
throw new Error('approvalModeCommand must have subCommands.');
|
||||
}
|
||||
|
||||
const sessionSubCommand = getScopeSubCommand(
|
||||
ApprovalMode.PLAN,
|
||||
'--session',
|
||||
);
|
||||
if (!sessionSubCommand?.action) {
|
||||
throw new Error('--session scope subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await sessionSubCommand.action(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('Approval mode changed to: plan');
|
||||
});
|
||||
|
||||
it('should allow providing a scope argument after selecting a mode subcommand', async () => {
|
||||
if (!approvalModeCommand.subCommands) {
|
||||
throw new Error('approvalModeCommand must have subCommands.');
|
||||
}
|
||||
|
||||
const planSubCommand = getModeSubCommand(ApprovalMode.PLAN);
|
||||
if (!planSubCommand?.action) {
|
||||
throw new Error('plan subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await planSubCommand.action(
|
||||
mockContext,
|
||||
'--user',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
expect(setSettingsValueMock).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'approvalMode',
|
||||
'plan',
|
||||
);
|
||||
expect(result.content).toBe(
|
||||
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --user plan pattern (scope first)', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'--user plan',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
expect(setSettingsValueMock).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'approvalMode',
|
||||
'plan',
|
||||
);
|
||||
expect(result.content).toBe(
|
||||
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support plan --user pattern (mode first)', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'plan --user',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
expect(setSettingsValueMock).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'approvalMode',
|
||||
'plan',
|
||||
);
|
||||
expect(result.content).toBe(
|
||||
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --project auto-edit pattern', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'--project auto-edit',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
expect(setSettingsValueMock).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'approvalMode',
|
||||
'auto-edit',
|
||||
);
|
||||
expect(result.content).toBe(
|
||||
`Approval mode changed to: auto-edit (saved to project settings at ${projectSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error when only scope flag is provided', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'--user',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('Missing approval mode');
|
||||
expect(setApprovalModeMock).not.toHaveBeenCalled();
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display error when multiple scope flags are provided', async () => {
|
||||
if (!approvalModeCommand.action) {
|
||||
throw new Error('approvalModeCommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await approvalModeCommand.action(
|
||||
mockContext,
|
||||
'--user --project plan',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('Multiple scope flags provided');
|
||||
expect(setApprovalModeMock).not.toHaveBeenCalled();
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should surface a helpful error when scope subcommands receive extra arguments', async () => {
|
||||
if (!approvalModeCommand.subCommands) {
|
||||
throw new Error('approvalModeCommand must have subCommands.');
|
||||
}
|
||||
|
||||
const userSubCommand = getScopeSubCommand(ApprovalMode.DEFAULT, '--user');
|
||||
if (!userSubCommand?.action) {
|
||||
throw new Error('--user scope subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = (await userSubCommand.action(
|
||||
mockContext,
|
||||
'extra',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toBe(
|
||||
'Scope subcommands do not accept additional arguments.',
|
||||
);
|
||||
expect(setApprovalModeMock).not.toHaveBeenCalled();
|
||||
expect(setSettingsValueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should provide completion for approval modes', async () => {
|
||||
if (!approvalModeCommand.completion) {
|
||||
throw new Error('approvalModeCommand must have a completion function.');
|
||||
}
|
||||
|
||||
// Test partial mode completion
|
||||
const result = await approvalModeCommand.completion(mockContext, 'p');
|
||||
expect(result).toEqual(['plan']);
|
||||
|
||||
const result2 = await approvalModeCommand.completion(mockContext, 'a');
|
||||
expect(result2).toEqual(['auto-edit']);
|
||||
|
||||
// Test empty completion - should suggest available modes first
|
||||
const result3 = await approvalModeCommand.completion(mockContext, '');
|
||||
expect(result3).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
|
||||
|
||||
const result4 = await approvalModeCommand.completion(mockContext, 'AUTO');
|
||||
expect(result4).toEqual(['auto-edit']);
|
||||
|
||||
// Test mode first pattern: 'plan ' should suggest scope flags
|
||||
const result5 = await approvalModeCommand.completion(mockContext, 'plan ');
|
||||
expect(result5).toEqual(['--session', '--project', '--user']);
|
||||
|
||||
const result6 = await approvalModeCommand.completion(
|
||||
mockContext,
|
||||
'plan --u',
|
||||
);
|
||||
expect(result6).toEqual(['--user']);
|
||||
|
||||
// Test scope first pattern: '--user ' should suggest modes
|
||||
const result7 = await approvalModeCommand.completion(
|
||||
mockContext,
|
||||
'--user ',
|
||||
);
|
||||
expect(result7).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
|
||||
|
||||
const result8 = await approvalModeCommand.completion(
|
||||
mockContext,
|
||||
'--user p',
|
||||
);
|
||||
expect(result8).toEqual(['plan']);
|
||||
|
||||
// Test completed patterns should return empty
|
||||
const result9 = await approvalModeCommand.completion(
|
||||
mockContext,
|
||||
'plan --user ',
|
||||
);
|
||||
expect(result9).toEqual([]);
|
||||
|
||||
const result10 = await approvalModeCommand.completion(
|
||||
mockContext,
|
||||
'--user plan ',
|
||||
);
|
||||
expect(result10).toEqual([]);
|
||||
it('should not have completion function', () => {
|
||||
expect(approvalModeCommand.completion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,428 +7,19 @@
|
||||
import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
MessageActionReturn,
|
||||
OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
const USAGE_MESSAGE =
|
||||
'Usage: /approval-mode <mode> [--session|--user|--project]';
|
||||
|
||||
const normalizeInputMode = (value: string): string =>
|
||||
value.trim().toLowerCase();
|
||||
|
||||
const tokenizeArgs = (args: string): string[] => {
|
||||
const matches = args.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g);
|
||||
if (!matches) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return matches.map((token) => {
|
||||
if (
|
||||
(token.startsWith('"') && token.endsWith('"')) ||
|
||||
(token.startsWith("'") && token.endsWith("'"))
|
||||
) {
|
||||
return token.slice(1, -1);
|
||||
}
|
||||
return token;
|
||||
});
|
||||
};
|
||||
|
||||
const parseApprovalMode = (value: string | null): ApprovalMode | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeInputMode(value).replace(/_/g, '-');
|
||||
const matchIndex = APPROVAL_MODES.findIndex(
|
||||
(candidate) => candidate === normalized,
|
||||
);
|
||||
|
||||
return matchIndex === -1 ? null : APPROVAL_MODES[matchIndex];
|
||||
};
|
||||
|
||||
const formatModeDescription = (mode: ApprovalMode): string => {
|
||||
switch (mode) {
|
||||
case ApprovalMode.PLAN:
|
||||
return 'Plan mode - Analyze only, do not modify files or execute commands';
|
||||
case ApprovalMode.DEFAULT:
|
||||
return 'Default mode - Require approval for file edits or shell commands';
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return 'Auto-edit mode - Automatically approve file edits';
|
||||
case ApprovalMode.YOLO:
|
||||
return 'YOLO mode - Automatically approve all tools';
|
||||
default:
|
||||
return `${mode} mode`;
|
||||
}
|
||||
};
|
||||
|
||||
const parseApprovalArgs = (
|
||||
args: string,
|
||||
): {
|
||||
mode: string | null;
|
||||
scope: 'session' | 'user' | 'project';
|
||||
error?: string;
|
||||
} => {
|
||||
const trimmedArgs = args.trim();
|
||||
if (!trimmedArgs) {
|
||||
return { mode: null, scope: 'session' };
|
||||
}
|
||||
|
||||
const tokens = tokenizeArgs(trimmedArgs);
|
||||
let mode: string | null = null;
|
||||
let scope: 'session' | 'user' | 'project' = 'session';
|
||||
let scopeFlag: string | null = null;
|
||||
|
||||
// Find scope flag and mode
|
||||
for (const token of tokens) {
|
||||
if (token === '--session' || token === '--user' || token === '--project') {
|
||||
if (scopeFlag) {
|
||||
return {
|
||||
mode: null,
|
||||
scope: 'session',
|
||||
error: 'Multiple scope flags provided',
|
||||
};
|
||||
}
|
||||
scopeFlag = token;
|
||||
scope = token.substring(2) as 'session' | 'user' | 'project';
|
||||
} else if (!mode) {
|
||||
mode = token;
|
||||
} else {
|
||||
return {
|
||||
mode: null,
|
||||
scope: 'session',
|
||||
error: 'Invalid arguments provided',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!mode) {
|
||||
return { mode: null, scope: 'session', error: 'Missing approval mode' };
|
||||
}
|
||||
|
||||
return { mode, scope };
|
||||
};
|
||||
|
||||
const setApprovalModeWithScope = async (
|
||||
context: CommandContext,
|
||||
mode: ApprovalMode,
|
||||
scope: 'session' | 'user' | 'project',
|
||||
): Promise<MessageActionReturn> => {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Always set the mode in the current session
|
||||
config.setApprovalMode(mode);
|
||||
|
||||
// If scope is not session, also persist to settings
|
||||
if (scope !== 'session') {
|
||||
const { settings } = context.services;
|
||||
if (!settings || typeof settings.setValue !== 'function') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Settings service is not available; unable to persist the approval mode.',
|
||||
};
|
||||
}
|
||||
|
||||
const settingScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
const scopeLabel = scope === 'user' ? 'user' : 'project';
|
||||
let settingsPath: string | undefined;
|
||||
|
||||
try {
|
||||
if (typeof settings.forScope === 'function') {
|
||||
settingsPath = settings.forScope(settingScope)?.path;
|
||||
}
|
||||
} catch (_error) {
|
||||
settingsPath = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
settings.setValue(settingScope, 'approvalMode', mode);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to save approval mode: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const locationSuffix = settingsPath ? ` at ${settingsPath}` : '';
|
||||
|
||||
const scopeSuffix = ` (saved to ${scopeLabel} settings${locationSuffix})`;
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Approval mode changed to: ${mode}${scopeSuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Approval mode changed to: ${mode}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to change approval mode: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const approvalModeCommand: SlashCommand = {
|
||||
name: 'approval-mode',
|
||||
description: 'View or change the approval mode for tool usage',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
|
||||
// If no arguments provided, show current mode and available options
|
||||
if (!args || args.trim() === '') {
|
||||
const currentMode =
|
||||
typeof config.getApprovalMode === 'function'
|
||||
? config.getApprovalMode()
|
||||
: null;
|
||||
|
||||
const messageLines: string[] = [];
|
||||
|
||||
if (currentMode) {
|
||||
messageLines.push(`Current approval mode: ${currentMode}`);
|
||||
messageLines.push('');
|
||||
}
|
||||
|
||||
messageLines.push('Available approval modes:');
|
||||
for (const mode of APPROVAL_MODES) {
|
||||
messageLines.push(` - ${mode}: ${formatModeDescription(mode)}`);
|
||||
}
|
||||
messageLines.push('');
|
||||
messageLines.push(USAGE_MESSAGE);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// Parse arguments flexibly
|
||||
const parsed = parseApprovalArgs(args);
|
||||
|
||||
if (parsed.error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `${parsed.error}. ${USAGE_MESSAGE}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.mode) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: USAGE_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const requestedMode = parseApprovalMode(parsed.mode);
|
||||
|
||||
if (!requestedMode) {
|
||||
let message = `Invalid approval mode: ${parsed.mode}\n\n`;
|
||||
message += 'Available approval modes:\n';
|
||||
for (const mode of APPROVAL_MODES) {
|
||||
message += ` - ${mode}: ${formatModeDescription(mode)}\n`;
|
||||
}
|
||||
message += `\n${USAGE_MESSAGE}`;
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: message,
|
||||
};
|
||||
}
|
||||
|
||||
return setApprovalModeWithScope(context, requestedMode, parsed.scope);
|
||||
},
|
||||
subCommands: APPROVAL_MODES.map((mode) => ({
|
||||
name: mode,
|
||||
description: formatModeDescription(mode),
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: '--session',
|
||||
description: 'Apply to current session only (temporary)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
if (args.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Scope subcommands do not accept additional arguments.',
|
||||
};
|
||||
}
|
||||
return setApprovalModeWithScope(context, mode, 'session');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '--project',
|
||||
description: 'Persist for this project/workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
if (args.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Scope subcommands do not accept additional arguments.',
|
||||
};
|
||||
}
|
||||
return setApprovalModeWithScope(context, mode, 'project');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '--user',
|
||||
description: 'Persist for this user on this machine',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
if (args.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Scope subcommands do not accept additional arguments.',
|
||||
};
|
||||
}
|
||||
return setApprovalModeWithScope(context, mode, 'user');
|
||||
},
|
||||
},
|
||||
],
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
if (args.trim().length > 0) {
|
||||
// Allow users who type `/approval-mode plan --user` via the subcommand path
|
||||
const parsed = parseApprovalArgs(`${mode} ${args}`);
|
||||
if (parsed.error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `${parsed.error}. ${USAGE_MESSAGE}`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedMode = parseApprovalMode(parsed.mode);
|
||||
if (!normalizedMode) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Invalid approval mode: ${parsed.mode}. ${USAGE_MESSAGE}`,
|
||||
};
|
||||
}
|
||||
|
||||
return setApprovalModeWithScope(context, normalizedMode, parsed.scope);
|
||||
}
|
||||
|
||||
return setApprovalModeWithScope(context, mode, 'session');
|
||||
},
|
||||
})),
|
||||
completion: async (_context: CommandContext, partialArg: string) => {
|
||||
const tokens = tokenizeArgs(partialArg);
|
||||
const hasTrailingSpace = /\s$/.test(partialArg);
|
||||
const currentSegment = hasTrailingSpace
|
||||
? ''
|
||||
: tokens.length > 0
|
||||
? tokens[tokens.length - 1]
|
||||
: '';
|
||||
|
||||
const normalizedCurrent = normalizeInputMode(currentSegment).replace(
|
||||
/_/g,
|
||||
'-',
|
||||
);
|
||||
|
||||
const scopeValues = ['--session', '--project', '--user'];
|
||||
|
||||
const normalizeToken = (token: string) =>
|
||||
normalizeInputMode(token).replace(/_/g, '-');
|
||||
|
||||
const normalizedTokens = tokens.map(normalizeToken);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
if (currentSegment.startsWith('-')) {
|
||||
return scopeValues.filter((scope) => scope.startsWith(currentSegment));
|
||||
}
|
||||
return APPROVAL_MODES;
|
||||
}
|
||||
|
||||
if (tokens.length === 1 && !hasTrailingSpace) {
|
||||
const originalToken = tokens[0];
|
||||
if (originalToken.startsWith('-')) {
|
||||
return scopeValues.filter((scope) =>
|
||||
scope.startsWith(normalizedCurrent),
|
||||
);
|
||||
}
|
||||
return APPROVAL_MODES.filter((mode) =>
|
||||
mode.startsWith(normalizedCurrent),
|
||||
);
|
||||
}
|
||||
|
||||
if (tokens.length === 1 && hasTrailingSpace) {
|
||||
const normalizedFirst = normalizedTokens[0];
|
||||
if (scopeValues.includes(tokens[0])) {
|
||||
return APPROVAL_MODES;
|
||||
}
|
||||
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
|
||||
return scopeValues;
|
||||
}
|
||||
return APPROVAL_MODES;
|
||||
}
|
||||
|
||||
if (tokens.length === 2 && !hasTrailingSpace) {
|
||||
const normalizedFirst = normalizedTokens[0];
|
||||
if (scopeValues.includes(tokens[0])) {
|
||||
return APPROVAL_MODES.filter((mode) =>
|
||||
mode.startsWith(normalizedCurrent),
|
||||
);
|
||||
}
|
||||
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
|
||||
return scopeValues.filter((scope) =>
|
||||
scope.startsWith(normalizedCurrent),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'approval-mode',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import open from 'open';
|
||||
import { bugCommand } from './bugCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import * as systemInfoUtils from '../../utils/systemInfo.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../../utils/version.js');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: () => ({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('node:process', () => ({
|
||||
default: {
|
||||
platform: 'test-platform',
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/systemInfo.js');
|
||||
|
||||
describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: '0.1.0',
|
||||
osPlatform: 'test-platform',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test',
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
selectedAuthType: '',
|
||||
ideClient: 'VSCode',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
gitCommit:
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined,
|
||||
});
|
||||
vi.stubEnv('SANDBOX', 'qwen-test');
|
||||
});
|
||||
|
||||
@@ -55,19 +48,7 @@ describe('bugCommand', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -75,14 +56,21 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A test bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:**
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
@@ -99,19 +87,7 @@ describe('bugCommand', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -119,14 +95,21 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A custom bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:**
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
@@ -138,25 +121,30 @@ describe('bugCommand', () => {
|
||||
});
|
||||
|
||||
it('should include Base URL when auth type is OpenAI', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: '0.1.0',
|
||||
osPlatform: 'test-platform',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test',
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
selectedAuthType: AuthType.USE_OPENAI,
|
||||
ideClient: 'VSCode',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
gitCommit:
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getContentGeneratorConfig: () => ({
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: AuthType.USE_OPENAI,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -164,15 +152,22 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'OpenAI bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:** ${AuthType.USE_OPENAI}
|
||||
* **Auth Method:** ${AuthType.USE_OPENAI}
|
||||
* **Base URL:** https://api.openai.com/v1
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
*/
|
||||
|
||||
import open from 'open';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
name: 'bug',
|
||||
@@ -23,50 +23,20 @@ export const bugCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
const { config } = context.services;
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
const osVersion = `${process.platform} ${process.version}`;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, '');
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const baseUrl =
|
||||
selectedAuthType === AuthType.USE_OPENAI
|
||||
? config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
const fields = getSystemInfoFields(systemInfo);
|
||||
|
||||
let info = `
|
||||
* **CLI Version:** ${cliVersion}
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Session ID:** ${config?.getSessionId() || 'unknown'}
|
||||
* **Operating System:** ${osVersion}
|
||||
* **Sandbox Environment:** ${sandboxEnv}
|
||||
* **Auth Type:** ${selectedAuthType}`;
|
||||
if (baseUrl) {
|
||||
info += `\n* **Base URL:** ${baseUrl}`;
|
||||
}
|
||||
info += `
|
||||
* **Model Version:** ${modelVersion}
|
||||
* **Memory Usage:** ${memoryUsage}
|
||||
`;
|
||||
if (ideClient) {
|
||||
info += `* **IDE Client:** ${ideClient}\n`;
|
||||
// Generate bug report info using the same field configuration
|
||||
let info = '\n';
|
||||
for (const field of fields) {
|
||||
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
|
||||
}
|
||||
|
||||
let bugReportUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
|
||||
|
||||
const bugCommandSettings = config?.getBugCommand();
|
||||
const bugCommandSettings = context.services.config?.getBugCommand();
|
||||
if (bugCommandSettings?.urlTemplate) {
|
||||
bugReportUrl = bugCommandSettings.urlTemplate;
|
||||
}
|
||||
@@ -98,11 +68,3 @@ export const bugCommand: SlashCommand = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { terminalSetup } from '../utils/terminalSetup.js';
|
||||
export const terminalSetupCommand: SlashCommand = {
|
||||
name: 'terminal-setup',
|
||||
description:
|
||||
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
|
||||
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
action: async (): Promise<MessageActionReturn> => {
|
||||
|
||||
@@ -129,7 +129,8 @@ export interface OpenDialogActionReturn {
|
||||
| 'model'
|
||||
| 'subagent_create'
|
||||
| 'subagent_list'
|
||||
| 'permissions';
|
||||
| 'permissions'
|
||||
| 'approval-mode';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,127 +7,46 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
type SystemInfoField,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
|
||||
interface AboutBoxProps {
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
}
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
ideClient,
|
||||
}) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
CLI Version
|
||||
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
const fields = getSystemInfoFields(props);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{cliVersion}</Text>
|
||||
</Box>
|
||||
{fields.map((field: SystemInfoField) => (
|
||||
<Box key={field.key} flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{getFieldValue(field, props)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Git Commit
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{modelVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Sandbox
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{sandboxEnv}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
OS
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{osVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Auth Method
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{gcpProject && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
GCP Project
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{gcpProject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{ideClient && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
IDE Client
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{ideClient}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
183
packages/cli/src/ui/components/ApprovalModeDialog.tsx
Normal file
183
packages/cli/src/ui/components/ApprovalModeDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
|
||||
interface ApprovalModeDialogProps {
|
||||
/** Callback function when an approval mode is selected */
|
||||
onSelect: (mode: ApprovalMode | undefined, scope: SettingScope) => void;
|
||||
|
||||
/** The settings object */
|
||||
settings: LoadedSettings;
|
||||
|
||||
/** Current approval mode */
|
||||
currentMode: ApprovalMode;
|
||||
|
||||
/** Available terminal height for layout calculations */
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
const formatModeDescription = (mode: ApprovalMode): string => {
|
||||
switch (mode) {
|
||||
case ApprovalMode.PLAN:
|
||||
return 'Analyze only, do not modify files or execute commands';
|
||||
case ApprovalMode.DEFAULT:
|
||||
return 'Require approval for file edits or shell commands';
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return 'Automatically approve file edits';
|
||||
case ApprovalMode.YOLO:
|
||||
return 'Automatically approve all tools';
|
||||
default:
|
||||
return `${mode} mode`;
|
||||
}
|
||||
};
|
||||
|
||||
export function ApprovalModeDialog({
|
||||
onSelect,
|
||||
settings,
|
||||
currentMode,
|
||||
availableTerminalHeight: _availableTerminalHeight,
|
||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||
// Start with User scope by default
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Track the currently highlighted approval mode
|
||||
const [highlightedMode, setHighlightedMode] = useState<ApprovalMode>(
|
||||
currentMode || ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
// Generate approval mode items with inline descriptions
|
||||
const modeItems = APPROVAL_MODES.map((mode) => ({
|
||||
label: `${mode} - ${formatModeDescription(mode)}`,
|
||||
value: mode,
|
||||
key: mode,
|
||||
}));
|
||||
|
||||
// Find the index of the current mode
|
||||
const initialModeIndex = modeItems.findIndex(
|
||||
(item) => item.value === highlightedMode,
|
||||
);
|
||||
const safeInitialModeIndex = initialModeIndex >= 0 ? initialModeIndex : 0;
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: ApprovalMode) => {
|
||||
onSelect(mode, selectedScope);
|
||||
},
|
||||
[onSelect, selectedScope],
|
||||
);
|
||||
|
||||
const handleModeHighlight = (mode: ApprovalMode) => {
|
||||
setHighlightedMode(mode);
|
||||
};
|
||||
|
||||
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
}, []);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
onSelect(highlightedMode, scope);
|
||||
},
|
||||
[onSelect, highlightedMode],
|
||||
);
|
||||
|
||||
const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode'));
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onSelect(undefined, selectedScope);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Generate scope message for approval mode setting
|
||||
const otherScopeModifiedMessage = getScopeMessageForSetting(
|
||||
'tools.approvalMode',
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Check if user scope is selected but workspace has the setting
|
||||
const showWorkspacePriorityWarning =
|
||||
selectedScope === SettingScope.User &&
|
||||
otherScopeModifiedMessage.toLowerCase().includes('workspace');
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="row"
|
||||
padding={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Approval Mode Selection */}
|
||||
<Text bold={focusSection === 'mode'} wrap="truncate">
|
||||
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
|
||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={modeItems}
|
||||
initialIndex={safeInitialModeIndex}
|
||||
onSelect={handleModeSelect}
|
||||
onHighlight={handleModeHighlight}
|
||||
isFocused={focusSection === 'mode'}
|
||||
maxItemsToShow={10}
|
||||
showScrollArrows={false}
|
||||
showNumbers={focusSection === 'mode'}
|
||||
/>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Scope Selection */}
|
||||
<Box marginTop={1}>
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Warning when workspace setting will override user setting */}
|
||||
{showWorkspacePriorityWarning && (
|
||||
<>
|
||||
<Text color={theme.status.warning} wrap="wrap">
|
||||
⚠ Workspace approval mode exists and takes priority. User-level
|
||||
change will have no effect.
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select, Tab to change focus)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
@@ -180,6 +181,22 @@ export const DialogManager = ({
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={() => process.exit(0)}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isApprovalModeDialogOpen) {
|
||||
const currentMode = config.getApprovalMode();
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<ApprovalModeDialog
|
||||
settings={settings}
|
||||
currentMode={currentMode}
|
||||
onSelect={uiActions.handleApprovalModeSelect}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -71,15 +71,24 @@ describe('<HistoryItemDisplay />', () => {
|
||||
|
||||
it('renders AboutBox for "about" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
id: 1,
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: '1.0.0',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-project',
|
||||
ideClient: 'test-ide',
|
||||
systemInfo: {
|
||||
cliVersion: '1.0.0',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
gitCommit: undefined,
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
|
||||
@@ -95,15 +95,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox
|
||||
cliVersion={itemForDisplay.cliVersion}
|
||||
osVersion={itemForDisplay.osVersion}
|
||||
sandboxEnv={itemForDisplay.sandboxEnv}
|
||||
modelVersion={itemForDisplay.modelVersion}
|
||||
selectedAuthType={itemForDisplay.selectedAuthType}
|
||||
gcpProject={itemForDisplay.gcpProject}
|
||||
ideClient={itemForDisplay.ideClient}
|
||||
/>
|
||||
<AboutBox {...itemForDisplay.systemInfo} />
|
||||
)}
|
||||
{itemForDisplay.type === 'help' && commands && (
|
||||
<Help commands={commands} />
|
||||
|
||||
@@ -13,15 +13,21 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
interface OpenAIKeyPromptProps {
|
||||
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
||||
onCancel: () => void;
|
||||
defaultApiKey?: string;
|
||||
defaultBaseUrl?: string;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export function OpenAIKeyPrompt({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
defaultApiKey,
|
||||
defaultBaseUrl,
|
||||
defaultModel,
|
||||
}: OpenAIKeyPromptProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [apiKey, setApiKey] = useState(defaultApiKey || '');
|
||||
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
|
||||
const [model, setModel] = useState(defaultModel || '');
|
||||
const [currentField, setCurrentField] = useState<
|
||||
'apiKey' | 'baseUrl' | 'model'
|
||||
>('apiKey');
|
||||
|
||||
@@ -9,11 +9,8 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { LoadedSettings, Settings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
getScopeItems,
|
||||
getScopeMessageForSetting,
|
||||
} from '../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
import {
|
||||
getDialogSettingKeys,
|
||||
setPendingSettingValue,
|
||||
@@ -30,6 +27,7 @@ import {
|
||||
getEffectiveValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import chalk from 'chalk';
|
||||
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
|
||||
@@ -43,6 +41,7 @@ interface SettingsDialogProps {
|
||||
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
||||
onRestartRequest?: () => void;
|
||||
availableTerminalHeight?: number;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
const maxItemsToShow = 8;
|
||||
@@ -52,6 +51,7 @@ export function SettingsDialog({
|
||||
onSelect,
|
||||
onRestartRequest,
|
||||
availableTerminalHeight,
|
||||
config,
|
||||
}: SettingsDialogProps): React.JSX.Element {
|
||||
// Get vim mode context to sync vim mode changes
|
||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||
@@ -184,6 +184,21 @@ export function SettingsDialog({
|
||||
});
|
||||
}
|
||||
|
||||
// Special handling for approval mode to apply to current session
|
||||
if (
|
||||
key === 'tools.approvalMode' &&
|
||||
settings.merged.tools?.approvalMode
|
||||
) {
|
||||
try {
|
||||
config?.setApprovalMode(settings.merged.tools.approvalMode);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to apply approval mode to current session:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from modifiedSettings since it's now saved
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev);
|
||||
@@ -357,12 +372,6 @@ export function SettingsDialog({
|
||||
setEditCursorPos(0);
|
||||
};
|
||||
|
||||
// Scope selector items
|
||||
const scopeItems = getScopeItems().map((item) => ({
|
||||
...item,
|
||||
key: item.value,
|
||||
}));
|
||||
|
||||
const handleScopeHighlight = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
};
|
||||
@@ -616,7 +625,11 @@ export function SettingsDialog({
|
||||
prev,
|
||||
),
|
||||
);
|
||||
} else if (defType === 'number' || defType === 'string') {
|
||||
} else if (
|
||||
defType === 'number' ||
|
||||
defType === 'string' ||
|
||||
defType === 'enum'
|
||||
) {
|
||||
if (
|
||||
typeof defaultValue === 'number' ||
|
||||
typeof defaultValue === 'string'
|
||||
@@ -673,6 +686,21 @@ export function SettingsDialog({
|
||||
selectedScope,
|
||||
);
|
||||
|
||||
// Special handling for approval mode to apply to current session
|
||||
if (
|
||||
currentSetting.value === 'tools.approvalMode' &&
|
||||
settings.merged.tools?.approvalMode
|
||||
) {
|
||||
try {
|
||||
config?.setApprovalMode(settings.merged.tools.approvalMode);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to apply approval mode to current session:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from global pending changes if present
|
||||
setGlobalPendingChanges((prev) => {
|
||||
if (!prev.has(currentSetting.value)) return prev;
|
||||
@@ -876,19 +904,12 @@ export function SettingsDialog({
|
||||
|
||||
{/* Scope Selection - conditionally visible based on height constraints */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={focusSection === 'scope'} wrap="truncate">
|
||||
{focusSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={scopeItems.findIndex(
|
||||
(item) => item.value === selectedScope,
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
showNumbers={focusSection === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -63,7 +62,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -98,7 +96,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -133,7 +130,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -168,7 +164,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -203,7 +198,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -238,7 +232,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -273,7 +266,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -308,7 +300,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
@@ -343,7 +334,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ │
|
||||
|
||||
@@ -6,7 +6,6 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
|
||||
│ > Apply To │
|
||||
│ ● 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ 3. System Settings │
|
||||
│ │
|
||||
│ (Use Enter to apply scope, Tab to select theme) │
|
||||
│ │
|
||||
|
||||
@@ -8,7 +8,11 @@ import { createContext, useContext } from 'react';
|
||||
import { type Key } from '../hooks/useKeypress.js';
|
||||
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
||||
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||
import { type AuthType, type EditorType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
type AuthType,
|
||||
type EditorType,
|
||||
type ApprovalMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||
@@ -19,6 +23,10 @@ export interface UIActions {
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
handleThemeHighlight: (themeName: string | undefined) => void;
|
||||
handleApprovalModeSelect: (
|
||||
mode: ApprovalMode | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
handleAuthSelect: (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface UIState {
|
||||
isSettingsDialogOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
slashCommands: readonly SlashCommand[];
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
|
||||
vscodium: 'VSCodium',
|
||||
windsurf: 'Windsurf',
|
||||
zed: 'Zed',
|
||||
trae: 'Trae',
|
||||
};
|
||||
|
||||
class EditorSettingsManager {
|
||||
|
||||
@@ -80,6 +80,8 @@ describe('handleAtCommand', () => {
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
|
||||
const registry = new ToolRegistry(mockConfig);
|
||||
|
||||
@@ -48,6 +48,7 @@ interface SlashCommandProcessorActions {
|
||||
openSettingsDialog: () => void;
|
||||
openModelDialog: () => void;
|
||||
openPermissionsDialog: () => void;
|
||||
openApprovalModeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
@@ -138,13 +139,7 @@ export const useSlashCommandProcessor = (
|
||||
if (message.type === MessageType.ABOUT) {
|
||||
historyItemContent = {
|
||||
type: 'about',
|
||||
cliVersion: message.cliVersion,
|
||||
osVersion: message.osVersion,
|
||||
sandboxEnv: message.sandboxEnv,
|
||||
modelVersion: message.modelVersion,
|
||||
selectedAuthType: message.selectedAuthType,
|
||||
gcpProject: message.gcpProject,
|
||||
ideClient: message.ideClient,
|
||||
systemInfo: message.systemInfo,
|
||||
};
|
||||
} else if (message.type === MessageType.HELP) {
|
||||
historyItemContent = {
|
||||
@@ -402,6 +397,9 @@ export const useSlashCommandProcessor = (
|
||||
case 'subagent_list':
|
||||
actions.openAgentsManagerDialog();
|
||||
return { type: 'handled' };
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
default: {
|
||||
|
||||
57
packages/cli/src/ui/hooks/useApprovalModeCommand.ts
Normal file
57
packages/cli/src/ui/hooks/useApprovalModeCommand.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ApprovalMode, Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
interface UseApprovalModeCommandReturn {
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
openApprovalModeDialog: () => void;
|
||||
handleApprovalModeSelect: (
|
||||
mode: ApprovalMode | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useApprovalModeCommand = (
|
||||
loadedSettings: LoadedSettings,
|
||||
config: Config,
|
||||
): UseApprovalModeCommandReturn => {
|
||||
const [isApprovalModeDialogOpen, setIsApprovalModeDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const openApprovalModeDialog = useCallback(() => {
|
||||
setIsApprovalModeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleApprovalModeSelect = useCallback(
|
||||
(mode: ApprovalMode | undefined, scope: SettingScope) => {
|
||||
try {
|
||||
if (!mode) {
|
||||
// User cancelled the dialog
|
||||
setIsApprovalModeDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the mode in the current session and persist to settings
|
||||
loadedSettings.setValue(scope, 'tools.approvalMode', mode);
|
||||
config.setApprovalMode(
|
||||
loadedSettings.merged.tools?.approvalMode ?? mode,
|
||||
);
|
||||
} finally {
|
||||
setIsApprovalModeDialogOpen(false);
|
||||
}
|
||||
},
|
||||
[config, loadedSettings],
|
||||
);
|
||||
|
||||
return {
|
||||
isApprovalModeDialogOpen,
|
||||
openApprovalModeDialog,
|
||||
handleApprovalModeSelect,
|
||||
};
|
||||
};
|
||||
151
packages/cli/src/ui/hooks/useAttentionNotifications.test.ts
Normal file
151
packages/cli/src/ui/hooks/useAttentionNotifications.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
AttentionNotificationReason,
|
||||
notifyTerminalAttention,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
import {
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
|
||||
useAttentionNotifications,
|
||||
} from './useAttentionNotifications.js';
|
||||
|
||||
vi.mock('../../utils/attentionNotification.js', () => ({
|
||||
notifyTerminalAttention: vi.fn(),
|
||||
AttentionNotificationReason: {
|
||||
ToolApproval: 'tool_approval',
|
||||
LongTaskComplete: 'long_task_complete',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedNotify = vi.mocked(notifyTerminalAttention);
|
||||
|
||||
describe('useAttentionNotifications', () => {
|
||||
beforeEach(() => {
|
||||
mockedNotify.mockReset();
|
||||
});
|
||||
|
||||
const render = (
|
||||
props?: Partial<Parameters<typeof useAttentionNotifications>[0]>,
|
||||
) =>
|
||||
renderHook(({ hookProps }) => useAttentionNotifications(hookProps), {
|
||||
initialProps: {
|
||||
hookProps: {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('notifies when tool approval is required while unfocused', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
);
|
||||
});
|
||||
|
||||
it('notifies when focus is lost after entering approval wait state', () => {
|
||||
const { rerender } = render({
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends a notification when a long task finishes while unfocused', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
|
||||
},
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not notify about long tasks when the CLI is focused', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
|
||||
},
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).not.toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat short responses as long tasks', () => {
|
||||
const { rerender } = render();
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 5,
|
||||
},
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
63
packages/cli/src/ui/hooks/useAttentionNotifications.ts
Normal file
63
packages/cli/src/ui/hooks/useAttentionNotifications.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
|
||||
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
|
||||
|
||||
interface UseAttentionNotificationsOptions {
|
||||
isFocused: boolean;
|
||||
streamingState: StreamingState;
|
||||
elapsedTime: number;
|
||||
}
|
||||
|
||||
export const useAttentionNotifications = ({
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
}: UseAttentionNotificationsOptions) => {
|
||||
const awaitingNotificationSentRef = useRef(false);
|
||||
const respondingElapsedRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
streamingState === StreamingState.WaitingForConfirmation &&
|
||||
!isFocused &&
|
||||
!awaitingNotificationSentRef.current
|
||||
) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
|
||||
awaitingNotificationSentRef.current = true;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
|
||||
awaitingNotificationSentRef.current = false;
|
||||
}
|
||||
}, [isFocused, streamingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
respondingElapsedRef.current = elapsedTime;
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamingState === StreamingState.Idle) {
|
||||
const wasLongTask =
|
||||
respondingElapsedRef.current >=
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
|
||||
if (wasLongTask && !isFocused) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
|
||||
}
|
||||
// Reset tracking for next task
|
||||
respondingElapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
}, [streamingState, elapsedTime, isFocused]);
|
||||
};
|
||||
@@ -6,13 +6,20 @@
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface DialogCloseOptions {
|
||||
// Theme dialog
|
||||
isThemeDialogOpen: boolean;
|
||||
handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void;
|
||||
|
||||
// Approval mode dialog
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
handleApprovalModeSelect: (
|
||||
mode: ApprovalMode | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
|
||||
// Auth dialog
|
||||
isAuthDialogOpen: boolean;
|
||||
handleAuthSelect: (
|
||||
@@ -57,6 +64,12 @@ export function useDialogClose(options: DialogCloseOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.isApprovalModeDialogOpen) {
|
||||
// Mimic ESC behavior: onSelect(undefined, selectedScope) - keeps current mode
|
||||
options.handleApprovalModeSelect(undefined, SettingScope.User);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.isEditorDialogOpen) {
|
||||
// Mimic ESC behavior: call onExit() directly
|
||||
options.exitEditorDialog();
|
||||
|
||||
@@ -120,13 +120,22 @@ export type HistoryItemWarning = HistoryItemBase & {
|
||||
|
||||
export type HistoryItemAbout = HistoryItemBase & {
|
||||
type: 'about';
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
systemInfo: {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HistoryItemHelp = HistoryItemBase & {
|
||||
@@ -288,13 +297,22 @@ export type Message =
|
||||
| {
|
||||
type: MessageType.ABOUT;
|
||||
timestamp: Date;
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
systemInfo: {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
};
|
||||
content?: string; // Optional content, not really used for ABOUT
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface TerminalSetupResult {
|
||||
requiresRestart?: boolean;
|
||||
}
|
||||
|
||||
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
|
||||
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'trae';
|
||||
|
||||
// Terminal detection
|
||||
async function detectTerminal(): Promise<SupportedTerminal | null> {
|
||||
@@ -68,6 +68,11 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
|
||||
) {
|
||||
return 'windsurf';
|
||||
}
|
||||
|
||||
if (process.env['TERM_PRODUCT']?.toLowerCase().includes('trae')) {
|
||||
return 'trae';
|
||||
}
|
||||
|
||||
// Check VS Code last since forks may also set VSCODE env vars
|
||||
if (termProgram === 'vscode' || process.env['VSCODE_GIT_IPC_HANDLE']) {
|
||||
return 'vscode';
|
||||
@@ -86,6 +91,8 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
|
||||
return 'cursor';
|
||||
if (parentName.includes('code') || parentName.includes('Code'))
|
||||
return 'vscode';
|
||||
if (parentName.includes('trae') || parentName.includes('Trae'))
|
||||
return 'trae';
|
||||
} catch (error) {
|
||||
// Continue detection even if process check fails
|
||||
console.debug('Parent process detection failed:', error);
|
||||
@@ -287,6 +294,10 @@ async function configureWindsurf(): Promise<TerminalSetupResult> {
|
||||
return configureVSCodeStyle('Windsurf', 'Windsurf');
|
||||
}
|
||||
|
||||
async function configureTrae(): Promise<TerminalSetupResult> {
|
||||
return configureVSCodeStyle('Trae', 'Trae');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main terminal setup function that detects and configures the current terminal.
|
||||
*
|
||||
@@ -333,6 +344,8 @@ export async function terminalSetup(): Promise<TerminalSetupResult> {
|
||||
return configureCursor();
|
||||
case 'windsurf':
|
||||
return configureWindsurf();
|
||||
case 'trae':
|
||||
return configureTrae();
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
|
||||
72
packages/cli/src/utils/attentionNotification.test.ts
Normal file
72
packages/cli/src/utils/attentionNotification.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from './attentionNotification.js';
|
||||
|
||||
describe('notifyTerminalAttention', () => {
|
||||
let stream: { write: ReturnType<typeof vi.fn>; isTTY: boolean };
|
||||
|
||||
beforeEach(() => {
|
||||
stream = { write: vi.fn().mockReturnValue(true), isTTY: true };
|
||||
});
|
||||
|
||||
it('emits terminal bell character', () => {
|
||||
const result = notifyTerminalAttention(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(stream.write).toHaveBeenCalledWith('\u0007');
|
||||
});
|
||||
|
||||
it('returns false when not running inside a tty', () => {
|
||||
stream.isTTY = false;
|
||||
|
||||
const result = notifyTerminalAttention(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ stream },
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(stream.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when stream write fails', () => {
|
||||
stream.write = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
const result = notifyTerminalAttention(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ stream },
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('works with different notification reasons', () => {
|
||||
const reasons = [
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
];
|
||||
|
||||
reasons.forEach((reason) => {
|
||||
stream.write.mockClear();
|
||||
|
||||
const result = notifyTerminalAttention(reason, { stream });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(stream.write).toHaveBeenCalledWith('\u0007');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
packages/cli/src/utils/attentionNotification.ts
Normal file
43
packages/cli/src/utils/attentionNotification.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
export enum AttentionNotificationReason {
|
||||
ToolApproval = 'tool_approval',
|
||||
LongTaskComplete = 'long_task_complete',
|
||||
}
|
||||
|
||||
export interface TerminalNotificationOptions {
|
||||
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
|
||||
}
|
||||
|
||||
const TERMINAL_BELL = '\u0007';
|
||||
|
||||
/**
|
||||
* Grabs the user's attention by emitting the terminal bell character.
|
||||
* This causes the terminal to flash or play a sound, alerting the user
|
||||
* to check the CLI for important events.
|
||||
*
|
||||
* @returns true when the bell was successfully written to the terminal.
|
||||
*/
|
||||
export function notifyTerminalAttention(
|
||||
_reason: AttentionNotificationReason,
|
||||
options: TerminalNotificationOptions = {},
|
||||
): boolean {
|
||||
const stream = options.stream ?? process.stdout;
|
||||
if (!stream?.write || stream.isTTY === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
stream.write(TERMINAL_BELL);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send terminal bell:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ import { settingExistsInScope } from './settingsUtils.js';
|
||||
export const SCOPE_LABELS = {
|
||||
[SettingScope.User]: 'User Settings',
|
||||
[SettingScope.Workspace]: 'Workspace Settings',
|
||||
[SettingScope.System]: 'System Settings',
|
||||
|
||||
// TODO: migrate system settings to user settings
|
||||
// we don't want to save settings to system scope, it is a troublemaker
|
||||
// comment it out for now.
|
||||
// [SettingScope.System]: 'System Settings',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -27,7 +31,7 @@ export function getScopeItems() {
|
||||
label: SCOPE_LABELS[SettingScope.Workspace],
|
||||
value: SettingScope.Workspace,
|
||||
},
|
||||
{ label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
|
||||
// { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getSystemInfo,
|
||||
getExtendedSystemInfo,
|
||||
getNpmVersion,
|
||||
getSandboxEnv,
|
||||
getIdeClientName,
|
||||
} from './systemInfo.js';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import * as child_process from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as versionUtils from './version.js';
|
||||
import type { ExecSyncOptions } from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
release: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('systemInfo', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
const originalVersion = process.version;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
baseUrl: 'https://api.openai.com',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'test-auth',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
vi.mocked(os.release).mockReturnValue('22.0.0');
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: 'x64',
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: 'v20.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: originalArch,
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: originalVersion,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getNpmVersion', () => {
|
||||
it('should return npm version when available', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('10.0.0');
|
||||
});
|
||||
|
||||
it('should return unknown when npm command fails', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(() => {
|
||||
throw new Error('npm not found');
|
||||
});
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxEnv', () => {
|
||||
it('should return "no sandbox" when SANDBOX is not set', () => {
|
||||
delete process.env['SANDBOX'];
|
||||
expect(getSandboxEnv()).toBe('no sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)');
|
||||
});
|
||||
|
||||
it('should return sandbox name without prefix when stripPrefix is true', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox name with prefix when stripPrefix is false', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox');
|
||||
});
|
||||
|
||||
it('should handle qwen- prefix removal', () => {
|
||||
process.env['SANDBOX'] = 'qwen-custom-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('custom-sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdeClientName', () => {
|
||||
it('should return IDE client name when IDE mode is enabled', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('test-ide');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE mode is disabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE client detection fails', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockRejectedValue(
|
||||
new Error('IDE client error'),
|
||||
);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemInfo', () => {
|
||||
it('should collect all system information', async () => {
|
||||
// Ensure SANDBOX is not set for this test
|
||||
delete process.env['SANDBOX'];
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo).toEqual({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing config gracefully', async () => {
|
||||
mockContext.services.config = null;
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo.modelVersion).toBe('Unknown');
|
||||
expect(systemInfo.sessionId).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtendedSystemInfo', () => {
|
||||
it('should include memory usage and base URL', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const { AuthType } = await import('@qwen-code/qwen-code-core');
|
||||
// Update the mock context to use OpenAI auth
|
||||
mockContext.services.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_OPENAI;
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.memoryUsage).toBeDefined();
|
||||
expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/);
|
||||
expect(extendedInfo.baseUrl).toBe('https://api.openai.com');
|
||||
});
|
||||
|
||||
it('should use sandbox env without prefix for bug reports', async () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.sandboxEnv).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should not include base URL for non-OpenAI auth', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.baseUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
packages/cli/src/utils/systemInfo.ts
Normal file
173
packages/cli/src/utils/systemInfo.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import os from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { getCliVersion } from './version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { formatMemoryUsage } from '../ui/utils/formatters.js';
|
||||
import { GIT_COMMIT_INFO } from '../generated/git-commit.js';
|
||||
|
||||
/**
|
||||
* System information interface containing all system-related details
|
||||
* that can be collected for debugging and reporting purposes.
|
||||
*/
|
||||
export interface SystemInfo {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional system information for bug reports
|
||||
*/
|
||||
export interface ExtendedSystemInfo extends SystemInfo {
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the NPM version, handling cases where npm might not be available.
|
||||
* Returns 'unknown' if npm command fails or is not found.
|
||||
*/
|
||||
export async function getNpmVersion(): Promise<string> {
|
||||
try {
|
||||
return execSync('npm --version', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IDE client name if IDE mode is enabled.
|
||||
* Returns empty string if IDE mode is disabled or IDE client is not detected.
|
||||
*/
|
||||
export async function getIdeClientName(
|
||||
context: CommandContext,
|
||||
): Promise<string> {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sandbox environment information.
|
||||
* Handles different sandbox types including sandbox-exec and custom sandbox environments.
|
||||
* For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names.
|
||||
*
|
||||
* @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports)
|
||||
*/
|
||||
export function getSandboxEnv(stripPrefix = false): string {
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
|
||||
if (!sandbox || sandbox === 'sandbox-exec') {
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
const profile = process.env['SEATBELT_PROFILE'] || 'unknown';
|
||||
return `sandbox-exec (${profile})`;
|
||||
}
|
||||
return 'no sandbox';
|
||||
}
|
||||
|
||||
// For bug reports, remove qwen- prefix
|
||||
if (stripPrefix) {
|
||||
return sandbox.replace(/^qwen-(?:code-)?/, '');
|
||||
}
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects comprehensive system information for debugging and reporting.
|
||||
* This function gathers all system-related details including OS, versions,
|
||||
* sandbox environment, authentication, and session information.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to SystemInfo object with all collected information
|
||||
*/
|
||||
export async function getSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<SystemInfo> {
|
||||
const osPlatform = process.platform;
|
||||
const osArch = process.arch;
|
||||
const osRelease = os.release();
|
||||
const nodeVersion = process.version;
|
||||
const npmVersion = await getNpmVersion();
|
||||
const sandboxEnv = getSandboxEnv();
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||
|
||||
return {
|
||||
cliVersion,
|
||||
osPlatform,
|
||||
osArch,
|
||||
osRelease,
|
||||
nodeVersion,
|
||||
npmVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
ideClient,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects extended system information for bug reports.
|
||||
* Includes all standard system info plus memory usage and optional base URL.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to ExtendedSystemInfo object
|
||||
*/
|
||||
export async function getExtendedSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<ExtendedSystemInfo> {
|
||||
const baseInfo = await getSystemInfo(context);
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
|
||||
// For bug reports, use sandbox name without prefix
|
||||
const sandboxEnv = getSandboxEnv(true);
|
||||
|
||||
// Get base URL if using OpenAI auth
|
||||
const baseUrl =
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI
|
||||
? context.services.config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
|
||||
// Get git commit info
|
||||
const gitCommit =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...baseInfo,
|
||||
sandboxEnv,
|
||||
memoryUsage,
|
||||
baseUrl,
|
||||
gitCommit,
|
||||
};
|
||||
}
|
||||
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
|
||||
/**
|
||||
* Field configuration for system information display
|
||||
*/
|
||||
export interface SystemInfoField {
|
||||
label: string;
|
||||
key: keyof ExtendedSystemInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified field configuration for system information display.
|
||||
* This ensures consistent labeling between /about and /bug commands.
|
||||
*/
|
||||
export function getSystemInfoFields(
|
||||
info: ExtendedSystemInfo,
|
||||
): SystemInfoField[] {
|
||||
const allFields: SystemInfoField[] = [
|
||||
{
|
||||
label: 'CLI Version',
|
||||
key: 'cliVersion',
|
||||
},
|
||||
{
|
||||
label: 'Git Commit',
|
||||
key: 'gitCommit',
|
||||
},
|
||||
{
|
||||
label: 'Model',
|
||||
key: 'modelVersion',
|
||||
},
|
||||
{
|
||||
label: 'Sandbox',
|
||||
key: 'sandboxEnv',
|
||||
},
|
||||
{
|
||||
label: 'OS Platform',
|
||||
key: 'osPlatform',
|
||||
},
|
||||
{
|
||||
label: 'OS Arch',
|
||||
key: 'osArch',
|
||||
},
|
||||
{
|
||||
label: 'OS Release',
|
||||
key: 'osRelease',
|
||||
},
|
||||
{
|
||||
label: 'Node.js Version',
|
||||
key: 'nodeVersion',
|
||||
},
|
||||
{
|
||||
label: 'NPM Version',
|
||||
key: 'npmVersion',
|
||||
},
|
||||
{
|
||||
label: 'Session ID',
|
||||
key: 'sessionId',
|
||||
},
|
||||
{
|
||||
label: 'Auth Method',
|
||||
key: 'selectedAuthType',
|
||||
},
|
||||
{
|
||||
label: 'Base URL',
|
||||
key: 'baseUrl',
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage',
|
||||
key: 'memoryUsage',
|
||||
},
|
||||
{
|
||||
label: 'IDE Client',
|
||||
key: 'ideClient',
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out optional fields that are not present
|
||||
return allFields.filter((field) => {
|
||||
const value = info[field.key];
|
||||
// Optional fields: only show if they exist and are non-empty
|
||||
if (
|
||||
field.key === 'baseUrl' ||
|
||||
field.key === 'gitCommit' ||
|
||||
field.key === 'ideClient'
|
||||
) {
|
||||
return Boolean(value);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a field from system info
|
||||
*/
|
||||
export function getFieldValue(
|
||||
field: SystemInfoField,
|
||||
info: ExtendedSystemInfo,
|
||||
): string {
|
||||
const value = info[field.key];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Special formatting for selectedAuthType
|
||||
if (field.key === 'selectedAuthType') {
|
||||
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
|
||||
|
||||
import { z } from 'zod';
|
||||
import { EOL } from 'node:os';
|
||||
import * as schema from './schema.js';
|
||||
export * from './schema.js';
|
||||
|
||||
@@ -173,7 +172,7 @@ class Connection {
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of output) {
|
||||
content += decoder.decode(chunk, { stream: true });
|
||||
const lines = content.split(EOL);
|
||||
const lines = content.split('\n');
|
||||
content = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -128,6 +128,14 @@ export type AgentRequest = z.infer<typeof agentRequestSchema>;
|
||||
|
||||
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
|
||||
|
||||
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
|
||||
|
||||
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
|
||||
|
||||
export type AvailableCommandsUpdate = z.infer<
|
||||
typeof availableCommandsUpdateSchema
|
||||
>;
|
||||
|
||||
export const writeTextFileRequestSchema = z.object({
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
@@ -386,6 +394,21 @@ export const promptRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandInputSchema = z.object({
|
||||
hint: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandSchema = z.object({
|
||||
description: z.string(),
|
||||
input: availableCommandInputSchema.nullable().optional(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandsUpdateSchema = z.object({
|
||||
availableCommands: z.array(availableCommandSchema),
|
||||
sessionUpdate: z.literal('available_commands_update'),
|
||||
});
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
@@ -423,6 +446,7 @@ export const sessionUpdateSchema = z.union([
|
||||
entries: z.array(planEntrySchema),
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
|
||||
@@ -12,6 +12,12 @@ import type {
|
||||
GeminiChat,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResult,
|
||||
SubAgentEventEmitter,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
@@ -25,9 +31,15 @@ import {
|
||||
MCPServerConfig,
|
||||
ToolConfirmationOutcome,
|
||||
logToolCall,
|
||||
logUserPrompt,
|
||||
getErrorStatus,
|
||||
isWithinRoot,
|
||||
isNodeError,
|
||||
SubAgentEventType,
|
||||
TaskTool,
|
||||
Kind,
|
||||
TodoWriteTool,
|
||||
UserPromptEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as acp from './acp.js';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -43,6 +55,26 @@ import { ExtensionStorage, type Extension } from '../config/extension.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
|
||||
import {
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
} from '../nonInteractiveCliCommands.js';
|
||||
import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js';
|
||||
import { isSlashCommand } from '../ui/utils/commandUtils.js';
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in ACP integration mode.
|
||||
* Only these commands will be available when using handleSlashCommand
|
||||
* or getAvailableCommands in ACP integration.
|
||||
*
|
||||
* Currently, only "init" is supported because `handleSlashCommand` in
|
||||
* nonInteractiveCliCommands.ts only supports handling results where
|
||||
* result.type is "submit_prompt". Other result types are either coupled
|
||||
* to the UI or cannot send notifications to the client via ACP.
|
||||
*
|
||||
* If you have a good idea to add support for more commands, PRs are welcome!
|
||||
*/
|
||||
const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
||||
|
||||
/**
|
||||
* Resolves the model to use based on the current configuration.
|
||||
@@ -141,7 +173,7 @@ class GeminiAgent {
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const sessionId = this.config.getSessionId() || randomUUID();
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
let isAuthenticated = false;
|
||||
@@ -172,9 +204,20 @@ class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
const session = new Session(sessionId, chat, config, this.client);
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.client,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Send available commands update as the first session update
|
||||
setTimeout(async () => {
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
};
|
||||
@@ -232,12 +275,14 @@ class GeminiAgent {
|
||||
|
||||
class Session {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private turn: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
private readonly config: Config,
|
||||
private readonly client: acp.Client,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
@@ -254,10 +299,57 @@ class Session {
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
||||
const promptId = Math.random().toString(16).slice(2);
|
||||
const chat = this.chat;
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
const chat = this.chat;
|
||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||
|
||||
// Extract text from all text blocks to construct the full prompt text for logging
|
||||
const promptText = params.prompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Log user prompt
|
||||
logUserPrompt(
|
||||
this.config,
|
||||
new UserPromptEvent(
|
||||
promptText.length,
|
||||
promptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
promptText,
|
||||
),
|
||||
);
|
||||
|
||||
// Check if the input contains a slash command
|
||||
// Extract text from the first text block if present
|
||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[];
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - allow specific built-in commands for ACP integration
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
if (slashCommandResult) {
|
||||
// Use the result from the slash command
|
||||
parts = slashCommandResult as Part[];
|
||||
} else {
|
||||
// Slash command didn't return a prompt, continue with normal processing
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
@@ -351,6 +443,37 @@ class Session {
|
||||
await this.client.sessionUpdate(params);
|
||||
}
|
||||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const slashCommands = await getAvailableCommands(
|
||||
this.config,
|
||||
this.settings,
|
||||
abortController.signal,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||
const availableCommands: AvailableCommand[] = slashCommands.map(
|
||||
(cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
input: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const update: AvailableCommandsUpdate = {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
};
|
||||
|
||||
await this.sendUpdate(update);
|
||||
} catch (error) {
|
||||
// Log error but don't fail session creation
|
||||
console.error('Error sending available commands update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async runTool(
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
@@ -403,9 +526,34 @@ class Session {
|
||||
);
|
||||
}
|
||||
|
||||
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
|
||||
const isTodoWriteTool =
|
||||
fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name;
|
||||
|
||||
// Declare subAgentToolEventListeners outside try block for cleanup in catch
|
||||
let subAgentToolEventListeners: Array<() => void> = [];
|
||||
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
|
||||
// Detect TaskTool and set up sub-agent tool tracking
|
||||
const isTaskTool = tool.name === TaskTool.Name;
|
||||
|
||||
if (isTaskTool && 'eventEmitter' in invocation) {
|
||||
// Access eventEmitter from TaskTool invocation
|
||||
const taskEventEmitter = (
|
||||
invocation as {
|
||||
eventEmitter: SubAgentEventEmitter;
|
||||
}
|
||||
).eventEmitter;
|
||||
|
||||
// Set up sub-agent tool tracking
|
||||
subAgentToolEventListeners = this.setupSubAgentToolTracking(
|
||||
taskEventEmitter,
|
||||
abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
@@ -460,7 +608,8 @@ class Session {
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (!isTodoWriteTool) {
|
||||
// Skip tool_call event for TodoWriteTool
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: callId,
|
||||
@@ -473,14 +622,61 @@ class Session {
|
||||
}
|
||||
|
||||
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||
const content = toToolCallContent(toolResult);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
content: content ? [content] : [],
|
||||
});
|
||||
// Clean up event listeners
|
||||
subAgentToolEventListeners.forEach((cleanup) => cleanup());
|
||||
|
||||
// Handle TodoWriteTool: extract todos and send plan update
|
||||
if (isTodoWriteTool) {
|
||||
// Extract todos from args (initial state)
|
||||
let todos: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}> = [];
|
||||
|
||||
if (Array.isArray(args['todos'])) {
|
||||
todos = args['todos'] as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
}
|
||||
|
||||
// If returnDisplay has todos (e.g., modified by user), use those instead
|
||||
if (
|
||||
toolResult.returnDisplay &&
|
||||
typeof toolResult.returnDisplay === 'object' &&
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'todo_list' &&
|
||||
'todos' in toolResult.returnDisplay &&
|
||||
Array.isArray(toolResult.returnDisplay.todos)
|
||||
) {
|
||||
todos = toolResult.returnDisplay.todos;
|
||||
}
|
||||
|
||||
// Convert todos to plan entries and send plan update
|
||||
if (todos.length > 0 || Array.isArray(args['todos'])) {
|
||||
const planEntries = convertTodosToPlanEntries(todos);
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'plan',
|
||||
entries: planEntries,
|
||||
});
|
||||
}
|
||||
|
||||
// Skip tool_call_update event for TodoWriteTool
|
||||
// Still log and return function response for LLM
|
||||
} else {
|
||||
// Normal tool handling: send tool_call_update
|
||||
const content = toToolCallContent(toolResult);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
content: content ? [content] : [],
|
||||
});
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
@@ -500,6 +696,9 @@ class Session {
|
||||
|
||||
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
||||
} catch (e) {
|
||||
// Ensure cleanup on error
|
||||
subAgentToolEventListeners.forEach((cleanup) => cleanup());
|
||||
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
|
||||
await this.sendUpdate({
|
||||
@@ -515,6 +714,300 @@ class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners to track sub-agent tool calls within a TaskTool execution.
|
||||
* Converts subagent tool call events into zedIntegration session updates.
|
||||
*
|
||||
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
|
||||
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
||||
* @returns Array of cleanup functions to remove event listeners
|
||||
*/
|
||||
private setupSubAgentToolTracking(
|
||||
eventEmitter: SubAgentEventEmitter,
|
||||
abortSignal: AbortSignal,
|
||||
): Array<() => void> {
|
||||
const cleanupFunctions: Array<() => void> = [];
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
|
||||
// Track subagent tool call states
|
||||
const subAgentToolStates = new Map<
|
||||
string,
|
||||
{
|
||||
tool?: AnyDeclarativeTool;
|
||||
invocation?: AnyToolInvocation;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Listen for tool call start
|
||||
const onToolCall = (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentToolCallEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const subAgentTool = toolRegistry.getTool(event.name);
|
||||
let subAgentInvocation: AnyToolInvocation | undefined;
|
||||
let toolKind: acp.ToolKind = 'other';
|
||||
let locations: acp.ToolCallLocation[] = [];
|
||||
|
||||
if (subAgentTool) {
|
||||
try {
|
||||
subAgentInvocation = subAgentTool.build(event.args);
|
||||
toolKind = this.mapToolKind(subAgentTool.kind);
|
||||
locations = subAgentInvocation.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
// If building fails, continue with defaults
|
||||
console.warn(`Failed to build subagent tool ${event.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save state for subsequent updates
|
||||
subAgentToolStates.set(event.callId, {
|
||||
tool: subAgentTool,
|
||||
invocation: subAgentInvocation,
|
||||
args: event.args,
|
||||
});
|
||||
|
||||
// Check if this is TodoWriteTool - if so, skip sending tool_call event
|
||||
// Plan update will be sent in onToolResult when we have the final state
|
||||
if (event.name === TodoWriteTool.Name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send tool call start update with rawInput
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: event.callId,
|
||||
status: 'in_progress',
|
||||
title: event.description || event.name,
|
||||
content: [],
|
||||
locations,
|
||||
kind: toolKind,
|
||||
rawInput: event.args,
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for tool call result
|
||||
const onToolResult = (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentToolResultEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = subAgentToolStates.get(event.callId);
|
||||
|
||||
// Check if this is TodoWriteTool - if so, route to plan updates
|
||||
if (event.name === TodoWriteTool.Name) {
|
||||
let todos:
|
||||
| Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
// Try to extract todos from resultDisplay first (final state)
|
||||
if (event.resultDisplay) {
|
||||
try {
|
||||
// resultDisplay might be a JSON stringified object
|
||||
const parsed =
|
||||
typeof event.resultDisplay === 'string'
|
||||
? JSON.parse(event.resultDisplay)
|
||||
: event.resultDisplay;
|
||||
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'type' in parsed &&
|
||||
parsed.type === 'todo_list' &&
|
||||
'todos' in parsed &&
|
||||
Array.isArray(parsed.todos)
|
||||
) {
|
||||
todos = parsed.todos;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, ignore - resultDisplay might not be JSON
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to args if resultDisplay doesn't have todos
|
||||
if (!todos && state?.args && Array.isArray(state.args['todos'])) {
|
||||
todos = state.args['todos'] as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
}
|
||||
|
||||
// Send plan update if we have todos
|
||||
if (todos) {
|
||||
const planEntries = convertTodosToPlanEntries(todos);
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'plan',
|
||||
entries: planEntries,
|
||||
});
|
||||
}
|
||||
|
||||
// Skip sending tool_call_update event for TodoWriteTool
|
||||
// Clean up state
|
||||
subAgentToolStates.delete(event.callId);
|
||||
return;
|
||||
}
|
||||
|
||||
let content: acp.ToolCallContent[] = [];
|
||||
|
||||
// If there's a result display, try to convert to ToolCallContent
|
||||
if (event.resultDisplay && state?.invocation) {
|
||||
// resultDisplay is typically a string
|
||||
if (typeof event.resultDisplay === 'string') {
|
||||
content = [
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: event.resultDisplay,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Send tool call completion update
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: event.callId,
|
||||
status: event.success ? 'completed' : 'failed',
|
||||
content: content.length > 0 ? content : [],
|
||||
title: state?.invocation?.getDescription() ?? event.name,
|
||||
kind: state?.tool ? this.mapToolKind(state.tool.kind) : null,
|
||||
locations:
|
||||
state?.invocation?.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
})) ?? null,
|
||||
rawInput: state?.args,
|
||||
});
|
||||
|
||||
// Clean up state
|
||||
subAgentToolStates.delete(event.callId);
|
||||
};
|
||||
|
||||
// Listen for permission requests
|
||||
const onToolWaitingApproval = async (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentApprovalRequestEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = subAgentToolStates.get(event.callId);
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
// Handle different confirmation types
|
||||
if (event.confirmationDetails.type === 'edit') {
|
||||
const editDetails = event.confirmationDetails as unknown as {
|
||||
type: 'edit';
|
||||
fileName: string;
|
||||
originalContent: string | null;
|
||||
newContent: string;
|
||||
};
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: editDetails.fileName,
|
||||
oldText: editDetails.originalContent ?? '',
|
||||
newText: editDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Build permission request options from confirmation details
|
||||
// event.confirmationDetails already contains all fields except onConfirm,
|
||||
// which we add here to satisfy the type requirement for toPermissionOptions
|
||||
const fullConfirmationDetails = {
|
||||
...event.confirmationDetails,
|
||||
onConfirm: async () => {
|
||||
// This is a placeholder - the actual response is handled via event.respond
|
||||
},
|
||||
} as unknown as ToolCallConfirmationDetails;
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
sessionId: this.id,
|
||||
options: toPermissionOptions(fullConfirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: event.callId,
|
||||
status: 'pending',
|
||||
title: event.description || event.name,
|
||||
content,
|
||||
locations:
|
||||
state?.invocation?.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
})) ?? [],
|
||||
kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other',
|
||||
rawInput: state?.args,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Request permission from zed client
|
||||
const output = await this.client.requestPermission(params);
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
: z
|
||||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
// Respond to subagent with the outcome
|
||||
await event.respond(outcome);
|
||||
} catch (error) {
|
||||
// If permission request fails, cancel the tool call
|
||||
console.error(
|
||||
`Permission request failed for subagent tool ${event.name}:`,
|
||||
error,
|
||||
);
|
||||
await event.respond(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
};
|
||||
|
||||
// Register event listeners
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(
|
||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
||||
onToolWaitingApproval,
|
||||
);
|
||||
|
||||
// Return cleanup functions
|
||||
cleanupFunctions.push(() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(
|
||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
||||
onToolWaitingApproval,
|
||||
);
|
||||
});
|
||||
|
||||
return cleanupFunctions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps core Tool Kind enum to ACP ToolKind string literals.
|
||||
*
|
||||
* @param kind - The core Kind enum value
|
||||
* @returns The corresponding ACP ToolKind string literal
|
||||
*/
|
||||
private mapToolKind(kind: Kind): acp.ToolKind {
|
||||
const kindMap: Record<Kind, acp.ToolKind> = {
|
||||
[Kind.Read]: 'read',
|
||||
[Kind.Edit]: 'edit',
|
||||
[Kind.Delete]: 'delete',
|
||||
[Kind.Move]: 'move',
|
||||
[Kind.Search]: 'search',
|
||||
[Kind.Execute]: 'execute',
|
||||
[Kind.Think]: 'think',
|
||||
[Kind.Fetch]: 'fetch',
|
||||
[Kind.Other]: 'other',
|
||||
};
|
||||
return kindMap[kind] ?? 'other';
|
||||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
@@ -859,6 +1352,27 @@ class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts todo items to plan entries format for zed integration.
|
||||
* Maps todo status to plan status and assigns a default priority.
|
||||
*
|
||||
* @param todos - Array of todo items with id, content, and status
|
||||
* @returns Array of plan entries with content, priority, and status
|
||||
*/
|
||||
function convertTodosToPlanEntries(
|
||||
todos: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>,
|
||||
): acp.PlanEntry[] {
|
||||
return todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
priority: 'medium' as const, // Default priority since todos don't have priority
|
||||
status: todo.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
if (toolResult.error?.message) {
|
||||
throw new Error(toolResult.error.message);
|
||||
@@ -870,26 +1384,6 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolResult.returnDisplay },
|
||||
};
|
||||
} else if (
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'todo_list'
|
||||
) {
|
||||
// Handle TodoResultDisplay - convert to text representation
|
||||
const todoText = toolResult.returnDisplay.todos
|
||||
.map((todo) => {
|
||||
const statusIcon = {
|
||||
pending: '○',
|
||||
in_progress: '◐',
|
||||
completed: '●',
|
||||
}[todo.status];
|
||||
return `${statusIcon} ${todo.content}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: todoText },
|
||||
};
|
||||
} else if (
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'plan_summary'
|
||||
|
||||
Reference in New Issue
Block a user