Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
768 changes: 768 additions & 0 deletions docs/design/slash-command/phase3-technical-design.md

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions docs/design/slash-command/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,11 @@

#### 3.5 Claude Code 缺失命令补齐

补充 Qwen Code 当前没有、Claude Code 有且常用的命令:
确认并回归 Qwen Code 已有的 `/doctor` 命令;`/release-notes` 不纳入本阶段,避免引入无明确产品需求的 built-in 命令表面。

| 命令 | 类型 | 说明 |
| ---------------- | ------- | ---------------------------------------- |
| `/doctor` | `local` | 环境自检,输出配置/连接/工具状态诊断 |
| `/release-notes` | `local` | 展示当前版本的更新日志 |
| `/cost` | `local` | 展示当前 session 的 token 消耗和费用估算 |
| 命令 | 类型 | 说明 |
| --------- | ------- | ------------------------------------ |
| `/doctor` | `local` | 环境自检,输出配置/连接/工具状态诊断 |

> 注:`/review`、`/commit` 等任务类命令以 bundled skill 形式提供,不在此列。

Expand All @@ -275,10 +273,11 @@
- [ ] 近期使用的命令在补全列表中优先出现
- [ ] alias 命中时在补全项中注明原名
- [ ] mid-input slash:ghost text 提示正确渲染
- [ ] `/help` 输出按来源分组,每条命令展示支持模式标记
- [ ] `/help` 以 Claude Code 风格分 tab 展示,避免命令堆砌,并在命令页展示支持模式标记
- [ ] ACP available commands 包含 `argumentHint`、`source`、`subcommands` 字段
- [ ] `/doctor`、`/release-notes`、`/cost` 三个命令可用
- [ ] `/doctor` 命令可用
- [ ] `/doctor` 在 non-interactive 模式下可执行(返回 `message`)
- [ ] 不新增 `/release-notes`

---

Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
} from '@agentclientprotocol/sdk';
import type { LoadedSettings } from '../../config/settings.js';
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
import { CommandKind } from '../../ui/commands/types.js';

vi.mock('../../nonInteractiveCliCommands.js', () => ({
ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE: [
Expand Down Expand Up @@ -222,6 +223,23 @@ describe('Session', () => {
name: 'init',
description: 'Initialize project context',
argumentHint: '[path]',
source: 'builtin-command',
sourceLabel: 'Built-in',
supportedModes: ['interactive', 'non_interactive', 'acp'],
modelInvocable: false,
subCommands: [
{
name: 'visible',
description: 'Visible subcommand',
kind: CommandKind.BUILT_IN,
},
{
name: 'hidden',
description: 'Hidden subcommand',
kind: CommandKind.BUILT_IN,
hidden: true,
},
],
},
]);

Expand All @@ -241,6 +259,14 @@ describe('Session', () => {
name: 'init',
description: 'Initialize project context',
input: { hint: '[path]' },
_meta: {
argumentHint: '[path]',
source: 'builtin-command',
sourceLabel: 'Built-in',
supportedModes: ['interactive', 'non_interactive', 'acp'],
subcommands: ['visible'],
modelInvocable: false,
},
},
],
},
Expand Down Expand Up @@ -275,6 +301,14 @@ describe('Session', () => {
name: 'init',
description: 'Initialize project context',
input: null,
_meta: {
argumentHint: undefined,
source: undefined,
sourceLabel: undefined,
supportedModes: ['interactive'],
subcommands: [],
modelInvocable: false,
},
},
],
_meta: {
Expand Down
25 changes: 19 additions & 6 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import {
getSubagentSystemReminder,
getArenaSystemReminder,
} from '@qwen-code/qwen-code-core';
import { getCommandSubcommandNames } from '../../services/commandMetadata.js';
import { getEffectiveSupportedModes } from '../../services/commandUtils.js';

import { RequestError } from '@agentclientprotocol/sdk';
import type {
Expand Down Expand Up @@ -976,13 +978,24 @@ export class Session implements SessionContext {
'acp',
);

// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol.
// Keep existing fields stable and attach Phase 3 metadata under _meta for
// forward-compatible clients.
const availableCommands: AvailableCommand[] = slashCommands.map(
(cmd) => ({
name: cmd.name,
description: cmd.description,
input: cmd.argumentHint ? { hint: cmd.argumentHint } : null,
}),
(cmd) =>
({
name: cmd.name,
description: cmd.description,
input: cmd.argumentHint ? { hint: cmd.argumentHint } : null,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This replaces PR #3618's acceptsInput logic with a simple cmd.argumentHint check, causing a functional regression in ACP mode.

Commands with subCommands but no argumentHint (e.g., /memory) will get input: null and auto-submit before the user can choose a subcommand. The same applies to commands with completion functions.

Suggested change
input: cmd.argumentHint ? { hint: cmd.argumentHint } : null,
input: (() => {
const acceptsInput =
cmd.kind !== CommandKind.BUILT_IN ||
cmd.completion != null ||
cmd.argumentHint != null ||
(cmd.subCommands != null && cmd.subCommands.length > 0);
return acceptsInput
? { hint: cmd.argumentHint ?? '' }
: null;
})(),

— deepseek-v4-pro via Qwen Code /review

_meta: {
argumentHint: cmd.argumentHint,
source: cmd.source,
sourceLabel: cmd.sourceLabel,
supportedModes: getEffectiveSupportedModes(cmd),
subcommands: getCommandSubcommandNames(cmd),
modelInvocable: cmd.modelInvocable === true,
},
}) as AvailableCommand,
);

let availableSkills: string[] | undefined;
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,11 +1220,13 @@ export async function loadCliConfig(
: settings.tools?.discoveryCommand,
toolCallCommand: bareMode ? undefined : settings.tools?.callCommand,
mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand,
mcpServers: bareMode ? {} : (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
mcpServers: bareMode
? {}
: (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
Expand Down
142 changes: 142 additions & 0 deletions packages/cli/src/services/commandMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import type { CommandSource, SlashCommand } from '../ui/commands/types.js';
import { getEffectiveSupportedModes } from './commandUtils.js';

export type CommandSourceGroup = {
key: 'built-in' | 'bundled-skill' | 'custom' | 'plugin' | 'mcp' | 'other';
title: string;
order: number;
};

export function getCommandSourceBadge(
command: Pick<SlashCommand, 'source' | 'sourceLabel'>,
): string | null {
switch (command.source) {
case 'bundled-skill':
return '[Skill]';
case 'skill-dir-command':
if (command.sourceLabel === 'User') return '[User]';
if (command.sourceLabel === 'Project') return '[Project]';
return '[Custom]';
case 'plugin-command':
return command.sourceLabel?.startsWith('Extension:')
? '[Extension]'
: '[Plugin]';
case 'mcp-prompt':
return '[MCP]';
case 'builtin-command':
default:
return null;
}
}

export function getCommandSourceGroup(
command: Pick<SlashCommand, 'source'>,
): CommandSourceGroup {
switch (command.source) {
case 'builtin-command':
return { key: 'built-in', title: 'Built-in Commands', order: 0 };
case 'bundled-skill':
return { key: 'bundled-skill', title: 'Bundled Skills', order: 1 };
case 'skill-dir-command':
return { key: 'custom', title: 'Custom Commands', order: 2 };
case 'plugin-command':
return { key: 'plugin', title: 'Plugin Commands', order: 3 };
case 'mcp-prompt':
return { key: 'mcp', title: 'MCP Commands', order: 4 };
default:
return { key: 'other', title: 'Other Commands', order: 5 };
}
}

export function formatSupportedModes(command: SlashCommand): string {
const modes = getEffectiveSupportedModes(command);
const hasInteractive = modes.includes('interactive');
const hasNonInteractive = modes.includes('non_interactive');
const hasAcp = modes.includes('acp');

if (hasInteractive && hasNonInteractive && hasAcp) {
return '[all]';
}

if (!hasInteractive && hasNonInteractive && hasAcp) {
return '[headless]';
}

if (hasInteractive && !hasNonInteractive && !hasAcp) {
return '[interactive]';
}

return modes
.map((mode) => {
switch (mode) {
case 'interactive':
return '[i]';
case 'non_interactive':
return '[ni]';
case 'acp':
return '[acp]';
default:
return `[${mode}]`;
}
})
.join(' ');
}

export function getCommandDisplayName(
command: Pick<SlashCommand, 'name' | 'altNames'>,
options: {
prefix?: string;
matchedAlias?: string;
includeAliases?: boolean;
} = {},
): string {
const prefix = options.prefix ?? '';
const baseLabel = `${prefix}${command.name}`;

if (options.matchedAlias) {
return `${baseLabel} (alias: ${options.matchedAlias})`;
}

if (options.includeAliases === false) {
return baseLabel;
}

const altNames = command.altNames?.filter(Boolean);
if (!altNames || altNames.length === 0) {
return baseLabel;
}

return `${baseLabel} (${altNames.join(', ')})`;
}

export function getCommandSubcommandNames(command: SlashCommand): string[] {
return (
command.subCommands
?.filter((subCommand) => !subCommand.hidden)
.map((subCommand) => subCommand.name) ?? []
);
}

export function formatCommandSourceLabel(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] formatCommandSourceLabel 已导出并有完整测试,但在整个代码库中无任何调用方,是死代码。建议:接入 SuggestionsDisplay 或 Help 组件,或从本次 PR 中移除。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

command: Pick<SlashCommand, 'source' | 'sourceLabel'>,
): string {
if (command.sourceLabel) {
return command.sourceLabel;
}

const fallbackLabels: Record<CommandSource, string> = {
'builtin-command': 'Built-in',
'bundled-skill': 'Skill',
'skill-dir-command': 'Custom',
'plugin-command': 'Plugin',
'mcp-prompt': 'MCP',
};

return command.source ? fallbackLabels[command.source] : 'Unknown';
}
27 changes: 27 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,13 @@ export const AppContainer = (props: AppContainerProps) => {
addItem: historyManager.addItem,
});

const [isHelpDialogOpen, setHelpDialogOpen] = useState(false);
const [activeHelpTab, setHelpTab] = useState<
'general' | 'commands' | 'custom-commands'
>('general');
const openHelpDialog = useCallback(() => setHelpDialogOpen(true), []);
const closeHelpDialog = useCallback(() => setHelpDialogOpen(false), []);

const { toggleVimEnabled } = useVimMode();

const {
Expand Down Expand Up @@ -707,6 +714,7 @@ export const AppContainer = (props: AppContainerProps) => {
openRewindSelector: () => openRewindSelectorRef.current(),
handleResume,
openDeleteDialog,
openHelpDialog,
}),
[
openAuthDialog,
Expand All @@ -731,12 +739,14 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
handleResume,
openDeleteDialog,
openHelpDialog,
],
);

const {
handleSlashCommand,
slashCommands,
recentSlashCommands,
pendingHistoryItems: pendingSlashCommandHistoryItems,
btwItem,
setBtwItem,
Expand Down Expand Up @@ -1597,6 +1607,7 @@ export const AppContainer = (props: AppContainerProps) => {
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isDeleteDialogOpen ||
isHelpDialogOpen ||
isExtensionsManagerDialogOpen ||
isRewindSelectorOpen ||
bgTasksDialogOpen;
Expand Down Expand Up @@ -1924,6 +1935,8 @@ export const AppContainer = (props: AppContainerProps) => {
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
isHelpDialogOpen,
closeHelpDialog,
isBackgroundTasksDialogOpen: bgTasksDialogOpen,
closeBackgroundTasksDialog: closeBgTasksDialog,
});
Expand Down Expand Up @@ -2293,7 +2306,10 @@ export const AppContainer = (props: AppContainerProps) => {
isResumeDialogOpen,
resumeMatchedSessions,
isDeleteDialogOpen,
isHelpDialogOpen,
activeHelpTab,
slashCommands,
recentSlashCommands,
pendingSlashCommandHistoryItems,
commandContext,
shellConfirmationRequest,
Expand Down Expand Up @@ -2414,7 +2430,10 @@ export const AppContainer = (props: AppContainerProps) => {
isResumeDialogOpen,
resumeMatchedSessions,
isDeleteDialogOpen,
isHelpDialogOpen,
activeHelpTab,
slashCommands,
recentSlashCommands,
pendingSlashCommandHistoryItems,
commandContext,
shellConfirmationRequest,
Expand Down Expand Up @@ -2576,6 +2595,10 @@ export const AppContainer = (props: AppContainerProps) => {
openDeleteDialog,
closeDeleteDialog,
handleDelete,
// Help dialog
openHelpDialog,
closeHelpDialog,
setHelpTab,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
Expand Down Expand Up @@ -2648,6 +2671,10 @@ export const AppContainer = (props: AppContainerProps) => {
openDeleteDialog,
closeDeleteDialog,
handleDelete,
// Help dialog
openHelpDialog,
closeHelpDialog,
setHelpTab,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
Expand Down
Loading
Loading