mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1) (#3283)
* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)
## Summary
Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.
## Key changes
### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
examples (all optional, backward-compatible)
### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
(explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests
### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)
### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use
### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands
### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true
### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.
### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
capability filtering is now self-contained in CommandService
* fix test ci
* fix memory command
* fix: pass 'non_interactive' mode explicitly to getAvailableCommands
- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
calling getAvailableCommands without specifying mode, causing it to default
to 'acp' instead of 'non_interactive'. Commands with supportedModes that
include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
instead of duplicating the logic inline, preventing divergence.
Fixes review comments by wenshao and tanzhenxin on PR #3283.
* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts
* fix test ci
This commit is contained in:
parent
6c999fe29f
commit
a82d766727
62 changed files with 2350 additions and 307 deletions
|
|
@ -210,10 +210,7 @@ describe('Session', () => {
|
|||
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(AbortSignal),
|
||||
[
|
||||
...nonInteractiveCliCommands.ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
'insight',
|
||||
],
|
||||
'acp',
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ import type { LoadedSettings } from '../../config/settings.js';
|
|||
import { z } from 'zod';
|
||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||
import {
|
||||
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
type NonInteractiveSlashCommandResult,
|
||||
|
|
@ -82,11 +81,6 @@ import { isSlashCommand } from '../../ui/utils/commandUtils.js';
|
|||
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
|
||||
import { classifyApiError } from '../../ui/hooks/useGeminiStream.js';
|
||||
|
||||
const ACP_ALLOWED_COMMANDS = [
|
||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
'insight',
|
||||
];
|
||||
|
||||
// Import modular session components
|
||||
import type {
|
||||
ApprovalModeValue,
|
||||
|
|
@ -330,13 +324,12 @@ export class Session implements SessionContext {
|
|||
let parts: Part[] | null;
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// ACP supports the standard non-interactive built-ins plus /insight.
|
||||
// Handle slash command in ACP mode using capability-based filtering
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
ACP_ALLOWED_COMMANDS,
|
||||
);
|
||||
|
||||
parts = await this.#processSlashCommandResult(
|
||||
|
|
@ -968,11 +961,11 @@ export class Session implements SessionContext {
|
|||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
// Use default allowed commands from getAvailableCommands
|
||||
// Load commands available in ACP mode
|
||||
const slashCommands = await getAvailableCommands(
|
||||
this.config,
|
||||
abortController.signal,
|
||||
ACP_ALLOWED_COMMANDS,
|
||||
'acp',
|
||||
);
|
||||
|
||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||
|
|
|
|||
|
|
@ -444,7 +444,11 @@ export class SystemController extends BaseController {
|
|||
}
|
||||
|
||||
try {
|
||||
const commands = await getAvailableCommands(this.context.config, signal);
|
||||
const commands = await getAvailableCommands(
|
||||
this.context.config,
|
||||
signal,
|
||||
'non_interactive',
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ 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';
|
||||
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
|
||||
import { filterCommandsForMode } from './services/commandUtils.js';
|
||||
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
|
|
@ -54,6 +55,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
});
|
||||
|
||||
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||
const mockGetCommandsForMode = vi.hoisted(() => vi.fn());
|
||||
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./services/CommandService.js', () => ({
|
||||
CommandService: {
|
||||
|
|
@ -79,8 +81,12 @@ describe('runNonInteractive', () => {
|
|||
beforeEach(async () => {
|
||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
|
||||
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
|
||||
filterCommandsForMode(mockGetCommands(), mode),
|
||||
);
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
getCommandsForMode: mockGetCommandsForMode,
|
||||
});
|
||||
|
||||
processStdoutSpy = vi
|
||||
|
|
@ -976,7 +982,7 @@ describe('runNonInteractive', () => {
|
|||
|
||||
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'The command "/help" is not supported in non-interactive mode.\n',
|
||||
'The command "/help" is not supported in this mode.\n',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { CommandKind } from './ui/commands/types.js';
|
||||
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
|
||||
import { filterCommandsForMode } from './services/commandUtils.js';
|
||||
|
||||
// Mock the CommandService
|
||||
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||
const mockGetCommandsForMode = vi.hoisted(() => vi.fn());
|
||||
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./services/CommandService.js', () => ({
|
||||
CommandService: {
|
||||
|
|
@ -25,8 +27,13 @@ describe('handleSlashCommand', () => {
|
|||
let abortController: AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
// getCommandsForMode applies real mode filtering on top of getCommands()
|
||||
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
|
||||
filterCommandsForMode(mockGetCommands(), mode),
|
||||
);
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
getCommandsForMode: mockGetCommandsForMode,
|
||||
});
|
||||
|
||||
mockConfig = {
|
||||
|
|
@ -74,11 +81,12 @@ describe('handleSlashCommand', () => {
|
|||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return unsupported for known built-in commands not in allowed list', async () => {
|
||||
it('should return unsupported for built-in commands without non-interactive supportedModes', async () => {
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
// No commandType → falls back to BUILT_IN → interactive only
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
|
@ -88,7 +96,6 @@ describe('handleSlashCommand', () => {
|
|||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
[], // Empty allowed list
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
|
|
@ -118,78 +125,18 @@ describe('handleSlashCommand', () => {
|
|||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toBe(
|
||||
'The command "/help" is not supported in non-interactive mode.',
|
||||
'The command "/help" is not supported in this mode.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unsupported (not no_command) for a disabled command so it is not forwarded to the model', async () => {
|
||||
const mockInitCommand = {
|
||||
name: 'init',
|
||||
description: 'Initialize project',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockInitCommand]);
|
||||
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['init']);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/init',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
['init'], // Would normally be allowed; denylist must still block it.
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('/init');
|
||||
expect(result.reason).toContain('disabled');
|
||||
}
|
||||
expect(mockInitCommand.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should match disabled names case-insensitively', async () => {
|
||||
const mockInitCommand = {
|
||||
name: 'init',
|
||||
description: 'Initialize project',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockInitCommand]);
|
||||
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['INIT']);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/init',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
['init'],
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
expect(mockInitCommand.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still return no_command for truly unknown slash commands even when a denylist is set', async () => {
|
||||
mockGetCommands.mockReturnValue([]);
|
||||
vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/does-not-exist',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should execute allowed built-in commands', async () => {
|
||||
it('should execute local commands with non_interactive supportedModes', async () => {
|
||||
const mockInitCommand = {
|
||||
name: 'init',
|
||||
description: 'Initialize project',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local' as const,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
|
|
@ -203,7 +150,6 @@ describe('handleSlashCommand', () => {
|
|||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
['init'], // init is in the allowed list
|
||||
);
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
|
|
@ -212,11 +158,13 @@ describe('handleSlashCommand', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should execute /btw when using the default allowed list', async () => {
|
||||
it('should execute /btw with non_interactive supportedModes', async () => {
|
||||
const mockBtwCommand = {
|
||||
name: 'btw',
|
||||
description: 'Ask a side question',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local' as const,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
|
|
@ -239,7 +187,7 @@ describe('handleSlashCommand', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should execute file commands regardless of allowed list', async () => {
|
||||
it('should execute FILE commands in any mode without explicit supportedModes', async () => {
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
|
|
@ -256,7 +204,6 @@ describe('handleSlashCommand', () => {
|
|||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
[], // Empty allowed list, but FILE commands should still work
|
||||
);
|
||||
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
|
|||
import { BundledSkillLoader } from './services/BundledSkillLoader.js';
|
||||
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
type ExecutionMode,
|
||||
} from './ui/commands/types.js';
|
||||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
|
|
@ -29,27 +29,6 @@ import { t } from './i18n/index.js';
|
|||
|
||||
const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS');
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
|
||||
* Only safe, read-only commands that don't require interactive UI.
|
||||
*
|
||||
* These commands are:
|
||||
* - init: Initialize project configuration
|
||||
* - summary: Generate session summary
|
||||
* - compress: Compress conversation history
|
||||
* - context: Show context window usage (read-only diagnostic)
|
||||
* - doctor: Run installation and environment diagnostics (read-only diagnostic)
|
||||
*/
|
||||
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
|
||||
'init',
|
||||
'summary',
|
||||
'compress',
|
||||
'btw',
|
||||
'bug',
|
||||
'context',
|
||||
'doctor',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Result of handling a slash command in non-interactive mode.
|
||||
*
|
||||
|
|
@ -187,36 +166,6 @@ function handleCommandResult(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || cmd.kind === CommandKind.SKILL) {
|
||||
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.
|
||||
*
|
||||
|
|
@ -224,9 +173,6 @@ function filterCommandsForNonInteractive(
|
|||
* @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. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
||||
* Pass an empty array to only allow file commands.
|
||||
* @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing
|
||||
* the outcome of the command execution.
|
||||
*/
|
||||
|
|
@ -235,9 +181,6 @@ export const handleSlashCommand = async (
|
|||
abortController: AbortController,
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
allowedBuiltinCommandNames: string[] = [
|
||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
],
|
||||
): Promise<NonInteractiveSlashCommandResult> => {
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
|
|
@ -247,26 +190,13 @@ export const handleSlashCommand = async (
|
|||
const isAcpMode = config.getExperimentalZedIntegration();
|
||||
const isInteractive = config.isInteractive();
|
||||
|
||||
const executionMode = isAcpMode
|
||||
const executionMode: ExecutionMode = isAcpMode
|
||||
? 'acp'
|
||||
: isInteractive
|
||||
? 'interactive'
|
||||
: 'non_interactive';
|
||||
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
const disabledSlashCommandsRaw = config.getDisabledSlashCommands();
|
||||
const disabledNameSet = new Set<string>();
|
||||
for (const name of disabledSlashCommandsRaw) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) disabledNameSet.add(trimmed.toLowerCase());
|
||||
}
|
||||
const isDisabled = (cmd: { name: string }) =>
|
||||
disabledNameSet.has(cmd.name.toLowerCase());
|
||||
|
||||
// Load the full command set (unfiltered by the denylist) so that the
|
||||
// fallback existence check below can distinguish a disabled command from a
|
||||
// truly unknown one. Without this, a disabled command would fall through to
|
||||
// `no_command` and be forwarded to the model as plain prompt text.
|
||||
// Load all commands to check if the command exists but is not allowed
|
||||
const allLoaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
|
|
@ -278,10 +208,7 @@ export const handleSlashCommand = async (
|
|||
abortController.signal,
|
||||
);
|
||||
const allCommands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
allCommands,
|
||||
allowedBuiltinSet,
|
||||
).filter((cmd) => !isDisabled(cmd));
|
||||
const filteredCommands = commandService.getCommandsForMode(executionMode);
|
||||
|
||||
// First, try to parse with filtered commands
|
||||
const { commandToExecute, args } = parseSlashCommand(
|
||||
|
|
@ -297,23 +224,12 @@ export const handleSlashCommand = async (
|
|||
);
|
||||
|
||||
if (knownCommand) {
|
||||
if (isDisabled(knownCommand)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: t(
|
||||
'The command "/{{command}}" is disabled by the current configuration.',
|
||||
{ command: knownCommand.name },
|
||||
),
|
||||
originalType: 'filtered_command',
|
||||
};
|
||||
}
|
||||
// Command exists but is not allowed in non-interactive mode
|
||||
// Command exists but is not allowed in this mode
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: t(
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||
{ command: knownCommand.name },
|
||||
),
|
||||
reason: t('The command "/{{command}}" is not supported in this mode.', {
|
||||
command: knownCommand.name,
|
||||
}),
|
||||
originalType: 'filtered_command',
|
||||
};
|
||||
}
|
||||
|
|
@ -372,51 +288,27 @@ export const handleSlashCommand = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Retrieves all available slash commands for the current configuration.
|
||||
* Retrieves all available slash commands for the given execution mode.
|
||||
*
|
||||
* @param config The configuration object
|
||||
* @param abortSignal Signal to cancel the loading process
|
||||
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
|
||||
* allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress).
|
||||
* Pass an empty array to only include file commands.
|
||||
* @param mode The execution mode to filter commands for. Defaults to 'acp'.
|
||||
* @returns A Promise that resolves to an array of SlashCommand objects
|
||||
*/
|
||||
export const getAvailableCommands = async (
|
||||
config: Config,
|
||||
abortSignal: AbortSignal,
|
||||
allowedBuiltinCommandNames: string[] = [
|
||||
...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE,
|
||||
],
|
||||
mode: ExecutionMode = 'acp',
|
||||
): Promise<SlashCommand[]> => {
|
||||
try {
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
const loaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
]
|
||||
: [new BundledSkillLoader(config), new FileCommandLoader(config)];
|
||||
|
||||
const disabledSlashCommands = config.getDisabledSlashCommands();
|
||||
const commandService = await CommandService.create(
|
||||
loaders,
|
||||
abortSignal,
|
||||
disabledSlashCommands.length > 0
|
||||
? new Set(disabledSlashCommands)
|
||||
: undefined,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
// Filter out hidden commands
|
||||
return filteredCommands.filter((cmd) => !cmd.hidden);
|
||||
const commandService = await CommandService.create(loaders, abortSignal);
|
||||
return commandService.getCommandsForMode(mode) as SlashCommand[];
|
||||
} catch (error) {
|
||||
// Handle errors gracefully - log and return empty array
|
||||
debugLogger.error('Error loading available commands:', error);
|
||||
|
|
|
|||
|
|
@ -135,6 +135,14 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
statuslineCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
return allDefinitions
|
||||
.filter((cmd): cmd is SlashCommand => cmd !== null)
|
||||
.map((cmd) => ({
|
||||
...cmd,
|
||||
source: 'builtin-command' as const,
|
||||
sourceLabel: 'Built-in',
|
||||
modelInvocable: false,
|
||||
userInvocable: cmd.userInvocable ?? true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ export class BundledSkillLoader implements ICommandLoader {
|
|||
name: skill.name,
|
||||
description: skill.description,
|
||||
kind: CommandKind.SKILL,
|
||||
source: 'bundled-skill' as const,
|
||||
sourceLabel: 'Skill',
|
||||
commandType: 'prompt' as const,
|
||||
modelInvocable: true,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
// Resolve template variables in skill body
|
||||
let body = skill.body;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
import type { SlashCommand, ExecutionMode } from '../ui/commands/types.js';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { filterCommandsForMode } from './commandUtils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('CLI_COMMANDS');
|
||||
|
||||
|
|
@ -124,4 +125,27 @@ export class CommandService {
|
|||
getCommands(): readonly SlashCommand[] {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns commands available in the specified execution mode.
|
||||
* Hidden commands are excluded.
|
||||
*/
|
||||
getCommandsForMode(mode: ExecutionMode): readonly SlashCommand[] {
|
||||
return Object.freeze(
|
||||
filterCommandsForMode(
|
||||
this.commands.filter((cmd) => !cmd.hidden),
|
||||
mode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns commands that the model is allowed to invoke (modelInvocable === true).
|
||||
* Hidden commands are excluded.
|
||||
*/
|
||||
getModelInvocableCommands(): readonly SlashCommand[] {
|
||||
return this.commands.filter(
|
||||
(cmd) => !cmd.hidden && cmd.modelInvocable === true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ export class McpPromptLoader implements ICommandLoader {
|
|||
name: commandName,
|
||||
description: prompt.description || `Invoke prompt ${prompt.name}`,
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
source: 'mcp-prompt' as const,
|
||||
sourceLabel: `MCP: ${serverName}`,
|
||||
commandType: 'prompt' as const,
|
||||
modelInvocable: true,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'help',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import path from 'node:path';
|
|||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CommandContext,
|
||||
CommandSource,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from '../ui/commands/types.js';
|
||||
|
|
@ -111,6 +112,12 @@ export function createSlashCommandFromDefinition(
|
|||
description,
|
||||
kind: CommandKind.FILE,
|
||||
extensionName,
|
||||
source: (extensionName
|
||||
? 'plugin-command'
|
||||
: 'skill-dir-command') as CommandSource,
|
||||
sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom',
|
||||
commandType: 'prompt' as const,
|
||||
modelInvocable: !extensionName,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
|
|
|
|||
212
packages/cli/src/services/commandUtils.test.ts
Normal file
212
packages/cli/src/services/commandUtils.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getEffectiveSupportedModes,
|
||||
filterCommandsForMode,
|
||||
} from './commandUtils.js';
|
||||
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
/** Minimal SlashCommand factory for tests */
|
||||
function makeCmd(overrides: Partial<SlashCommand>): SlashCommand {
|
||||
return {
|
||||
name: 'test',
|
||||
description: 'test command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getEffectiveSupportedModes', () => {
|
||||
// ── Priority 1: explicit supportedModes ───────────────────────────────
|
||||
it('explicit supportedModes overrides commandType inference', () => {
|
||||
const cmd = makeCmd({
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive'],
|
||||
});
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('explicit supportedModes can expand to all modes even for local-jsx', () => {
|
||||
const cmd = makeCmd({
|
||||
commandType: 'local-jsx',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
});
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('explicit empty supportedModes returns empty array', () => {
|
||||
const cmd = makeCmd({ supportedModes: [] });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Priority 2: commandType inference ─────────────────────────────────
|
||||
it('commandType: prompt infers all modes', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.SKILL, commandType: 'prompt' });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('commandType: local infers interactive only (conservative default)', () => {
|
||||
const cmd = makeCmd({ commandType: 'local' });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('commandType: local-jsx infers interactive only', () => {
|
||||
const cmd = makeCmd({ commandType: 'local-jsx' });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('commandType: local with explicit supportedModes can unlock non_interactive', () => {
|
||||
const cmd = makeCmd({
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
});
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Priority 3: CommandKind fallback (backward compat) ────────────────
|
||||
it('no commandType, CommandKind.BUILT_IN falls back to interactive only', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.BUILT_IN });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']);
|
||||
});
|
||||
|
||||
it('no commandType, CommandKind.FILE falls back to all modes', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.FILE });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('no commandType, CommandKind.SKILL falls back to all modes', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.SKILL });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('no commandType, CommandKind.MCP_PROMPT falls back to all modes (fixes original bug)', () => {
|
||||
const cmd = makeCmd({ kind: CommandKind.MCP_PROMPT });
|
||||
expect(getEffectiveSupportedModes(cmd)).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCommandsForMode', () => {
|
||||
const commands: SlashCommand[] = [
|
||||
makeCmd({
|
||||
name: 'init',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'model',
|
||||
commandType: 'local-jsx',
|
||||
// no explicit supportedModes → interactive only
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'review',
|
||||
kind: CommandKind.SKILL,
|
||||
commandType: 'prompt',
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'gh-prompt',
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
commandType: 'prompt',
|
||||
}),
|
||||
makeCmd({
|
||||
name: 'my-script',
|
||||
kind: CommandKind.FILE,
|
||||
commandType: 'prompt',
|
||||
}),
|
||||
];
|
||||
|
||||
it('interactive mode includes all commands', () => {
|
||||
const result = filterCommandsForMode(commands, 'interactive');
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'init',
|
||||
'model',
|
||||
'review',
|
||||
'gh-prompt',
|
||||
'my-script',
|
||||
]);
|
||||
});
|
||||
|
||||
it('non_interactive mode excludes local-jsx commands', () => {
|
||||
const result = filterCommandsForMode(commands, 'non_interactive');
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'init',
|
||||
'review',
|
||||
'gh-prompt',
|
||||
'my-script',
|
||||
]);
|
||||
});
|
||||
|
||||
it('acp mode excludes local-jsx commands', () => {
|
||||
const result = filterCommandsForMode(commands, 'acp');
|
||||
expect(result.map((c) => c.name)).toEqual([
|
||||
'init',
|
||||
'review',
|
||||
'gh-prompt',
|
||||
'my-script',
|
||||
]);
|
||||
});
|
||||
|
||||
it('non_interactive includes MCP_PROMPT commands (bug fix)', () => {
|
||||
const result = filterCommandsForMode(commands, 'non_interactive');
|
||||
expect(result.some((c) => c.name === 'gh-prompt')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not filter hidden commands (hidden filtering is caller responsibility)', () => {
|
||||
const withHidden = [
|
||||
...commands,
|
||||
makeCmd({ name: 'hidden-cmd', commandType: 'local', hidden: true }),
|
||||
];
|
||||
const result = filterCommandsForMode(withHidden, 'non_interactive');
|
||||
// filterCommandsForMode does NOT filter hidden — it only filters by mode
|
||||
// hidden-cmd has commandType: 'local' but no supportedModes, so it's interactive only
|
||||
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(false);
|
||||
});
|
||||
|
||||
it('hidden local command with explicit supportedModes still passes mode filter', () => {
|
||||
const withHidden = [
|
||||
...commands,
|
||||
makeCmd({
|
||||
name: 'hidden-cmd',
|
||||
commandType: 'local',
|
||||
hidden: true,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
}),
|
||||
];
|
||||
const result = filterCommandsForMode(withHidden, 'non_interactive');
|
||||
// filterCommandsForMode passes it through — CommandService.getCommandsForMode removes hidden
|
||||
expect(result.some((c) => c.name === 'hidden-cmd')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array when no commands match', () => {
|
||||
const jsxOnly = [makeCmd({ name: 'model', commandType: 'local-jsx' })];
|
||||
expect(filterCommandsForMode(jsxOnly, 'non_interactive')).toEqual([]);
|
||||
});
|
||||
});
|
||||
91
packages/cli/src/services/commandUtils.ts
Normal file
91
packages/cli/src/services/commandUtils.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility functions for slash command mode filtering.
|
||||
*
|
||||
* This module provides the core capability-based filtering logic that replaces
|
||||
* the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist.
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type ExecutionMode,
|
||||
type SlashCommand,
|
||||
} from '../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Returns the effective list of execution modes for a command.
|
||||
*
|
||||
* Priority (highest to lowest):
|
||||
* 1. Explicit `supportedModes` declaration on the command
|
||||
* 2. Inference from `commandType`
|
||||
* 3. Fallback based on `CommandKind` (backward-compat for commands that
|
||||
* have not yet been migrated to declare commandType)
|
||||
*
|
||||
* @param cmd The slash command to evaluate.
|
||||
* @returns The list of execution modes in which the command is available.
|
||||
*/
|
||||
export function getEffectiveSupportedModes(cmd: SlashCommand): ExecutionMode[] {
|
||||
// Priority 1: explicit declaration wins
|
||||
if (cmd.supportedModes !== undefined) {
|
||||
return cmd.supportedModes;
|
||||
}
|
||||
|
||||
// Priority 2: infer from commandType
|
||||
if (cmd.commandType !== undefined) {
|
||||
switch (cmd.commandType) {
|
||||
case 'prompt':
|
||||
// prompt commands have no UI dependency — available in all modes
|
||||
return ['interactive', 'non_interactive', 'acp'];
|
||||
case 'local':
|
||||
// local commands default to interactive only (conservative).
|
||||
// Commands that are verified headless-friendly must explicitly declare
|
||||
// supportedModes (mirrors Claude Code's supportsNonInteractive: true).
|
||||
return ['interactive'];
|
||||
case 'local-jsx':
|
||||
// local-jsx commands always require the React/Ink runtime
|
||||
return ['interactive'];
|
||||
default:
|
||||
return ['interactive'];
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: backward-compat fallback based on CommandKind.
|
||||
// This branch should not be hit once all commands declare commandType.
|
||||
switch (cmd.kind) {
|
||||
case CommandKind.BUILT_IN:
|
||||
// Conservative default for unmigrated built-in commands
|
||||
return ['interactive'];
|
||||
case CommandKind.FILE:
|
||||
case CommandKind.SKILL:
|
||||
case CommandKind.MCP_PROMPT:
|
||||
// These kinds have always been available in all modes
|
||||
return ['interactive', 'non_interactive', 'acp'];
|
||||
default:
|
||||
return ['interactive'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a list of commands to those available in the given execution mode.
|
||||
*
|
||||
* This function replaces `filterCommandsForNonInteractive`. It does NOT filter
|
||||
* out hidden commands — that responsibility belongs to the caller (e.g.,
|
||||
* CommandService.getCommandsForMode).
|
||||
*
|
||||
* @param commands The full list of loaded commands.
|
||||
* @param mode The target execution mode.
|
||||
* @returns Commands that support the given mode.
|
||||
*/
|
||||
export function filterCommandsForMode(
|
||||
commands: readonly SlashCommand[],
|
||||
mode: ExecutionMode,
|
||||
): SlashCommand[] {
|
||||
return commands.filter((cmd) =>
|
||||
getEffectiveSupportedModes(cmd).includes(mode),
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export const aboutCommand: SlashCommand = {
|
|||
return t('show version info');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context) => {
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const agentsCommand: SlashCommand = {
|
|||
return t('Manage subagents for specialized task delegation.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'manage',
|
||||
|
|
@ -24,6 +25,7 @@ export const agentsCommand: SlashCommand = {
|
|||
return t('Manage existing subagents (view, edit, delete).');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'subagent_list',
|
||||
|
|
@ -35,6 +37,7 @@ export const agentsCommand: SlashCommand = {
|
|||
return t('Create a new subagent with guided setup.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'subagent_create',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const approvalModeCommand: SlashCommand = {
|
|||
return t('View or change the approval mode for tool usage');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -384,12 +384,14 @@ export const arenaCommand: SlashCommand = {
|
|||
name: 'arena',
|
||||
description: 'Manage Arena sessions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'start',
|
||||
description:
|
||||
'Start an Arena session with multiple models competing on the same task',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
@ -446,6 +448,7 @@ export const arenaCommand: SlashCommand = {
|
|||
name: 'stop',
|
||||
description: 'Stop the current Arena session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
|
|
@ -487,6 +490,7 @@ export const arenaCommand: SlashCommand = {
|
|||
name: 'status',
|
||||
description: 'Show the current Arena session status',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
|
|
@ -529,6 +533,7 @@ export const arenaCommand: SlashCommand = {
|
|||
description:
|
||||
'Select a model result and merge its diff into the current workspace',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const authCommand: SlashCommand = {
|
|||
return t('Configure authentication information for login');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'auth',
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ export const btwCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const bugCommand: SlashCommand = {
|
|||
return t('submit a bug report');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export const clearCommand: SlashCommand = {
|
|||
return t('Clear conversation history and free up context');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context, _args) => {
|
||||
const { config } = context.services;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export const compressCommand: SlashCommand = {
|
|||
return t('Compresses the context by replacing it with a summary.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context) => {
|
||||
const { ui } = context;
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ export const contextCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const showDetails =
|
||||
args?.trim().toLowerCase() === 'detail' ||
|
||||
|
|
@ -360,6 +362,8 @@ export const contextCommand: SlashCommand = {
|
|||
return t('Show per-item context usage breakdown.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context: CommandContext) => {
|
||||
// Delegate to main action with 'detail' arg to show detailed view
|
||||
await contextCommand.action!(context, 'detail');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const copyCommand: SlashCommand = {
|
|||
return t('Copy the last result or code snippet to clipboard');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistory();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export const directoryCommand: SlashCommand = {
|
|||
return t('Manage workspace directories');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'add',
|
||||
|
|
@ -83,6 +84,7 @@ export const directoryCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
completion: async (_context: CommandContext, partialArg: string) =>
|
||||
getDirPathCompletions(partialArg),
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
|
|
@ -222,6 +224,7 @@ export const directoryCommand: SlashCommand = {
|
|||
return t('Show all directories in the workspace');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const docsCommand: SlashCommand = {
|
|||
return t('open full Qwen Code documentation in your browser');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
|
||||
const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const editorCommand: SlashCommand = {
|
|||
return t('set external editor preference');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'editor',
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export current session message history to a file');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'html',
|
||||
|
|
@ -332,6 +333,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to HTML format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
action: exportHtmlAction,
|
||||
},
|
||||
{
|
||||
|
|
@ -340,6 +342,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to markdown format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
action: exportMarkdownAction,
|
||||
},
|
||||
{
|
||||
|
|
@ -348,6 +351,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to JSON format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
action: exportJsonAction,
|
||||
},
|
||||
{
|
||||
|
|
@ -356,6 +360,7 @@ export const exportCommand: SlashCommand = {
|
|||
return t('Export session to JSONL format (one message per line)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
action: exportJsonlAction,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ const exploreExtensionsCommand: SlashCommand = {
|
|||
return t('Open extensions page in your browser');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: exploreAction,
|
||||
completion: completeExtensionsExplore,
|
||||
};
|
||||
|
|
@ -226,6 +227,7 @@ const manageExtensionsCommand: SlashCommand = {
|
|||
return t('Manage installed extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
|
|
@ -235,6 +237,7 @@ const installCommand: SlashCommand = {
|
|||
return t('Install an extension from a git repo or local path');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: installAction,
|
||||
};
|
||||
|
||||
|
|
@ -244,6 +247,7 @@ export const extensionsCommand: SlashCommand = {
|
|||
return t('Manage extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
subCommands: [
|
||||
manageExtensionsCommand,
|
||||
installCommand,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const helpCommand: SlashCommand = {
|
|||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
get description() {
|
||||
return t('for help on Qwen Code');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const listCommand: SlashCommand = {
|
|||
return t('List all configured hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
|
|
@ -185,6 +186,7 @@ export const hooksCommand: SlashCommand = {
|
|||
return t('Manage Qwen Code hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('manage IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (): SlashCommandActionReturn =>
|
||||
({
|
||||
type: 'message',
|
||||
|
|
@ -160,6 +161,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('manage IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
subCommands: [],
|
||||
};
|
||||
|
||||
|
|
@ -169,6 +171,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('check status of IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
const { messageType, content } =
|
||||
await getIdeStatusMessageWithFiles(ideClient);
|
||||
|
|
@ -189,6 +192,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
});
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context) => {
|
||||
const installer = getIdeInstaller(currentIDE);
|
||||
const isSandBox = !!process.env['SANDBOX'];
|
||||
|
|
@ -276,6 +280,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('enable IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(
|
||||
SettingScope.User,
|
||||
|
|
@ -300,6 +305,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||
return t('disable IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(
|
||||
SettingScope.User,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const initCommand: SlashCommand = {
|
|||
return t('Analyzes the project and creates a tailored QWEN.md file.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export const insightCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
action: async (context: CommandContext) => {
|
||||
try {
|
||||
context.ui.setDebugMessage(t('Generating insights...'));
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export const languageCommand: SlashCommand = {
|
|||
return t('View or change the language setting');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
@ -268,6 +269,7 @@ export const languageCommand: SlashCommand = {
|
|||
return t('Set UI language');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
@ -322,6 +324,7 @@ export const languageCommand: SlashCommand = {
|
|||
});
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context, args) => {
|
||||
if (args.trim()) {
|
||||
return {
|
||||
|
|
@ -345,6 +348,7 @@ export const languageCommand: SlashCommand = {
|
|||
return t('Set LLM output language');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const mcpCommand: SlashCommand = {
|
|||
return t('Open MCP management dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
|
|
|
|||
|
|
@ -21,19 +21,4 @@ describe('memoryCommand', () => {
|
|||
dialog: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a non-interactive fallback message outside the interactive UI', async () => {
|
||||
const context = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
});
|
||||
|
||||
const result = await memoryCommand.action?.(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,22 +14,9 @@ export const memoryCommand: SlashCommand = {
|
|||
return t('Open the memory manager.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
|
||||
if (executionMode === 'interactive') {
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'memory',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t(
|
||||
'The memory manager is only available in the interactive UI. In non-interactive mode, open the user or project memory files directly.',
|
||||
),
|
||||
};
|
||||
},
|
||||
commandType: 'local-jsx',
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'memory',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const modelCommand: SlashCommand = {
|
|||
return t('Switch the model for this session (--fast for suggestion model)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
completion: async (_context, partialArg) => {
|
||||
if (partialArg && '--fast'.startsWith(partialArg)) {
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const permissionsCommand: SlashCommand = {
|
|||
return t('Manage permission rules');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'permissions',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const planCommand: SlashCommand = {
|
|||
return t('Switch to plan mode or exit plan mode');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const quitCommand: SlashCommand = {
|
|||
return t('exit the cli');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: restoreAction,
|
||||
completion,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { t } from '../../i18n/index.js';
|
|||
export const resumeCommand: SlashCommand = {
|
||||
name: 'resume',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
get description() {
|
||||
return t('Resume a previous session');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const settingsCommand: SlashCommand = {
|
|||
return t('View and edit Qwen Code settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const setupGithubCommand: SlashCommand = {
|
|||
return t('Set up GitHub Actions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const skillsCommand: SlashCommand = {
|
|||
return t('List available skills.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const rawArgs = args?.trim() ?? '';
|
||||
const [skillName = ''] = rawArgs.split(/\s+/);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const statsCommand: SlashCommand = {
|
|||
return t('check session stats. Usage: /stats [model|tools]');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context: CommandContext) => {
|
||||
const now = new Date();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
|
|
@ -50,6 +51,7 @@ export const statsCommand: SlashCommand = {
|
|||
return t('Show model-specific usage statistics.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
|
@ -65,6 +67,7 @@ export const statsCommand: SlashCommand = {
|
|||
return t('Show tool-specific usage statistics.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const statuslineCommand: SlashCommand = {
|
|||
return t("Set up Qwen Code's status line UI");
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (_context, args): SubmitPromptActionReturn => {
|
||||
const prompt =
|
||||
args.trim() || 'Configure my statusLine from my shell PS1 configuration';
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const summaryCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
const { ui } = context;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const terminalSetupCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
|
||||
action: async (): Promise<MessageActionReturn> => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const themeCommand: SlashCommand = {
|
|||
return t('change the theme');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'theme',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const toolsCommand: SlashCommand = {
|
|||
return t('list available Qwen Code tools. Usage: /tools [desc]');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const subCommand = args?.trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const trustCommand: SlashCommand = {
|
|||
return t('Manage folder trust settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'trust',
|
||||
|
|
|
|||
|
|
@ -235,6 +235,49 @@ export enum CommandKind {
|
|||
SKILL = 'skill',
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution mode for a slash command invocation.
|
||||
* - interactive: React/Ink UI mode (terminal)
|
||||
* - non_interactive: headless CLI mode (text/JSON output)
|
||||
* - acp: ACP/Zed editor integration mode
|
||||
*/
|
||||
export type ExecutionMode = 'interactive' | 'non_interactive' | 'acp';
|
||||
|
||||
/**
|
||||
* The source of a slash command, used for Help grouping, completion badges,
|
||||
* and ACP available-command metadata.
|
||||
*
|
||||
* Distinct from CommandKind: CommandKind drives loader logic (4 values);
|
||||
* CommandSource drives display and user mental model (5+ values).
|
||||
*/
|
||||
export type CommandSource =
|
||||
| 'builtin-command' // BuiltinCommandLoader
|
||||
| 'bundled-skill' // BundledSkillLoader
|
||||
| 'skill-dir-command' // FileCommandLoader (user/project, no extensionName)
|
||||
| 'plugin-command' // FileCommandLoader (extension, extensionName set)
|
||||
| 'mcp-prompt'; // McpPromptLoader
|
||||
// Reserved for future loaders (not implemented in Phase 1):
|
||||
// | 'workflow-command'
|
||||
// | 'plugin-skill'
|
||||
// | 'dynamic-skill'
|
||||
|
||||
/**
|
||||
* The execution type of a slash command, describing *how* it runs.
|
||||
*
|
||||
* - prompt: Produces a submit_prompt — content is sent to the model.
|
||||
* Default supportedModes: all. Default modelInvocable: true.
|
||||
*
|
||||
* - local: Runs local logic with no React/Ink UI dependency.
|
||||
* Can return message, stream_messages, submit_prompt, tool, etc.
|
||||
* Default supportedModes: ['interactive'] — must explicitly declare
|
||||
* supportedModes to unlock other modes (mirrors Claude Code's
|
||||
* supportsNonInteractive: true pattern).
|
||||
*
|
||||
* - local-jsx: Depends on React/Ink UI (dialogs, JSX components, etc.).
|
||||
* Default supportedModes: ['interactive'] only.
|
||||
*/
|
||||
export type CommandType = 'prompt' | 'local' | 'local-jsx';
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
value: string;
|
||||
label?: string;
|
||||
|
|
@ -255,6 +298,69 @@ export interface SlashCommand {
|
|||
// Optional metadata for extension commands
|
||||
extensionName?: string;
|
||||
|
||||
// ── Phase 1: source & execution type ──────────────────────────────────
|
||||
/**
|
||||
* The source of this command. Set by the Loader, not by the command itself.
|
||||
* Will replace CommandKind as the canonical source identifier in a future phase.
|
||||
*/
|
||||
source?: CommandSource;
|
||||
|
||||
/**
|
||||
* Human-readable source label for display in Help, completion badges, etc.
|
||||
* - builtin-command → "Built-in"
|
||||
* - bundled-skill → "Skill"
|
||||
* - skill-dir-command → "Custom"
|
||||
* - plugin-command → "Plugin: <extensionName>"
|
||||
* - mcp-prompt → "MCP: <serverName>"
|
||||
* Set by the Loader; may be overridden by the command itself.
|
||||
*/
|
||||
sourceLabel?: string;
|
||||
|
||||
/**
|
||||
* How this command executes. Set by built-in command files (local/local-jsx)
|
||||
* or by Loaders (prompt). Used by getEffectiveSupportedModes() to infer
|
||||
* which execution modes are supported.
|
||||
*/
|
||||
commandType?: CommandType;
|
||||
|
||||
// ── Phase 1: mode capability ───────────────────────────────────────────
|
||||
/**
|
||||
* Which execution modes this command is available in.
|
||||
* Explicit declaration takes priority over commandType inference.
|
||||
* See getEffectiveSupportedModes() in commandUtils.ts for the full logic.
|
||||
*/
|
||||
supportedModes?: ExecutionMode[];
|
||||
|
||||
// ── Phase 1: visibility ────────────────────────────────────────────────
|
||||
/**
|
||||
* Whether users can invoke this command via a slash command.
|
||||
* Defaults to true for all commands.
|
||||
*/
|
||||
userInvocable?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the model can invoke this command via a tool call.
|
||||
* Defaults to false. prompt-type commands (skills, file commands, MCP prompts)
|
||||
* should be true. Built-in commands must always be false.
|
||||
*/
|
||||
modelInvocable?: boolean;
|
||||
|
||||
// ── Phase 3 reserved: UX metadata (defined now, unused until Phase 3) ─
|
||||
/**
|
||||
* Argument hint shown after the command name in the completion menu.
|
||||
* Example: "<model-id>" / "show|list|set <id>"
|
||||
*/
|
||||
argumentHint?: string;
|
||||
|
||||
/**
|
||||
* Describes when to use this command — injected into the model-visible
|
||||
* description for modelInvocable commands.
|
||||
*/
|
||||
whenToUse?: string;
|
||||
|
||||
/** Usage examples shown in Help and completion. */
|
||||
examples?: string[];
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
context: CommandContext,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const vimCommand: SlashCommand = {
|
|||
return t('toggle vim mode on/off');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
commandType: 'local-jsx',
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ export const useSlashCommandProcessor = (
|
|||
);
|
||||
// Avoid overwriting newer results from a subsequent effect run
|
||||
if (!controller.signal.aborted) {
|
||||
setCommands(commandService.getCommands());
|
||||
setCommands(commandService.getCommandsForMode('interactive'));
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load slash commands:', error);
|
||||
|
|
|
|||
|
|
@ -36,34 +36,50 @@ import {
|
|||
} from './nonInteractiveHelpers.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_config: unknown,
|
||||
_signal: AbortSignal,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
) => {
|
||||
const allowedSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
const allCommands = [
|
||||
{ name: 'help', kind: 'built-in' },
|
||||
{ name: 'commit', kind: 'file' },
|
||||
{ name: 'memory', kind: 'built-in' },
|
||||
{ name: 'init', kind: 'built-in' },
|
||||
{ name: 'summary', kind: 'built-in' },
|
||||
{ name: 'compress', kind: 'built-in' },
|
||||
];
|
||||
vi.mock('../nonInteractiveCliCommands.js', async () => {
|
||||
const { filterCommandsForMode } = await import('../services/commandUtils.js');
|
||||
return {
|
||||
getAvailableCommands: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_config: unknown,
|
||||
_signal: AbortSignal,
|
||||
mode: string = 'acp',
|
||||
) => {
|
||||
// Simulate capability-based filtering with commandType / supportedModes
|
||||
// Delegate to production filterCommandsForMode to avoid logic divergence
|
||||
const allCommands = [
|
||||
{ name: 'help', commandType: 'local-jsx' },
|
||||
{ name: 'commit', commandType: 'prompt' },
|
||||
{ name: 'memory', commandType: 'local' },
|
||||
{
|
||||
name: 'init',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
},
|
||||
{
|
||||
name: 'compress',
|
||||
commandType: 'local',
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'],
|
||||
},
|
||||
];
|
||||
|
||||
// Filter commands: always include file commands, only include allowed built-in commands
|
||||
return allCommands.filter(
|
||||
(cmd) =>
|
||||
cmd.kind === 'file' ||
|
||||
(cmd.kind === 'built-in' && allowedSet.has(cmd.name)),
|
||||
);
|
||||
},
|
||||
),
|
||||
}));
|
||||
return filterCommandsForMode(
|
||||
allCommands as unknown as Parameters<
|
||||
typeof filterCommandsForMode
|
||||
>[0],
|
||||
mode as Parameters<typeof filterCommandsForMode>[1],
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/utils/computeStats.js', () => ({
|
||||
computeSessionStats: vi.fn().mockReturnValue({
|
||||
|
|
@ -520,12 +536,10 @@ describe('buildSystemMessage', () => {
|
|||
});
|
||||
|
||||
it('should build system message with all fields', async () => {
|
||||
const allowedBuiltinCommands = ['init', 'summary', 'compress'];
|
||||
const result = await buildSystemMessage(
|
||||
mockConfig,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
allowedBuiltinCommands,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
|
|
@ -557,7 +571,6 @@ describe('buildSystemMessage', () => {
|
|||
config,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
['init', 'summary'],
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
|
|
@ -573,7 +586,6 @@ describe('buildSystemMessage', () => {
|
|||
config,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
['init', 'summary'],
|
||||
);
|
||||
|
||||
expect(result.mcp_servers).toEqual([]);
|
||||
|
|
@ -589,36 +601,38 @@ describe('buildSystemMessage', () => {
|
|||
config,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
['init', 'summary'],
|
||||
);
|
||||
|
||||
expect(result.qwen_code_version).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should only include allowed built-in commands and all file commands', async () => {
|
||||
const allowedBuiltinCommands = ['init', 'summary'];
|
||||
it('should include local commands with ACP supportedModes and prompt commands', async () => {
|
||||
const result = await buildSystemMessage(
|
||||
mockConfig,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
allowedBuiltinCommands,
|
||||
);
|
||||
|
||||
// Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed)
|
||||
// Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set)
|
||||
expect(result.slash_commands).toEqual(['commit', 'init', 'summary']);
|
||||
// Should include: 'commit' (prompt), 'compress', 'init', 'summary' (local+ACP)
|
||||
// Should NOT include: 'help' (local-jsx), 'memory' (local without ACP supportedModes)
|
||||
expect(result.slash_commands).toEqual([
|
||||
'commit',
|
||||
'compress',
|
||||
'init',
|
||||
'summary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include only file commands when no built-in commands are allowed', async () => {
|
||||
it('should exclude interactive-only commands from system message', async () => {
|
||||
const result = await buildSystemMessage(
|
||||
mockConfig,
|
||||
'test-session-id',
|
||||
'auto' as PermissionMode,
|
||||
[], // Empty array - no built-in commands allowed
|
||||
);
|
||||
|
||||
// Should only include 'commit' (FILE command)
|
||||
expect(result.slash_commands).toEqual(['commit']);
|
||||
// 'help' (local-jsx) and 'memory' (local without ACP) should be excluded
|
||||
expect(result.slash_commands).not.toContain('help');
|
||||
expect(result.slash_commands).not.toContain('memory');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -196,20 +196,15 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
|
|||
* Load slash command names using getAvailableCommands
|
||||
*
|
||||
* @param config - Config instance
|
||||
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
|
||||
* If not provided, uses the default from getAvailableCommands.
|
||||
* @returns Promise resolving to array of slash command names
|
||||
*/
|
||||
async function loadSlashCommandNames(
|
||||
config: Config,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<string[]> {
|
||||
async function loadSlashCommandNames(config: Config): Promise<string[]> {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const commands = await getAvailableCommands(
|
||||
config,
|
||||
controller.signal,
|
||||
allowedBuiltinCommandNames,
|
||||
'non_interactive',
|
||||
);
|
||||
|
||||
// Extract command names and sort
|
||||
|
|
@ -240,15 +235,12 @@ async function loadSlashCommandNames(
|
|||
* @param config - Config instance
|
||||
* @param sessionId - Session identifier
|
||||
* @param permissionMode - Current permission/approval mode
|
||||
* @param allowedBuiltinCommandNames - Optional array of allowed built-in command names.
|
||||
* If not provided, defaults to empty array (only file commands will be included).
|
||||
* @returns Promise resolving to CLISystemMessage
|
||||
*/
|
||||
export async function buildSystemMessage(
|
||||
config: Config,
|
||||
sessionId: string,
|
||||
permissionMode: PermissionMode,
|
||||
allowedBuiltinCommandNames?: string[],
|
||||
): Promise<CLISystemMessage> {
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
||||
|
|
@ -261,11 +253,8 @@ export async function buildSystemMessage(
|
|||
}))
|
||||
: [];
|
||||
|
||||
// Load slash commands with filtering based on allowed built-in commands
|
||||
const slashCommands = await loadSlashCommandNames(
|
||||
config,
|
||||
allowedBuiltinCommandNames,
|
||||
);
|
||||
// Load slash commands available in ACP mode
|
||||
const slashCommands = await loadSlashCommandNames(config);
|
||||
|
||||
// Load subagent names from config
|
||||
let agentNames: string[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue