feat(cli): Phase 2 — slash command multi-mode expansion, ACP fixes, and UX improvements (#3377)

* 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

* feat(cli): Phase 2 slash command expansion + ACP fixes + UX improvements

Phase 2.1 - Command mode expansion:
- Extend 13 built-in commands to support non_interactive/acp modes
- A class: export, plan, statusline - supportedModes only
- A+ class: language, copy, restore - add non-interactive branches
- A' class: model, approvalMode - handle dialog paths in non-interactive
- B class: about, stats, insight, docs, clear - full non-interactive branches
- context: format output as readable Markdown instead of raw JSON
- export: use HTML as default format when no subcommand given

Phase 2.2 - SkillTool integration:
- SkillTool now consumes CommandService.getModelInvocableCommands()

Phase 2.3 - Mid-input slash ghost text:
- Replace mid-input dropdown completion with inline ghost text
- Match Claude Code behavior: gray dimmed completion hint in input box
- Tab accepts the ghost text completion
- Add findMidInputSlashCommand() and getBestSlashCommandMatch() utilities

ACP session bug fixes:
- Fix executionMode undefined in interactive mode (slashCommandProcessor)
- Fix slash command output not visible in Zed (use emitAgentMessage)
- Fix newline rendering in Zed (Markdown hard line-break)
- Fix history replay merging consecutive user messages (recordSlashCommand)
- Fix /clear not clearing model context (dynamic chat reference)

* feat: inline complete only for modelInvocable

* 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

* fix mcp prompt in skill manager

* revert pr#3345

* fix test ci

* feat(cli): adapt /insight for non_interactive mode with message return

- non_interactive: run generateStaticInsight() synchronously with no-op
  progress callback, return { type: 'message' } with output path
- acp: keep existing stream_messages path with progress streaming
- interactive: unchanged

Add tests for non_interactive success and error paths.

Update phase2-technical-design.md and roadmap.md to reflect the
three-way mode split and clarify that MCP prompts do not need
modelInvocable (they are called via native MCP tool call mechanism).

* fix(cli): ghost text only shown when cursor is at end of slash token

Use strict equality (!==) instead of > in findMidInputSlashCommand so that
ghost text is only computed and Tab-accepted when the cursor sits exactly at
the trailing edge of the partial command token.

Previously, with the cursor inside an already-typed token (e.g. /re|view),
the ghost text suffix would still be shown and pressing Tab would insert it
at the cursor position, producing a duplicated tail. Using strict equality
makes ghost text disappear as soon as the cursor moves inside the token.

Add unit tests for findMidInputSlashCommand covering cursor-at-end,
cursor-inside-token, cursor-past-token, start-of-line, and
no-space-before-slash cases.

* fix(cli): support /model <model-id> in non-interactive and ACP modes

Previously, /model <model-id> (without --fast) fell through to the
non-interactive branch that only returned the current model info and
incorrectly told users to use --fast. Now:

- /model <model-id>  → sets the main model via settings + config.setModel()
- /model             → shows current model with correct usage hint
- /model --fast <id> → unchanged (sets fast model)

Fixes the inconsistency flagged in PR review: the help text said to use
'/model <model-id>' but the command returned a dialog action which is
unsupported in non-interactive mode.

* fix(cli): declare supportedModes on doctorCommand to enable non-interactive and ACP

The command's action already had non-interactive handling (returns a JSON
message with check results), but without supportedModes declared the
BUILT_IN fallback restricted it to interactive-only so it was never
registered in non_interactive or acp sessions.

* feat(skills): add SkillCommandLoader for user/project/extension skills as slash commands

- New SkillCommandLoader loads user, project, and extension level SKILL.md
  files as slash commands (previously only bundled skills were slash-invocable)
- Extension skills follow plugin-command rules: modelInvocable only when
  description or whenToUse is present
- User/project skills are always modelInvocable (matching bundled behavior)
- skill-manager now injects extensionName when loading extension-level skills
- Add when_to_use and disable-model-invocation frontmatter support to SKILL.md
  and .md command files (SkillConfig, markdown-command-parser, command-factory,
  BundledSkillLoader, FileCommandLoader)
- SkillTool filters out skills with disableModelInvocation and includes
  whenToUse in the skill description shown to the model
- 16 unit tests for SkillCommandLoader covering all cases

* docs: update phase2 design doc to reflect final decisions on plan/statusline/copy/restore

These four commands are intentionally kept as interactive-only by design:
- /plan and /statusline: tightly coupled with interactive multi-turn UI
- /copy and /restore: clipboard and snapshot restore are inherently interactive

