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:
顾盼 2026-04-20 14:34:43 +08:00 committed by GitHub
parent 6c999fe29f
commit a82d766727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2350 additions and 307 deletions

View file

@ -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',

View file

@ -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

View file

@ -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 [];

View file

@ -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',
);
});

View file

@ -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');

View file

@ -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);

View file

@ -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,
}));
}
}

View file

@ -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;

View file

@ -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,
);
}
}

View file

@ -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',

View file

@ -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,

View 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([]);
});
});

View 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),
);
}

View file

@ -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);

View file

@ -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',

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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,

View file

@ -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);

View file

@ -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;

View file

@ -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';

View file

@ -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');

View file

@ -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();

View file

@ -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 },

View file

@ -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}`;

View file

@ -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',

View file

@ -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,
},
],

View file

@ -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,

View file

@ -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');
},

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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...'));

View file

@ -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,

View file

@ -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',

View file

@ -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.',
});
});
});

View file

@ -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',
}),
};

View file

@ -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 [

View file

@ -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',

View file

@ -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,

View file

@ -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;

View file

@ -151,6 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: restoreAction,
completion,
};

View file

@ -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');
},

View file

@ -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',

View file

@ -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> => {

View file

@ -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+/);

View file

@ -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(
{

View file

@ -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';

View file

@ -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;

View file

@ -23,6 +23,7 @@ export const terminalSetupCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (): Promise<MessageActionReturn> => {
try {

View file

@ -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',

View file

@ -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();

View file

@ -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',

View file

@ -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,

View file

@ -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();

View file

@ -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);

View file

@ -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');
});
});

View file

@ -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[] = [];