Update design doc classification table, section 4.2, 4.3, 5.2, 5.3,
file change summary, test requirements, behavior analysis table,
and implementation batch descriptions to reflect this decision.

* feat(cli): re-implement slashCommands.disabled denylist based on current refactored code

Adapts the feature originally introduced in pr#3445 to the current
CommandService / Phase-2 refactored code.

Sources (merged, de-duplicated, case-insensitive):
  - settings key slashCommands.disabled (string[], UNION merge)
  - --disabled-slash-commands CLI flag (comma-separated or repeated)
  - QWEN_DISABLED_SLASH_COMMANDS environment variable

Enforcement points:
  - CommandService.create() accepts optional disabledNames: ReadonlySet<string>
    and removes matching commands post-rename, so disabled commands never appear
    in autocomplete, mid-input ghost text, or model-invocable commands list.
  - slashCommandProcessor (interactive TUI) passes the denylist to
    CommandService.create so disabled commands are absent from dropdown/ghost text.
  - nonInteractiveCliCommands.handleSlashCommand() keeps allCommands unfiltered
    to distinguish disabled vs unknown; disabled commands return unsupported with
    a "disabled by the current configuration" reason (not no_command).
  - getAvailableCommands() (ACP) passes the denylist to CommandService.create.

Config plumbing:
  - core/Config: ConfigParameters.disabledSlashCommands + getDisabledSlashCommands()
  - cli/config: CliArgs.disabledSlashCommands + yargs option + loadCliConfig merge
  - settingsSchema: slashCommands.disabled (MergeStrategy.UNION)
  - settings.schema.json: regenerated

Tests: 28 pass (CommandService x4, nonInteractiveCliCommands x3 new cases)

* feat(cli): complete slashCommands.disabled coverage from pr#3445

Fill in the three items that were missing from the initial re-implementation:

- packages/cli/src/config/settings.test.ts: add UNION-merge test for
  slashCommands.disabled across user and workspace scopes
- packages/cli/src/nonInteractiveCli.test.ts: add getDisabledSlashCommands
  mock to the shared mockConfig fixture
- docs/users/configuration/settings.md: add slashCommands section (table +
  example + note) and --disabled-slash-commands row in the CLI args table

* fix(cli): match disabled slash commands by alias as well as primary name

The denylist previously only checked cmd.name (the primary/canonical name),
so disabling a command by its alias (e.g. 'about' for the 'status' command)
had no effect. Fix both CommandService.create() and the isDisabled() helper
in nonInteractiveCliCommands.ts to also check altNames.

Also improve the user-facing error message to show the token the user actually
typed (e.g. /about) instead of always showing the primary name (/status).
This commit is contained in:
顾盼 2026-04-22 19:12:44 +08:00 committed by GitHub
parent 58cdf101ba
commit 2710bdec0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2934 additions and 635 deletions

View file

@ -1126,6 +1126,36 @@ const SETTINGS_SCHEMA = {
},
},
slashCommands: {
type: 'object',
label: 'Slash Commands',
category: 'Advanced',
requiresRestart: true,
default: {},
description:
'Configuration for slash commands exposed by the CLI. Useful for ' +
'locking down the command surface in multi-tenant or enterprise ' +
'deployments.',
showInDialog: false,
properties: {
disabled: {
type: 'array',
label: 'Disabled Slash Commands',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Slash command names to hide and refuse to execute. Matched ' +
'case-insensitively against the final command name (for extension ' +
'commands this is the disambiguated form, e.g. "myext.deploy"). ' +
'Merged as a union across settings scopes, so workspace settings ' +
'can add to but not remove entries defined in system/user settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
},
},
permissions: {
type: 'object',
label: 'Permissions',
@ -1175,36 +1205,6 @@ const SETTINGS_SCHEMA = {
},
},
slashCommands: {
type: 'object',
label: 'Slash Commands',
category: 'Advanced',
requiresRestart: true,
default: {},
description:
'Configuration for slash commands exposed by the CLI. Useful for ' +
'locking down the command surface in multi-tenant or enterprise ' +
'deployments.',
showInDialog: false,
properties: {
disabled: {
type: 'array',
label: 'Disabled Slash Commands',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Slash command names to hide and refuse to execute. Matched ' +
'case-insensitively against the final command name (for extension ' +
'commands this is the disambiguated form, e.g. "myext.deploy"). ' +
'Merged as a union across settings scopes, so workspace settings ' +
'can add to but not remove entries defined in system/user settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
},
},
tools: {
type: 'object',
label: 'Tools',