diff --git a/docs/design/slash-command/phase3-technical-design.md b/docs/design/slash-command/phase3-technical-design.md new file mode 100644 index 00000000000..3cc741e1953 --- /dev/null +++ b/docs/design/slash-command/phase3-technical-design.md @@ -0,0 +1,768 @@ +# Phase 3 技术设计文档:体验对齐 + +## 1. 设计目标与约束 + +### 1.1 目标 + +Phase 3 在 Phase 1/2 已落地的命令元数据、跨模式过滤和 prompt command 模型调用基础上,补齐用户可感知的 slash command 体验: + +- 补全菜单展示来源、参数提示、alias 命中,并引入 session 级最近使用排序 +- 完善 mid-input slash command 的 ghost text、参数提示、来源展示和有效 token 高亮 +- 将 `/help` 从当前不可用的命令堆砌重构为 Claude Code 风格的分 tab、清晰、美观的帮助面板 +- 增强 ACP `available_commands_update` 的命令元数据 +- 确认已实现的 `/doctor` 不重复实现;`/release-notes` 不纳入本阶段 + +### 1.2 硬性约束 + +- **代码为准**:Phase 1/2 文档与实现存在差异时,以当前主分支源码为准。 +- **不引入新执行架构**:继续复用现有 `SlashCommand`、`CommandService`、`handleSlashCommand`、`useSlashCompletion` 和 `Help` 组件,不新建 `CommandDescriptor` / `CommandExecutor` / `ModeAdapter`。 +- **不恢复 `commandType`**:当前实现已删除 Phase 1 早期设计中的 `commandType` 字段,Phase 3 不重新引入该字段。 +- **session 级 recently used**:最近使用排序只在当前 CLI session 内生效,不持久化到磁盘。 +- **interactive 行为不退化**:补全、help、doctor 等已有 interactive 行为保持可用;Phase 3 只增强展示与补齐缺失命令。 +- **ACP 向后兼容**:`availableCommands[].name`、`description`、`input` 三个已有字段保持不变;新增元数据放在兼容字段或 `_meta` 中,避免破坏已有 ACP 客户端。 + +--- + +## 2. 当前实现基线(源码审计结论) + +### 2.1 已有元数据与 Loader 行为 + +`packages/cli/src/ui/commands/types.ts` 当前 `SlashCommand` 已包含: + +- `source?: CommandSource` +- `sourceLabel?: string` +- `supportedModes?: ExecutionMode[]` +- `userInvocable?: boolean` +- `modelInvocable?: boolean` +- `argumentHint?: string` +- `whenToUse?: string` +- `examples?: string[]` + +`CommandSource` 当前支持: + +```typescript +export type CommandSource = + | 'builtin-command' + | 'bundled-skill' + | 'skill-dir-command' + | 'plugin-command' + | 'mcp-prompt'; +``` + +各 Loader 当前已填充的展示信息: + +| Loader | source | sourceLabel | argumentHint | modelInvocable | +| --------------------------------------- | -------------------------------------- | ---------------------------------------- | ---------------- | ------------------------------------------------ | +| `BuiltinCommandLoader` | `builtin-command` | `Built-in` | 多数未声明 | `false` | +| `BundledSkillLoader` | `bundled-skill` | `Skill` | 来自 skill | `!disableModelInvocation` | +| `FileCommandLoader` / `command-factory` | `skill-dir-command` / `plugin-command` | `Custom` / `Plugin: ` | 来自 frontmatter | 用户/项目默认 true;插件需 description/whenToUse | +| `SkillCommandLoader` | `skill-dir-command` / `plugin-command` | `User` / `Project` / `Extension: ` | 来自 skill | 用户/项目默认 true;插件需 description/whenToUse | +| `McpPromptLoader` | `mcp-prompt` | `MCP: ` | 未生成 | 当前未显式设置 `modelInvocable` | + +> 注意:Phase 1 路线图曾要求 MCP prompt `modelInvocable: true`,但当前实现没有显式设置。Phase 3 不改变 MCP prompt 的模型调用路径;MCP prompt 仍通过 MCP 原生机制调用,不通过 `SkillTool` 中转。 + +### 2.2 当前已实现的 Phase 3 相关能力 + +| 能力 | 当前状态 | 关键文件 | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| mid-input slash 基础 ghost text | 已部分实现,仅对 `modelInvocable` 命令做前缀补全 | `ui/utils/commandUtils.ts`、`ui/hooks/useCommandCompletion.tsx` | +| line-start 命令 argument ghost text | 已部分实现,命令完全匹配且无 args 时展示 `argumentHint` | `ui/hooks/useCommandCompletion.tsx` | +| alias 参与匹配 | 已实现匹配与排序,但展示总是显示全部 alias,不区分命中 alias | `ui/hooks/useSlashCompletion.ts` | +| source badge | 仅 MCP 展示 `[MCP]` | `ui/components/SuggestionsDisplay.tsx`、`ui/components/Help.tsx` | +| `/help` | 当前实现视为未完成:虽有分组尝试,但仍是命令堆砌,不具备 Claude Code 风格的分 tab、清晰可读帮助面板体验 | `ui/components/Help.tsx` | +| ACP `argumentHint` | 已映射到 `availableCommands[].input.hint` | `acp-integration/session/Session.ts` | +| ACP source/supportedModes/subcommands/modelInvocable | 未暴露 | `acp-integration/session/Session.ts` | +| 冲突处理 | extension 命令冲突时已重命名为 `extensionName.commandName`,非 extension 同名为后加载覆盖前加载 | `services/CommandService.ts` | +| `/doctor` | 已实现,支持 `interactive` / `non_interactive` / `acp` | `ui/commands/doctorCommand.ts`、`utils/doctorChecks.ts` | + +### 2.3 Claude Code 可借鉴点 + +参考 `/Users/mochi/code/claude-code` 源码: + +- `src/types/command.ts`:命令模型包含 `argumentHint`、`whenToUse`、`aliases`、`loadedFrom`、`kind`、`immediate`、`isSensitive`、`userFacingName`、`supportsNonInteractive` 等展示/能力字段。 +- `src/utils/suggestions/commandSuggestions.ts`:补全排序同时考虑精确命中、alias 命中、prefix、fuzzy、skill usage;alias 命中时只展示用户实际命中的 alias。 +- `src/utils/suggestions/commandSuggestions.ts`:mid-input slash 使用 `findMidInputSlashCommand()`、`getBestCommandMatch()` 和 `findSlashCommandPositions()` 支持 ghost text 与高亮。 +- `src/components/HelpV2/Commands.tsx`:Help V2 是可浏览的命令目录,展示描述时会附带来源信息。 +- `src/commands.ts`:Claude Code 内置 `/doctor`、`/release-notes` 等命令,Qwen Code 当前已实现 `/doctor`;本阶段不实现 `/release-notes`。 + +Phase 3 采用“体验对齐,不复制架构”的方式借鉴上述点。 + +--- + +## 3. 总体方案 + +### 3.1 文件变更总览 + +| 文件 | 变更内容 | +| ------------------------------------------------------- | ------------------------------------------------------------------------- | +| `packages/cli/src/ui/components/SuggestionsDisplay.tsx` | 扩展 `Suggestion` 类型,展示 source badge、argumentHint、aliasHit | +| `packages/cli/src/ui/hooks/useSlashCompletion.ts` | 生成增强补全项;排序接入 recently used;保留 alias 命中信息 | +| `packages/cli/src/ui/hooks/useCommandCompletion.tsx` | mid-input ghost text 复用增强匹配;输出 argument/source 元数据供 UI 展示 | +| `packages/cli/src/ui/utils/commandUtils.ts` | 增加 slash token 高亮辅助函数,或扩展现有函数返回命令有效性 | +| `packages/cli/src/ui/components/InputPrompt.tsx` | 渲染有效 slash command token 高亮;保留 Tab 接受 ghost text | +| `packages/cli/src/ui/components/Help.tsx` | 重构为 Claude Code 风格的分 tab 帮助面板,避免命令堆砌 | +| `packages/cli/src/ui/commands/helpCommand.ts` | 如需 non-interactive/acp 帮助文本,扩展 action;否则仅保持 interactive UI | +| `packages/cli/src/acp-integration/session/Session.ts` | 在 ACP update 中暴露增强元数据 | +| `packages/cli/src/ui/commands/*Command.ts` | 为常用 built-in 命令补充 `argumentHint` | + +### 3.2 新增共享展示工具 + +建议新增 `packages/cli/src/services/commandMetadata.ts`,集中处理 Help、Completion、ACP 共同需要的展示逻辑: + +```typescript +export function getCommandSourceBadge(cmd: SlashCommand): string | null; +export function getCommandSourceGroup(cmd: SlashCommand): CommandSourceGroup; +export function formatSupportedModes(cmd: SlashCommand): string; +export function getCommandDisplayName(cmd: SlashCommand): string; +export function getCommandSubcommandNames(cmd: SlashCommand): string[]; +``` + +不建议把这些展示函数放入 Loader,避免 Loader 承担 UI 逻辑。 + +--- + +## 4. Phase 3.1:补全体验增强 + +### 4.1 扩展 `Suggestion` 数据结构 + +当前: + +```typescript +export interface Suggestion { + label: string; + value: string; + description?: string; + matchedIndex?: number; + commandKind?: CommandKind; +} +``` + +建议扩展为: + +```typescript +export interface Suggestion { + label: string; + value: string; + description?: string; + matchedIndex?: number; + commandKind?: CommandKind; + + // Phase 3 + source?: CommandSource; + sourceLabel?: string; + sourceBadge?: string; + argumentHint?: string; + matchedAlias?: string; + supportedModes?: ExecutionMode[]; + modelInvocable?: boolean; +} +``` + +`mode !== 'slash'` 的文件补全、reverse search 不需要填充这些字段。 + +### 4.2 source badge 展示 + +当前 `SuggestionsDisplay` 只对 `CommandKind.MCP_PROMPT` 追加 `[MCP]`。Phase 3 改为使用 `source` / `sourceLabel` 统一生成 badge: + +| source / sourceLabel | badge | +| --------------------------------- | ------------------------------------------ | +| `builtin-command` | `[Built-in]`(可选:默认不展示,降低噪音) | +| `bundled-skill` / `Skill` | `[Skill]` | +| `skill-dir-command` / `User` | `[User]` | +| `skill-dir-command` / `Project` | `[Project]` | +| `skill-dir-command` / `Custom` | `[Custom]` | +| `plugin-command` / `Plugin: x` | `[Plugin]` 或 `[Plugin: x]` | +| `plugin-command` / `Extension: x` | `[Extension]` 或 `[Extension: x]` | +| `mcp-prompt` | `[MCP]` | + +推荐实现: + +```typescript +function getCommandSourceBadge(cmd: SlashCommand): string | null { + switch (cmd.source) { + case 'bundled-skill': + return '[Skill]'; + case 'skill-dir-command': + return cmd.sourceLabel === 'User' + ? '[User]' + : cmd.sourceLabel === 'Project' + ? '[Project]' + : '[Custom]'; + case 'plugin-command': + return '[Plugin]'; + case 'mcp-prompt': + return '[MCP]'; + case 'builtin-command': + default: + return null; + } +} +``` + +> 是否展示 `[Built-in]` 由 UI 可读性决定。Help 中必须展示 Built-in 分组;补全菜单中可以省略 built-in badge,只对非内置来源展示 badge。 + +### 4.3 argument hint 展示 + +补全菜单中命令名后追加灰色 `argumentHint`: + +```text +/model Switch model +/export md|html|json|jsonl Export current session +/review [pr-number] [--comment] [Skill] Review changed code +``` + +实现建议: + +- `useSlashCompletion` 在 `finalSuggestions` 中填充 `argumentHint: cmd.argumentHint` +- `SuggestionsDisplay` 在 label 后以 `theme.text.secondary` 渲染 `argumentHint` +- `commandColumnWidth` 计算包含 label + hint + badge,避免描述列错位 +- 子命令补全也支持 `argumentHint` + +需要先为常用 built-in 命令补充 `argumentHint`。建议首批: + +| 命令 | argumentHint | +| ---------------- | ----------------------- | ------------------ | -------- | ------------- | ------- | +| `/model` | `[--fast] []` | +| `/approval-mode` | `` | +| `/language` | `ui | output ` | +| `/export` | `md | html | json | jsonl [path]` | +| `/memory` | `show | add | refresh` | +| `/mcp` | `desc | nodesc | schema | auth | noauth` | +| `/stats` | `[model | tools]` | +| `/docs` | 空或不设置 | +| `/doctor` | 空或不设置 | + +### 4.4 recently used 排序 + +#### 4.4.1 状态存储 + +在 `useSlashCommandProcessor` 或 `AppContainer` 中维护 session 级最近使用状态: + +```typescript +type RecentSlashCommand = { + name: string; + usedAt: number; + count: number; +}; +``` + +建议以 `Map` 存储,key 使用最终命令名(即冲突处理后的 `cmd.name`)。 + +#### 4.4.2 记录时机 + +在 `useSlashCommandProcessor.handleSlashCommand` 成功解析到 `commandToExecute` 后记录使用: + +- 未找到命令不记录 +- hidden 命令可不记录 +- alias 调用按 canonical `commandToExecute.name` 记录 +- 子命令调用建议记录父命令和叶子命令完整路径,首期只记录叶子命令也可接受 + +#### 4.4.3 排序权重 + +当前 `compareRankedCommandMatches()` 排序顺序是: + +1. matchStrength +2. completionPriority +3. fzf score +4. match start +5. item length +6. original index + +Phase 3 插入 `recentScore`: + +```typescript +return ( + right.matchStrength - left.matchStrength || + right.completionPriority - left.completionPriority || + right.recentScore - left.recentScore || + right.score - left.score || + left.start - right.start || + left.itemLength - right.itemLength || + left.originalIndex - right.originalIndex +); +``` + +`recentScore` 建议: + +```typescript +const RECENT_DECAY_MS = 10 * 60 * 1000; +const recentScore = count * 10 + Math.max(0, 10 - ageMs / RECENT_DECAY_MS); +``` + +当 query 为空(用户只输入 `/`)时,recently used 命令置顶;当 query 非空时,只在同等匹配强度下加权,避免近期命令压过明显更精确的命令。 + +### 4.5 alias 命中展示 + +当前 alias 已参与 `AsyncFzf` 和 prefix fallback,但 `formatSlashCommandLabel()` 总是显示所有 alias: + +```text +help (?) +compress (summarize) +``` + +Phase 3 改为: + +- 当用户输入命中主名:不额外展示 alias,或保持现有简洁格式 +- 当用户输入命中 alias:展示 `help (alias: ?)` +- `Suggestion.matchedAlias` 由匹配阶段写入 + +实现要点: + +```typescript +function findMatchedAlias( + cmd: SlashCommand, + query: string, +): string | undefined { + return cmd.altNames?.find((alt) => + alt.toLowerCase().startsWith(query.toLowerCase()), + ); +} +``` + +在 FZF 结果中,如果 `result.item` 来自 `altNames`,可直接将其作为 `matchedAlias`;prefix fallback 中同理。 + +--- + +## 5. Phase 3.2:mid-input slash command 完整版 + +### 5.1 当前行为 + +当前 `findMidInputSlashCommand()` 仅识别“由空白分隔的 `/xxx` token”,且要求 cursor 位于 token 末尾;`getBestSlashCommandMatch()` 只在 `modelInvocable` 命令中做字母序 prefix 匹配。 + +这符合 Phase 2 基础版目标,但 Phase 3 需要补齐展示与高亮。 + +### 5.2 ghost text 增强 + +保留当前策略:mid-input slash 只提示 `modelInvocable` 命令,因为正文中的内置命令不会作为 slash command 执行。 + +增强点: + +- 匹配算法从字母序 prefix 改为复用 `useSlashCompletion` 的排序规则(至少考虑 `completionPriority` 和 recently used) +- 返回结构扩展为: + +```typescript +export type BestSlashCommandMatch = { + suffix: string; + fullCommand: string; + command: SlashCommand; + sourceBadge?: string; + argumentHint?: string; +}; +``` + +### 5.3 mid-input source badge 与 argument hint + +由于 ghost text 位置空间有限,不建议把 badge 和 hint 直接塞入 ghost text 主体。建议展示规则: + +- ghost text 仍只渲染命令名后缀,例如输入 `please /rev` 显示 `iew` +- 当 token 已完整匹配命令且命令有 `argumentHint` 时,在 cursor 后显示淡色参数提示,例如 `/review [pr-number] [--comment]` +- source badge 仅在 dropdown 或状态提示中展示;如果 mid-input 不弹 dropdown,则可不强制显示 badge + +### 5.4 有效命令 token 高亮 + +借鉴 Claude Code `findSlashCommandPositions()`,在 `InputPrompt.renderLineWithHighlighting()` 中对正文里的有效 slash command token 着色。 + +建议新增工具函数: + +```typescript +export type SlashCommandToken = { + start: number; + end: number; + commandName: string; + valid: boolean; +}; + +export function findSlashCommandTokens( + text: string, + commands: readonly SlashCommand[], +): SlashCommandToken[]; +``` + +规则: + +- token 必须位于字符串开头或前一个字符为空白 +- token 形如 `/[a-zA-Z][a-zA-Z0-9:_-]*` +- 对 mid-input 高亮只判定 `modelInvocable` 命令为 valid +- line-start token 可判定所有 interactive 可见命令为 valid +- valid token 使用 accent 色;invalid token 保持普通文本,避免把路径 `/usr/bin` 误标为命令 + +--- + +## 6. Phase 3.3:Help 目录重构 + +### 6.1 当前问题 + +`Help.tsx` 当前输出: + +- Basics +- 平铺 `Commands:` +- `[MCP]` 说明 +- Keyboard Shortcuts + +问题: + +- 所有来源混在一起,skill、custom、plugin、MCP 难以区分 +- 不展示 `argumentHint` +- 不展示 `supportedModes` +- 不展示 `modelInvocable` +- 子命令只缩进一级,不展示来源/mode + +### 6.2 分组设计 + +按 `source` / `sourceLabel` 分组: + +1. **Built-in Commands**:`source === 'builtin-command'` +2. **Bundled Skills**:`source === 'bundled-skill'` +3. **Custom Commands**:`source === 'skill-dir-command'`,包含 `Custom` / `User` / `Project` +4. **Plugin Commands**:`source === 'plugin-command'`,包含 `Plugin:*` / `Extension:*` +5. **MCP Commands**:`source === 'mcp-prompt'` +6. **Other Commands**:source 缺失的兼容兜底 + +每组内部按命令名排序;hidden 命令不展示。 + +### 6.3 每条命令展示字段 + +格式建议: + +```text +/model [--fast] [] Switch model + source: Built-in modes: interactive, non_interactive, acp + +/review [pr-number] [--comment] Review changed code + source: Skill modes: interactive, non_interactive, acp model: yes +``` + +为避免 Help 过宽,建议压缩为单行: + +```text + /review [pr-number] [--comment] [Skill] [all] [model] - Review changed code +``` + +mode badge 建议: + +| supportedModes | badge | +| ----------------------------------- | ---------------- | +| `interactive` only | `[interactive]` | +| `interactive, non_interactive, acp` | `[all]` | +| `non_interactive, acp` | `[headless]` | +| 其他组合 | `[i] [ni] [acp]` | + +### 6.4 `/help` 是否扩展到 headless + +路线图只要求 `/help` 输出按来源分组,没有明确要求 non-interactive/acp。当前 `/help` 是 `supportedModes: ['interactive']`。 + +Phase 3 建议新增 headless 路径,但作为独立子任务: + +- `supportedModes` 改为 all modes +- interactive:继续渲染 `HistoryItemHelp` +- non_interactive/acp:返回纯文本分组目录 `message` + +如果 scope 需要收敛,可先只重构 interactive `Help` 组件,headless `/help` 延后。 + +--- + +## 7. Phase 3.4:ACP available commands 元数据增强 + +### 7.1 当前 ACP 输出 + +`Session.sendAvailableCommandsUpdate()` 当前将 `SlashCommand[]` 映射为: + +```typescript +{ + name: cmd.name, + description: cmd.description, + input: cmd.argumentHint ? { hint: cmd.argumentHint } : null, +} +``` + +其中 `argumentHint` 已通过 `input.hint` 暴露。 + +### 7.2 增强方案 + +ACP protocol 的 `AvailableCommand` 类型如果不能直接增加字段,使用 `_meta` 保持兼容: + +```typescript +const availableCommands: AvailableCommand[] = slashCommands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + input: cmd.argumentHint ? { hint: cmd.argumentHint } : null, + _meta: { + argumentHint: cmd.argumentHint, + source: cmd.source, + sourceLabel: cmd.sourceLabel, + supportedModes: cmd.supportedModes ?? getEffectiveSupportedModes(cmd), + subcommands: cmd.subCommands + ?.filter((sub) => !sub.hidden) + .map((sub) => sub.name), + modelInvocable: cmd.modelInvocable === true, + }, +})); +``` + +如果 `AvailableCommand` 类型允许扩展字段,则优先输出为一等字段: + +```typescript +{ + name, + description, + input, + argumentHint, + source, + supportedModes, + subcommands, + modelInvocable, +} +``` + +但仍建议保留 `_meta` 镜像一段时间,便于旧客户端渐进迁移。 + +### 7.3 subcommands 递归策略 + +验收标准只要求 `subcommands` 名称列表。首期输出一级子命令即可: + +```typescript +subcommands: cmd.subCommands?.map((sub) => sub.name) ?? []; +``` + +后续如果 ACP 客户端需要多级树,可扩展为: + +```typescript +type AcpSubcommandMeta = { + name: string; + description?: string; + argumentHint?: string; + subcommands?: AcpSubcommandMeta[]; +}; +``` + +--- + +## 8. Phase 3.5:Claude Code 缺失命令补齐 + +### 8.1 `/doctor`:已实现,不重复实现 + +当前 `doctorCommand` 已存在: + +- 文件:`packages/cli/src/ui/commands/doctorCommand.ts` +- 注册:`BuiltinCommandLoader` +- 模式:`['interactive', 'non_interactive', 'acp']` +- interactive:展示 `HistoryItemDoctor` +- non_interactive/acp:返回 JSON `message` +- 诊断逻辑:`packages/cli/src/utils/doctorChecks.ts` + +Phase 3 只需在 Help 和补全中为 `/doctor` 正确展示来源、mode;如需优化,可将 headless JSON 改为更适合人读的 Markdown,但这不是必需项。 + +### 8.2 `/release-notes`:不纳入本阶段 + +`/release-notes` 不再作为 Phase 3 需求。本阶段不新增命令、不注册 built-in、不编写相关测试,避免引入无明确产品需求的命令表面。 + +--- + +## 9. 冲突策略确认与展示 + +当前 `CommandService` 冲突策略: + +- extension/plugin 命令若与已存在命令同名,重命名为 `extensionName.commandName` +- 若二次冲突,追加数字后缀:`extensionName.commandName1` +- 非 extension 命令同名时,后加载覆盖前加载 + +Phase 3 不改变执行语义,只在 Help/Completion 中清晰展示最终名称和来源。 + +建议补充测试确保: + +- 被重命名的 plugin command 在补全中显示最终名称和 `[Plugin]` badge +- Help 中按 Plugin Commands 分组展示最终名称 +- ACP 输出使用最终名称 + +> 路线图中“built-in > bundled/skill-dir > plugin > mcp”的优先级,与当前实现“非 extension 后加载覆盖前加载”不完全一致。Phase 3 文档以当前 `CommandService` 源码为准,不在本阶段改冲突语义;如需严格调整优先级,应作为单独 Phase 处理,避免改变已有用户/项目命令覆盖行为。 + +--- + +## 10. 测试策略 + +### 10.1 补全测试 + +更新或新增: + +- `packages/cli/src/ui/hooks/useSlashCompletion.test.ts` +- `packages/cli/src/ui/hooks/useCommandCompletion.test.ts` +- `packages/cli/src/ui/components/SuggestionsDisplay.test.tsx`(如当前无文件则新增) + +覆盖: + +- source badge:Skill/Custom/Plugin/MCP 正确展示 +- argumentHint:命令名后展示 hint,且列宽不破坏描述 +- recently used:只输入 `/` 时近期命令排在前面;输入明确 query 时精确命中优先 +- alias 命中:输入 `?` 展示 `help (alias: ?)`,输入 `he` 不展示 alias 命中提示 +- mid-input ghost:正文 `/rev` 提示 modelInvocable `/review` 后缀 +- mid-input 不提示 built-in:正文 `/sta` 不提示 `/stats`(除非未来设计允许内嵌 built-in 执行) + +### 10.2 Help 测试 + +更新:`packages/cli/src/ui/components/Help.test.tsx` + +覆盖: + +- 按 Built-in/Bundled Skills/Custom/Plugin/MCP 分组 +- hidden 命令不展示 +- 子命令展示名称列表 +- `argumentHint`、source badge、mode badge、model badge 正确出现 +- altNames 仍可展示,但不干扰主命令名 + +### 10.3 ACP 测试 + +更新:`packages/cli/src/acp-integration/session/Session.test.ts` + +覆盖: + +- `availableCommands[].input.hint` 保持现有行为 +- 新增元数据包含 `argumentHint`、`source`、`sourceLabel`、`supportedModes`、`subcommands`、`modelInvocable` +- 无 `argumentHint` 的命令 `input: null` 保持兼容 +- `getAvailableCommands(config, signal, 'acp')` 调用保持不变 + +### 10.4 新命令测试 + +本阶段不新增 `/release-notes` 或其他 built-in 命令,因此不需要新增命令测试。仅保留 `/doctor` 既有回归测试。 + +### 10.5 E2E 测试方案 + +Phase 3 同时修改 TUI 补全、slash command 执行、ACP command metadata,单元测试不能覆盖完整用户路径。E2E 验证分三类进行: + +1. **构建本地 CLI**:先运行 `npm run build && npm run bundle`,后续使用 `node dist/cli.js` 验证本地实现。 +2. **Interactive / tmux 场景**:用于验证补全菜单、ghost text、Tab 接受、Help 渲染等 TUI 行为。 +3. **Headless / JSON 场景**:用于验证 non-interactive slash command 输出,不依赖 TUI。 +4. **ACP integration 场景**:用于验证 `available_commands_update` 元数据。 + +#### 10.5.1 E2E 前置步骤 + +```bash +npm run build && npm run bundle +``` + +Interactive 场景建议使用独立临时目录,避免污染当前仓库: + +```bash +tmux new-session -d -s qwen-slash-phase3 -x 200 -y 50 \ + "cd /tmp/qwen-slash-phase3 && /Users/mochi/code/qwen-code-test/dist/cli.js --approval-mode yolo" +sleep 3 +``` + +发送输入时拆分文本和回车,避免 TUI 吞掉提交: + +```bash +tmux send-keys -t qwen-slash-phase3 "/help" +sleep 0.5 +tmux send-keys -t qwen-slash-phase3 Enter +``` + +捕获输出: + +```bash +tmux capture-pane -t qwen-slash-phase3 -p -S -100 +``` + +清理: + +```bash +tmux kill-session -t qwen-slash-phase3 +``` + +#### 10.5.2 E2E 测试清单 + +| 场景 | 模式 | 步骤 | 预期结果 | +| ----------------------- | ---------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 补全 source badge | interactive/tmux | 输入 `/`,观察补全菜单 | skill/custom/plugin/MCP 命令展示对应 source badge;built-in 可不展示 badge | +| 补全 argument hint | interactive/tmux | 输入 `/model`、`/export` | 命令名后展示 `argumentHint`;无参数命令不展示噪声 hint | +| recently used 排序 | interactive/tmux | 先执行 `/help`,再输入 `/` | `/help` 在同等匹配条件下优先出现;精确 query 仍优先匹配 query | +| alias 命中展示 | interactive/tmux | 输入 `/?` | 补全项展示 `help (alias: ?)`;输入 `/he` 时不误显示 alias 命中 | +| mid-input ghost text | interactive/tmux | 在正文中输入 `please /rev` | 出现 `/review` 的 ghost text 后缀,Tab 可接受 | +| mid-input token 高亮 | interactive/tmux | 输入包含 `/review` 的正文 | 有效 model-invocable slash token 使用命令高亮;路径如 `/usr/bin` 不被高亮为命令 | +| Help 分组目录 | interactive/tmux | 执行 `/help` | 输出包含 Built-in Commands、Bundled Skills、Custom Commands、Plugin Commands、MCP Commands 分组;每条命令展示 source/mode/hint | +| `/doctor` headless 回归 | headless/json | 执行 `node dist/cli.js "/doctor" --approval-mode yolo --output-format json 2>/dev/null` | 返回 `message`,不触发 TUI-only 组件错误 | +| ACP metadata | integration | 运行 ACP session 并触发 `available_commands_update` | 每个 command 保留 `name`、`description`、`input.hint`,并包含 `argumentHint`、`source`、`supportedModes`、`subcommands`、`modelInvocable` | + +#### 10.5.3 Headless 命令示例 + +`/release-notes` 不纳入本阶段;headless 回归仅保留 `/doctor` 等既有命令验证。 + +### 10.6 回归测试命令 + +按 AGENTS.md,优先运行单文件测试: + +```bash +cd packages/cli && npx vitest run src/ui/hooks/useSlashCompletion.test.ts +cd packages/cli && npx vitest run src/ui/hooks/useCommandCompletion.test.ts +cd packages/cli && npx vitest run src/ui/components/Help.test.tsx +cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts +``` + +最终验证: + +```bash +npm run build && npm run typecheck +npm run build && npm run bundle +``` + +--- + +## 11. 验收标准 + +### 11.1 补全菜单 + +- [ ] 补全菜单展示 source badge(至少 `[MCP]`、`[Skill]`、`[Custom]`、`[Plugin]`) +- [ ] 补全菜单展示 `argumentHint` +- [ ] session 内最近使用命令在只输入 `/` 时优先出现 +- [ ] alias 命中时展示 `alias: `,非 alias 命中不噪声展示 +- [ ] plugin/extension 冲突重命名后的命令在补全中展示最终名称和来源 + +### 11.2 mid-input slash + +- [ ] 正文中输入 `/review` 这类 model-invocable 命令时 ghost text 正确提示 +- [ ] Tab 可接受 mid-input ghost text +- [ ] 有效 mid-input slash command token 高亮 +- [ ] built-in 命令不会在正文中被误提示为可执行内嵌命令 +- [ ] 参数提示在命令完整匹配且无 args 时显示 + +### 11.3 Help + +- [ ] `/help` 按来源分组展示命令 +- [ ] 每条命令展示名称、`argumentHint`、description、source、supportedModes 标记 +- [ ] model-invocable 命令有明确标记 +- [ ] 子命令以名称列表或缩进项展示 +- [ ] hidden 命令不展示 + +### 11.4 ACP + +- [ ] ACP `available_commands_update` 继续包含 `name`、`description`、`input.hint` +- [ ] ACP command 元数据包含 `argumentHint`、`source`、`supportedModes`、`subcommands`、`modelInvocable` +- [ ] 旧客户端忽略新增字段时不受影响 + +### 11.5 缺失命令 + +- [ ] `/doctor` 仍可用,且 non-interactive 返回 `message` +- [ ] 不新增 `/release-notes`,文档、测试和验收标准中均不再要求该命令 + +--- + +## 12. 非目标 + +以下内容不纳入 Phase 3: + +- 不实现 workflow command / dynamic skill / mcp skill 新 Loader +- 不引入持久化 command usage tracking +- 不改变 `SkillTool` 的模型调用协议 +- 不改变 MCP prompt 的模型调用路径 +- 不重构 command 执行器或 mode adapter +- 不改变现有 user/project command 覆盖语义 + +--- + +## 13. 建议实施顺序 + +1. **补全数据结构与 badge/hint 展示**:先扩展 `Suggestion` 和 `SuggestionsDisplay`,风险低、反馈直观。 +2. **补充 built-in `argumentHint`**:让已有 ghost text 和 ACP `input.hint` 立即受益。 +3. **recently used 排序**:在 `useSlashCompletion` 引入 recent score,补测试。 +4. **alias 命中展示**:调整 FZF/prefix 匹配保留 `matchedAlias`。 +5. **Help 分 tab 重构**:按 Claude Code 风格提供 General / Commands / Custom Commands 等清晰面板,避免堆砌命令。 +6. **ACP 元数据增强**:扩展 `Session.sendAvailableCommandsUpdate()`,保持 `_meta` 兼容。 +7. **mid-input 高亮增强**:最后处理渲染层,避免与补全逻辑并行改动过大。 diff --git a/docs/design/slash-command/roadmap.md b/docs/design/slash-command/roadmap.md index 106778327c6..8db16cdd09c 100644 --- a/docs/design/slash-command/roadmap.md +++ b/docs/design/slash-command/roadmap.md @@ -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 形式提供,不在此列。 @@ -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` --- diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 99f34d2eb89..8350d39dc03 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -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: [ @@ -250,6 +251,23 @@ describe('Session', () => { description: 'Initialize project context', kind: 'built-in', 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, + }, + ], }, ]); @@ -269,6 +287,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, + }, }, ], }, @@ -298,6 +324,14 @@ describe('Session', () => { name: 'export', description: 'Export conversation history', input: { hint: '' }, + _meta: { + argumentHint: undefined, + source: undefined, + sourceLabel: undefined, + supportedModes: ['interactive'], + subcommands: ['md'], + modelInvocable: false, + }, }, ], }, @@ -333,6 +367,14 @@ describe('Session', () => { name: 'init', description: 'Initialize project context', input: null, + _meta: { + argumentHint: undefined, + source: undefined, + sourceLabel: undefined, + supportedModes: ['interactive'], + subcommands: [], + modelInvocable: false, + }, }, ], _meta: { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9b1c77e0491..60c445306e4 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -58,6 +58,8 @@ import { needsConfirmation, isPlanModeBlocked, } 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 { @@ -1270,6 +1272,14 @@ export class Session implements SessionContext { name: cmd.name, description: cmd.description, input: acceptsInput ? { hint: cmd.argumentHint ?? '' } : null, + _meta: { + argumentHint: cmd.argumentHint, + source: cmd.source, + sourceLabel: cmd.sourceLabel, + supportedModes: getEffectiveSupportedModes(cmd), + subcommands: getCommandSubcommandNames(cmd), + modelInvocable: cmd.modelInvocable === true, + }, }; }); diff --git a/packages/cli/src/services/commandMetadata.test.ts b/packages/cli/src/services/commandMetadata.test.ts new file mode 100644 index 00000000000..54d6780f841 --- /dev/null +++ b/packages/cli/src/services/commandMetadata.test.ts @@ -0,0 +1,271 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + getCommandSourceBadge, + getCommandSourceGroup, + formatSupportedModes, + getCommandDisplayName, + getCommandSubcommandNames, + formatCommandSourceLabel, +} from './commandMetadata.js'; +import type { SlashCommand } from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; + +function makeCmd(overrides: Partial = {}): SlashCommand { + return { + name: 'test', + description: 'Test command', + kind: CommandKind.BUILT_IN, + source: 'builtin-command', + ...overrides, + action: async () => {}, + } as unknown as SlashCommand; +} + +// --------------------------------------------------------------------------- +// getCommandSourceBadge +// --------------------------------------------------------------------------- +describe('getCommandSourceBadge', () => { + it('returns null for builtin-command', () => { + expect( + getCommandSourceBadge(makeCmd({ source: 'builtin-command' })), + ).toBeNull(); + }); + + it('returns [Skill] for bundled-skill', () => { + expect(getCommandSourceBadge(makeCmd({ source: 'bundled-skill' }))).toBe( + '[Skill]', + ); + }); + + it('returns [User] for skill-dir-command with User label', () => { + expect( + getCommandSourceBadge( + makeCmd({ source: 'skill-dir-command', sourceLabel: 'User' }), + ), + ).toBe('[User]'); + }); + + it('returns [Project] for skill-dir-command with Project label', () => { + expect( + getCommandSourceBadge( + makeCmd({ source: 'skill-dir-command', sourceLabel: 'Project' }), + ), + ).toBe('[Project]'); + }); + + it('returns [Custom] for skill-dir-command with other label', () => { + expect( + getCommandSourceBadge( + makeCmd({ source: 'skill-dir-command', sourceLabel: 'Other' }), + ), + ).toBe('[Custom]'); + }); + + it('returns [Extension] for plugin-command with Extension: prefix', () => { + expect( + getCommandSourceBadge( + makeCmd({ source: 'plugin-command', sourceLabel: 'Extension: my-ext' }), + ), + ).toBe('[Extension]'); + }); + + it('returns [Plugin] for plugin-command without Extension: prefix', () => { + expect( + getCommandSourceBadge( + makeCmd({ source: 'plugin-command', sourceLabel: 'My Plugin' }), + ), + ).toBe('[Plugin]'); + }); + + it('returns [MCP] for mcp-prompt', () => { + expect(getCommandSourceBadge(makeCmd({ source: 'mcp-prompt' }))).toBe( + '[MCP]', + ); + }); + + it('returns null for unknown source (default branch)', () => { + expect( + getCommandSourceBadge( + makeCmd({ source: 'unknown-source' as SlashCommand['source'] }), + ), + ).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// getCommandSourceGroup +// --------------------------------------------------------------------------- +describe('getCommandSourceGroup', () => { + it('returns built-in group for builtin-command', () => { + const g = getCommandSourceGroup(makeCmd({ source: 'builtin-command' })); + expect(g.key).toBe('built-in'); + expect(g.order).toBe(0); + }); + + it('returns bundled-skill group', () => { + const g = getCommandSourceGroup(makeCmd({ source: 'bundled-skill' })); + expect(g.key).toBe('bundled-skill'); + expect(g.order).toBe(1); + }); + + it('returns custom group for skill-dir-command', () => { + const g = getCommandSourceGroup(makeCmd({ source: 'skill-dir-command' })); + expect(g.key).toBe('custom'); + expect(g.order).toBe(2); + }); + + it('returns plugin group for plugin-command', () => { + const g = getCommandSourceGroup(makeCmd({ source: 'plugin-command' })); + expect(g.key).toBe('plugin'); + expect(g.order).toBe(3); + }); + + it('returns mcp group for mcp-prompt', () => { + const g = getCommandSourceGroup(makeCmd({ source: 'mcp-prompt' })); + expect(g.key).toBe('mcp'); + expect(g.order).toBe(4); + }); + + it('returns other group for unknown source', () => { + const g = getCommandSourceGroup( + makeCmd({ source: 'unknown-source' as SlashCommand['source'] }), + ); + expect(g.key).toBe('other'); + expect(g.order).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// formatSupportedModes +// --------------------------------------------------------------------------- +describe('formatSupportedModes', () => { + it('returns [all] when all three modes are present', () => { + const cmd = makeCmd({ + supportedModes: ['interactive', 'non_interactive', 'acp'], + }); + expect(formatSupportedModes(cmd)).toBe('[all]'); + }); + + it('returns [headless] when non_interactive and acp but not interactive', () => { + const cmd = makeCmd({ + supportedModes: ['non_interactive', 'acp'], + }); + expect(formatSupportedModes(cmd)).toBe('[headless]'); + }); + + it('returns [interactive] when only interactive mode', () => { + const cmd = makeCmd({ supportedModes: ['interactive'] }); + expect(formatSupportedModes(cmd)).toBe('[interactive]'); + }); + + it('formats individual modes with short tokens', () => { + const cmd = makeCmd({ supportedModes: ['interactive', 'acp'] }); + const result = formatSupportedModes(cmd); + expect(result).toContain('[i]'); + expect(result).toContain('[acp]'); + }); +}); + +// --------------------------------------------------------------------------- +// getCommandDisplayName +// --------------------------------------------------------------------------- +describe('getCommandDisplayName', () => { + it('returns plain name with prefix', () => { + const cmd = makeCmd({ name: 'review' }); + expect(getCommandDisplayName(cmd, { prefix: '/' })).toBe('/review'); + }); + + it('appends matched alias when provided', () => { + const cmd = makeCmd({ name: 'stats', altNames: ['usage'] }); + expect( + getCommandDisplayName(cmd, { prefix: '/', matchedAlias: 'usage' }), + ).toBe('/stats (alias: usage)'); + }); + + it('appends altNames when includeAliases not false', () => { + const cmd = makeCmd({ name: 'stats', altNames: ['usage', 'u'] }); + expect(getCommandDisplayName(cmd)).toBe('stats (usage, u)'); + }); + + it('omits altNames when includeAliases is false', () => { + const cmd = makeCmd({ name: 'stats', altNames: ['usage'] }); + expect(getCommandDisplayName(cmd, { includeAliases: false })).toBe('stats'); + }); + + it('returns plain name when no altNames', () => { + const cmd = makeCmd({ name: 'clear', altNames: undefined }); + expect(getCommandDisplayName(cmd)).toBe('clear'); + }); +}); + +// --------------------------------------------------------------------------- +// getCommandSubcommandNames +// --------------------------------------------------------------------------- +describe('getCommandSubcommandNames', () => { + it('returns empty array when no subCommands', () => { + expect(getCommandSubcommandNames(makeCmd())).toEqual([]); + }); + + it('returns names of non-hidden subCommands', () => { + const cmd = makeCmd({ + subCommands: [ + { name: 'add', hidden: false } as SlashCommand, + { name: 'remove', hidden: true } as SlashCommand, + { name: 'list', hidden: false } as SlashCommand, + ], + }); + expect(getCommandSubcommandNames(cmd)).toEqual(['add', 'list']); + }); +}); + +// --------------------------------------------------------------------------- +// formatCommandSourceLabel +// --------------------------------------------------------------------------- +describe('formatCommandSourceLabel', () => { + it('returns sourceLabel when present', () => { + const cmd = makeCmd({ source: 'builtin-command', sourceLabel: 'My Label' }); + expect(formatCommandSourceLabel(cmd)).toBe('My Label'); + }); + + it('returns Built-in for builtin-command without sourceLabel', () => { + const cmd = makeCmd({ source: 'builtin-command', sourceLabel: undefined }); + expect(formatCommandSourceLabel(cmd)).toBe('Built-in'); + }); + + it('returns Skill for bundled-skill', () => { + const cmd = makeCmd({ source: 'bundled-skill', sourceLabel: undefined }); + expect(formatCommandSourceLabel(cmd)).toBe('Skill'); + }); + + it('returns Custom for skill-dir-command', () => { + const cmd = makeCmd({ + source: 'skill-dir-command', + sourceLabel: undefined, + }); + expect(formatCommandSourceLabel(cmd)).toBe('Custom'); + }); + + it('returns Plugin for plugin-command', () => { + const cmd = makeCmd({ source: 'plugin-command', sourceLabel: undefined }); + expect(formatCommandSourceLabel(cmd)).toBe('Plugin'); + }); + + it('returns MCP for mcp-prompt', () => { + const cmd = makeCmd({ source: 'mcp-prompt', sourceLabel: undefined }); + expect(formatCommandSourceLabel(cmd)).toBe('MCP'); + }); + + it('returns Unknown when source is falsy', () => { + const cmd = makeCmd({ + source: undefined as unknown as SlashCommand['source'], + sourceLabel: undefined, + }); + expect(formatCommandSourceLabel(cmd)).toBe('Unknown'); + }); +}); diff --git a/packages/cli/src/services/commandMetadata.ts b/packages/cli/src/services/commandMetadata.ts new file mode 100644 index 00000000000..e97991e4d4a --- /dev/null +++ b/packages/cli/src/services/commandMetadata.ts @@ -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, +): 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, +): 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, + 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( + command: Pick, +): string { + if (command.sourceLabel) { + return command.sourceLabel; + } + + const fallbackLabels: Record = { + 'builtin-command': 'Built-in', + 'bundled-skill': 'Skill', + 'skill-dir-command': 'Custom', + 'plugin-command': 'Plugin', + 'mcp-prompt': 'MCP', + }; + + return command.source ? fallbackLabels[command.source] : 'Unknown'; +} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6c163be15f6..dc6046bf24d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -693,6 +693,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 { @@ -752,6 +759,7 @@ export const AppContainer = (props: AppContainerProps) => { openRewindSelector: () => openRewindSelectorRef.current(), handleResume, openDeleteDialog, + openHelpDialog, }), [ openAuthDialog, @@ -776,12 +784,14 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, handleResume, openDeleteDialog, + openHelpDialog, ], ); const { handleSlashCommand, slashCommands, + recentSlashCommands, pendingHistoryItems: pendingSlashCommandHistoryItems, btwItem, setBtwItem, @@ -1665,6 +1675,7 @@ export const AppContainer = (props: AppContainerProps) => { isApprovalModeDialogOpen || isResumeDialogOpen || isDeleteDialogOpen || + isHelpDialogOpen || isExtensionsManagerDialogOpen || isRewindSelectorOpen || bgTasksDialogOpen; @@ -1991,6 +2002,8 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen, showWelcomeBackDialog, handleWelcomeBackClose, + isHelpDialogOpen, + closeHelpDialog, isBackgroundTasksDialogOpen: bgTasksDialogOpen, closeBackgroundTasksDialog: closeBgTasksDialog, }); @@ -2362,7 +2375,10 @@ export const AppContainer = (props: AppContainerProps) => { isResumeDialogOpen, resumeMatchedSessions, isDeleteDialogOpen, + isHelpDialogOpen, + activeHelpTab, slashCommands, + recentSlashCommands, pendingSlashCommandHistoryItems, commandContext, shellConfirmationRequest, @@ -2477,7 +2493,10 @@ export const AppContainer = (props: AppContainerProps) => { isResumeDialogOpen, resumeMatchedSessions, isDeleteDialogOpen, + isHelpDialogOpen, + activeHelpTab, slashCommands, + recentSlashCommands, pendingSlashCommandHistoryItems, commandContext, shellConfirmationRequest, @@ -2632,6 +2651,10 @@ export const AppContainer = (props: AppContainerProps) => { openDeleteDialog, closeDeleteDialog, handleDelete, + // Help dialog + openHelpDialog, + closeHelpDialog, + setHelpTab, // Feedback dialog openFeedbackDialog, closeFeedbackDialog, @@ -2697,6 +2720,10 @@ export const AppContainer = (props: AppContainerProps) => { openDeleteDialog, closeDeleteDialog, handleDelete, + // Help dialog + openHelpDialog, + closeHelpDialog, + setHelpTab, // Feedback dialog openFeedbackDialog, closeFeedbackDialog, diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index e96695680a2..fd6bcff5d55 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -33,6 +33,7 @@ export const approvalModeCommand: SlashCommand = { get description() { return t('View or change the approval mode for tool usage'); }, + argumentHint: '', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: async ( diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index b86e8aa6ed9..8d6de3eedc0 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -324,6 +324,7 @@ export const exportCommand: SlashCommand = { get description() { return t('Export current session message history to a file'); }, + argumentHint: 'md|html|json|jsonl [path]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportHtmlAction, diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts index e956d1c53e2..964682b7203 100644 --- a/packages/cli/src/ui/commands/helpCommand.test.ts +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -8,7 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { helpCommand } from './helpCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { MessageType } from '../types.js'; import { CommandKind } from './types.js'; describe('helpCommand', () => { @@ -28,25 +27,33 @@ describe('helpCommand', () => { vi.clearAllMocks(); }); - it('should add a help message to the UI history', async () => { + it('should open the help dialog', async () => { if (!helpCommand.action) { throw new Error('Help command has no action'); } - await helpCommand.action(mockContext, ''); + await expect(helpCommand.action(mockContext, '')).resolves.toEqual({ + type: 'dialog', + dialog: 'help', + }); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should ignore arguments because help has no subcommands', async () => { + if (!helpCommand.action) { + throw new Error('Help command has no action'); + } - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HELP, - timestamp: expect.any(Date), - }), - expect.any(Number), - ); + await expect(helpCommand.action(mockContext, 'commands')).resolves.toEqual({ + type: 'dialog', + dialog: 'help', + }); }); it('should have the correct command properties', () => { expect(helpCommand.name).toBe('help'); expect(helpCommand.kind).toBe(CommandKind.BUILT_IN); + expect(helpCommand.argumentHint).toBeUndefined(); expect(helpCommand.description).toBe('for help on Qwen Code'); }); }); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 659158b7cb3..3e224964a9f 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -6,7 +6,6 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; -import { MessageType, type HistoryItemHelp } from '../types.js'; import { t } from '../../i18n/index.js'; export const helpCommand: SlashCommand = { @@ -17,12 +16,8 @@ export const helpCommand: SlashCommand = { get description() { return t('for help on Qwen Code'); }, - action: async (context) => { - const helpItem: Omit = { - type: MessageType.HELP, - timestamp: new Date(), - }; - - context.ui.addItem(helpItem, Date.now()); - }, + action: async () => ({ + type: 'dialog', + dialog: 'help', + }), }; diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index 7a5834a81ef..96f0fbb473d 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -182,6 +182,7 @@ export const languageCommand: SlashCommand = { get description() { return t('View or change the language setting'); }, + argumentHint: 'ui|output ', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 6e3c3d0d0f6..f149eaa13d3 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -13,6 +13,7 @@ export const mcpCommand: SlashCommand = { get description() { return t('Open MCP management dialog'); }, + argumentHint: 'desc|nodesc|schema|auth|noauth', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: async (): Promise => ({ diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 65c27a6018d..8370884ac6b 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -13,6 +13,7 @@ export const memoryCommand: SlashCommand = { get description() { return t('Open the memory manager.'); }, + argumentHint: 'show|add|refresh', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: async () => ({ diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index c9e17afe064..117bd566460 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -34,6 +34,7 @@ export const modelCommand: SlashCommand = { 'Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).', ); }, + argumentHint: '[--fast] []', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, completion: async (context, partialArg) => { diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 72a49ae3307..e87b5275f1b 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -22,6 +22,7 @@ export const statsCommand: SlashCommand = { get description() { return t('check session stats. Usage: /stats [model|tools]'); }, + argumentHint: '[model|tools]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: (context: CommandContext): MessageActionReturn | void => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index c5bda6bea92..b1399eaf35d 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -118,6 +118,7 @@ export const Composer = () => { config={config} slashCommands={uiState.slashCommands} commandContext={uiState.commandContext} + recentSlashCommands={uiState.recentSlashCommands} shellModeActive={uiState.shellModeActive} setShellModeActive={uiActions.setShellModeActive} approvalMode={showAutoAcceptIndicator} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 4c1eec5390f..22944dc0237 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -48,6 +48,7 @@ import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; import { RewindSelector } from './RewindSelector.js'; import { MemoryDialog } from './MemoryDialog.js'; +import { Help } from './Help.js'; import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js'; import { useBackgroundTaskViewState } from '../contexts/BackgroundTaskViewContext.js'; import { t } from '../../i18n/index.js'; @@ -256,6 +257,18 @@ export const DialogManager = ({ if (uiState.isMemoryDialogOpen) { return ; } + if (uiState.isHelpDialogOpen) { + return ( + + ); + } if (uiState.isApprovalModeDialogOpen) { const currentMode = config.getApprovalMode(); return ( diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index 23b379eaf77..f97331baa7f 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -6,29 +6,69 @@ /** @vitest-environment jsdom */ +import React, { act } from 'react'; import { render } from 'ink-testing-library'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { Help } from './Help.js'; import type { SlashCommand } from '../commands/types.js'; import { CommandKind } from '../commands/types.js'; +import type { HelpTab } from '../contexts/UIActionsContext.js'; const mockCommands: readonly SlashCommand[] = [ { name: 'test', description: 'A test command', kind: CommandKind.BUILT_IN, + source: 'builtin-command', + sourceLabel: 'Built-in', + supportedModes: ['interactive'], + argumentHint: '[value]', altNames: ['alias-one', 'alias-two'], }, + { + name: 'review', + description: 'Review changed code', + kind: CommandKind.SKILL, + source: 'bundled-skill', + sourceLabel: 'Skill', + supportedModes: ['interactive', 'non_interactive', 'acp'], + argumentHint: '[pr-number]', + modelInvocable: true, + }, + { + name: 'custom', + description: 'A custom command', + kind: CommandKind.FILE, + source: 'skill-dir-command', + sourceLabel: 'Project', + }, + { + name: 'plugin-cmd', + description: 'A plugin command', + kind: CommandKind.FILE, + source: 'plugin-command', + sourceLabel: 'Plugin: demo', + }, + { + name: 'mcp-prompt', + description: 'An MCP prompt', + kind: CommandKind.MCP_PROMPT, + source: 'mcp-prompt', + sourceLabel: 'MCP: demo', + }, { name: 'hidden', description: 'A hidden command', hidden: true, kind: CommandKind.BUILT_IN, + source: 'builtin-command', }, { name: 'parent', description: 'A parent command', kind: CommandKind.BUILT_IN, + source: 'builtin-command', + sourceLabel: 'Built-in', subCommands: [ { name: 'visible-child', @@ -45,39 +85,200 @@ const mockCommands: readonly SlashCommand[] = [ }, ]; -describe('Help Component', () => { - it('should render platform-specific keyboard shortcuts', () => { - const { lastFrame } = render(); - const output = lastFrame(); +const keypressSubscribers = new Set<(key: KeypressTestKey) => void>(); +type KeypressTestKey = { + name: string; + shift?: boolean; +}; + +vi.mock('../contexts/KeypressContext.js', () => ({ + useKeypressContext: () => ({ + subscribe: (handler: (key: KeypressTestKey) => void) => { + keypressSubscribers.add(handler); + }, + unsubscribe: (handler: (key: KeypressTestKey) => void) => { + keypressSubscribers.delete(handler); + }, + }), +})); - if (process.platform === 'win32') { - expect(output).toContain('Tab'); - expect(output).not.toContain('Shift+Tab'); - } else { - expect(output).toContain('Shift+Tab'); +function sendKey(key: KeypressTestKey) { + act(() => { + for (const handler of keypressSubscribers) { + handler(key); } }); +} + +const InteractiveHelpHarness = ({ + onClose, + commands = mockCommands, + initialTab = 'general', +}: { + onClose: () => void; + commands?: readonly SlashCommand[]; + initialTab?: HelpTab; +}) => { + const [tab, setTab] = React.useState(initialTab); + return ( + + ); +}; - it('should not render hidden commands', () => { - const { lastFrame } = render(); +describe('Help Component', () => { + it('renders Claude Code style tabs and the general page by default', () => { + const { lastFrame } = render(); const output = lastFrame(); - expect(output).toContain('/test'); - expect(output).not.toContain('/hidden'); + expect(output).toContain('Qwen Code'); + expect(output).toContain('general'); + expect(output).toContain('commands'); + expect(output).toContain('custom-commands'); + expect(output).toContain('Shortcuts'); + expect(output).toContain('Esc to cancel'); + expect(output).not.toContain('/help commands'); }); - it('should not render hidden subcommands', () => { - const { lastFrame } = render(); + it('renders built-in commands in the commands tab without custom command clutter', () => { + const { lastFrame } = render( + , + ); const output = lastFrame(); + expect(output).toContain('Built-in Commands'); + expect(output).toContain('/test [value]'); + expect(output).toContain('[interactive]'); + expect(output).toContain('/parent'); expect(output).toContain('visible-child'); expect(output).not.toContain('hidden-child'); + expect(output).not.toContain('/hidden'); + expect(output).not.toContain('/custom'); }); - it('should render alt names for commands when available', () => { - const { lastFrame } = render(); + it('renders custom, skill, plugin, and MCP commands in the custom tab', () => { + const { lastFrame } = render( + , + ); const output = lastFrame(); - expect(output).toContain('/test (alias-one, alias-two)'); + expect(output).toContain('Bundled Skills'); + expect(output).toContain('Custom Commands'); + expect(output).toContain('Plugin Commands'); + expect(output).toContain('MCP Commands'); + expect(output).toContain('/review [pr-number]'); + expect(output).toContain('[Skill]'); + expect(output).toContain('[all]'); + expect(output).toContain('[model]'); + expect(output).toContain('/custom'); + expect(output).toContain('[Project]'); + expect(output).toContain('/plugin-cmd'); + expect(output).toContain('[Plugin]'); + expect(output).toContain('/mcp-prompt'); + expect(output).toContain('[MCP]'); + expect(output).not.toContain('/test'); + }); + + it('switches tabs with Tab and Shift+Tab when interactive', () => { + const onClose = vi.fn(); + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Shortcuts'); + + sendKey({ name: 'tab' }); + expect(lastFrame()).toContain('Built-in Commands'); + + sendKey({ name: 'tab' }); + expect(lastFrame()).toContain('Custom Commands'); + + sendKey({ name: 'tab', shift: true }); + expect(lastFrame()).toContain('Built-in Commands'); + }); + + it('scrolls long command lists with the up and down keys', () => { + const manyCommands: SlashCommand[] = Array.from( + { length: 12 }, + (_, index): SlashCommand => ({ + name: `cmd-${String(index).padStart(2, '0')}`, + description: `Command ${index} description`, + kind: CommandKind.BUILT_IN, + source: 'builtin-command', + sourceLabel: 'Built-in', + }), + ); + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('/cmd-00'); + expect(lastFrame()).not.toContain('/cmd-11'); + expect(lastFrame()).toContain('Use ↑/↓ to scroll'); + + sendKey({ name: 'down' }); + sendKey({ name: 'down' }); + expect(lastFrame()).not.toContain('/cmd-00'); + + sendKey({ name: 'pagedown' }); + expect(lastFrame()).toContain('/cmd-11'); + + sendKey({ name: 'pageup' }); + expect(lastFrame()).toContain('/cmd-00'); + }); + + it('resets scroll position when switching command tabs', () => { + const mixedCommands: SlashCommand[] = [ + ...Array.from( + { length: 12 }, + (_, index): SlashCommand => ({ + name: `builtin-${String(index).padStart(2, '0')}`, + description: `Built-in ${index} description`, + kind: CommandKind.BUILT_IN, + source: 'builtin-command', + sourceLabel: 'Built-in', + }), + ), + ...Array.from( + { length: 12 }, + (_, index): SlashCommand => ({ + name: `skill-${String(index).padStart(2, '0')}`, + description: `Skill ${index} description`, + kind: CommandKind.SKILL, + source: 'bundled-skill', + sourceLabel: 'Skill', + }), + ), + ]; + const { lastFrame } = render( + , + ); + + sendKey({ name: 'pagedown' }); + expect(lastFrame()).not.toContain('/builtin-00'); + + sendKey({ name: 'tab' }); + expect(lastFrame()).toContain('/skill-00'); + }); + + it('closes with Escape when interactive', () => { + const onClose = vi.fn(); + render(); + + sendKey({ name: 'escape' }); + + expect(onClose).toHaveBeenCalledOnce(); }); }); diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index 48866b598ab..b7fe57c0699 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -5,190 +5,503 @@ */ import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { type SlashCommand, CommandKind } from '../commands/types.js'; +import { type SlashCommand } from '../commands/types.js'; import { t } from '../../i18n/index.js'; +import { + formatSupportedModes, + getCommandDisplayName, + getCommandSourceBadge, + getCommandSourceGroup, + getCommandSubcommandNames, +} from '../../services/commandMetadata.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import type { HelpTab } from '../contexts/UIActionsContext.js'; -interface Help { +export type { HelpTab }; + +interface HelpProps { commands: readonly SlashCommand[]; width?: number; + activeTab?: HelpTab; + onTabChange?: (tab: HelpTab) => void; + onClose?: () => void; + isInteractive?: boolean; } -export const Help: React.FC = ({ commands, width }) => ( - - {/* Basics */} - - {t('Basics:')} - - - - {t('Add context')} - - :{' '} - {t( - 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.', - { - symbol: t('@'), - example: t('@src/myFile.ts'), - }, - )} - - - - {t('Shell mode')} - - :{' '} - {t( - 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).', - { - symbol: t('!'), - example1: t('!npm run start'), - example2: t('start server'), - }, - )} - +type CommandGroup = { + key: string; + title: string; + order: number; + commands: SlashCommand[]; +}; - +const DEFAULT_WIDTH = 100; +const KEY_COL_WIDTH = 20; +const COMMAND_LIST_VISIBLE_LINES = 18; +const TAB_DEFS: Array<{ tab: HelpTab; labelKey: string }> = [ + { tab: 'general', labelKey: 'general' }, + { tab: 'commands', labelKey: 'commands' }, + { tab: 'custom-commands', labelKey: 'custom-commands' }, +]; +const DOCS_URL = 'https://qwenlm.github.io/qwen-code-docs/'; - {/* Commands */} - - {t('Commands:')} - - {commands - .filter((command) => command.description && !command.hidden) - .map((command: SlashCommand) => ( - - - - {' '} - {formatCommandLabel(command, '/')} - - {command.kind === CommandKind.MCP_PROMPT && ( - [MCP] +export const Help: React.FC = ({ + commands, + width = DEFAULT_WIDTH, + activeTab = 'general', + onTabChange, + onClose, + isInteractive = false, +}) => { + const safeWidth = Math.max(72, width); + const bodyWidth = safeWidth - 6; + const handleTabChange = useCallback( + (direction: 1 | -1) => { + const currentIndex = TAB_DEFS.findIndex((tab) => tab.tab === activeTab); + const nextIndex = + (currentIndex + direction + TAB_DEFS.length) % TAB_DEFS.length; + onTabChange?.(TAB_DEFS[nextIndex].tab); + }, + [activeTab, onTabChange], + ); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onClose?.(); + return; + } + if (key.name === 'tab') { + handleTabChange(key.shift ? -1 : 1); + } + }, + { isActive: isInteractive }, + ); + + return ( + + + + + + {activeTab === 'general' && } + {activeTab === 'commands' && ( + )} - {command.description && ' - ' + command.description} - - {command.subCommands && - command.subCommands - .filter((subCommand) => !subCommand.hidden) - .map((subCommand) => ( - - - {' '} - {formatCommandLabel(subCommand)} - - {subCommand.description && ' - ' + subCommand.description} - - ))} + {activeTab === 'custom-commands' && ( + + )} + + + + {t('For more help:')} {DOCS_URL} + + + + + {t('Tab/Shift+Tab to switch tabs · Esc to cancel')} + + - ))} - - - {' '} - !{' '} - - - {t('shell command')} - - - [MCP] -{' '} - {t('Model Context Protocol command (from external servers)')} + + + ); +}; + +const HelpTabs: React.FC<{ activeTab: HelpTab }> = ({ activeTab }) => ( + + + Qwen Code + + {TAB_DEFS.map(({ tab, labelKey }) => { + const active = tab === activeTab; + return ( + + + {` ${t(labelKey)} `} + + + ); + })} + +); - +const GeneralHelp: React.FC<{ width: number }> = ({ width }) => { + const shortcuts: Array<[string, string]> = [ + ['@', t('Add files or folders as context')], + ['!', t('Run shell commands')], + ['/', t('Open command menu')], + ['Tab', t('Accept ghost text or completion')], + ['Esc Esc', t('Clear input or cancel operation')], + ['Ctrl+L', t('Clear the screen')], + [ + process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J', + t('Insert a newline'), + ], + [ + process.platform === 'win32' ? 'Tab' : 'Shift+Tab', + t('Cycle approval modes'), + ], + ['Alt+←/→', t('Jump through words')], + ['↑/↓', t('Cycle prompt history')], + ]; + const left = shortcuts.slice(0, Math.ceil(shortcuts.length / 2)); + const right = shortcuts.slice(Math.ceil(shortcuts.length / 2)); + const colWidth = Math.floor((width - 2) / 2); - {/* Shortcuts */} - - {t('Keyboard Shortcuts:')} - - - - Alt+Left/Right - {' '} - - {t('Jump through words in the input')} - - - - Ctrl+C - {' '} - - {t('Close dialogs, cancel requests, or quit application')} - - - - {process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'} - {' '} - -{' '} - {process.platform === 'linux' - ? t('New line (Alt+Enter works for certain linux distros)') - : t('New line')} - - - - Ctrl+L - {' '} - - {t('Clear the screen')} - - - - Ctrl+O - {' '} - - {t('to toggle compact mode')} - - - - {process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'} - {' '} - - {t('Open input in external editor')} - - - - Enter - {' '} - - {t('Send message')} - - - - Esc - {' '} - - {t('Cancel operation / Clear input (double press)')} - - - - {process.platform === 'win32' ? 'Tab' : 'Shift+Tab'} - {' '} - - {t('Cycle approval modes')} - - - - Up/Down - {' '} - - {t('Cycle through your prompt history')} - - - - {t('For a full list of shortcuts, see {{docPath}}', { - docPath: t('docs/keyboard-shortcuts.md'), - })} + return ( + + + + {t( + 'Qwen Code understands your codebase, makes edits with your permission, and executes commands right from your terminal.', + )} + + + + {t('Shortcuts')} + + + + {left.map(([key, desc]) => ( + + ))} + + + {right.map(([key, desc]) => ( + + ))} + + + + ); +}; + +const ShortcutRow: React.FC<{ + shortcutKey: string; + desc: string; + width: number; +}> = ({ shortcutKey, desc, width }) => ( + + + {shortcutKey} + + + {truncateText(desc, width - KEY_COL_WIDTH - 1)} ); -/** - * Builds a display label for a slash command, including any alternate names. - */ -function formatCommandLabel(command: SlashCommand, prefix = ''): string { - const altNames = command.altNames?.filter(Boolean); - const baseLabel = `${prefix}${command.name}`; +const CommandsHelp: React.FC<{ + commands: readonly SlashCommand[]; + width: number; + customOnly: boolean; + isInteractive: boolean; +}> = ({ commands, width, customOnly, isInteractive }) => { + const groups = useMemo( + () => groupCommands(commands, customOnly), + [commands, customOnly], + ); + const lines = useMemo( + () => renderCommandLines(groups, width), + [groups, width], + ); + const maxScroll = Math.max(0, lines.length - COMMAND_LIST_VISIBLE_LINES); + const [scrollOffset, setScrollOffset] = useState(0); + + useEffect(() => { + setScrollOffset(0); + }, [customOnly, commands]); + + useEffect(() => { + setScrollOffset((offset) => Math.min(offset, maxScroll)); + }, [maxScroll]); + + useKeypress( + (key) => { + if (key.name === 'up') { + setScrollOffset((offset) => Math.max(0, offset - 1)); + } else if (key.name === 'down') { + setScrollOffset((offset) => Math.min(maxScroll, offset + 1)); + } else if (key.name === 'pageup') { + setScrollOffset((offset) => + Math.max(0, offset - COMMAND_LIST_VISIBLE_LINES), + ); + } else if (key.name === 'pagedown') { + setScrollOffset((offset) => + Math.min(maxScroll, offset + COMMAND_LIST_VISIBLE_LINES), + ); + } + }, + { isActive: isInteractive }, + ); + + if (groups.length === 0) { + return ( + + {customOnly + ? t('No custom commands are currently available.') + : t('No commands are currently available.')} + + ); + } + + const visibleLines = lines.slice( + scrollOffset, + scrollOffset + COMMAND_LIST_VISIBLE_LINES, + ); + + return ( + + + + {customOnly + ? t('Browse custom, skill, plugin, and MCP commands:') + : t('Browse built-in commands:')} + + + + {visibleLines.map((line, index) => { + const stableKey = + line.type === 'blank' + ? `blank:${index}` + : `${line.type}:${line.text}:${index}`; + return ; + })} + + {maxScroll > 0 && + (() => { + const totalCommands = lines.filter( + (l) => l.type === 'signature', + ).length; + const visibleSignatures = visibleLines.filter( + (l): l is Extract => + l.type === 'signature', + ); + const firstCmd = + visibleSignatures.length > 0 + ? visibleSignatures[0].commandIndex + 1 + : 0; + const lastCmd = + visibleSignatures.length > 0 + ? visibleSignatures[visibleSignatures.length - 1].commandIndex + 1 + : 0; + const range = + firstCmd === lastCmd ? `${firstCmd}` : `${firstCmd}-${lastCmd}`; + return ( + + + {t('Use ↑/↓ to scroll')} {`(${range}/${totalCommands})`} + + + ); + })()} + + ); +}; + +type CommandLine = + | { type: 'group'; text: string; count: number } + | { type: 'signature'; text: string; meta: string; commandIndex: number } + | { type: 'description'; text: string } + | { type: 'subcommands'; text: string } + | { type: 'blank' }; + +const CommandLine: React.FC<{ line: CommandLine }> = ({ line }) => { + switch (line.type) { + case 'group': + return ( + + {line.text}{' '} + {`(${line.count})`} + + ); + case 'signature': + return ( + + {line.text} + {line.meta && {line.meta}} + + ); + case 'description': + return ( + + + {line.text} + + + ); + case 'subcommands': + return ( + + + {line.text} + + + ); + case 'blank': + return ; + default: + return null; + } +}; + +function renderCommandLines( + groups: CommandGroup[], + width: number, +): CommandLine[] { + const lines: CommandLine[] = []; + let commandIndex = 0; + groups.forEach((group, groupIndex) => { + lines.push({ + type: 'group', + text: group.title, + count: group.commands.length, + }); + group.commands.forEach((cmd) => { + const sigLine = getCommandSignatureLine(cmd, width); + lines.push({ ...sigLine, commandIndex: commandIndex++ } as CommandLine); + const descriptionLine = getCommandDescriptionLine(cmd, width); + if (descriptionLine) { + lines.push(descriptionLine); + } + const subcommandsLine = getCommandSubcommandsLine(cmd, width); + if (subcommandsLine) { + lines.push(subcommandsLine); + } + }); + if (groupIndex < groups.length - 1) { + lines.push({ type: 'blank' }); + } + }); + return lines; +} + +function getCommandSignatureLine( + command: SlashCommand, + width: number, +): CommandLine { + const badge = getCommandSourceBadge(command); + const name = getCommandDisplayName(command, { + prefix: '/', + includeAliases: false, + }); + const signature = [name, command.argumentHint].filter(Boolean).join(' '); + const meta = [ + badge, + formatSupportedModes(command), + command.modelInvocable ? '[model]' : undefined, + ] + .filter(Boolean) + .join(' '); + + return { + type: 'signature', + text: truncateText(signature, Math.floor(width * 0.42)), + meta, + commandIndex: -1, // assigned by renderCommandLines + }; +} - if (!altNames || altNames.length === 0) { - return baseLabel; +function getCommandDescriptionLine( + command: SlashCommand, + width: number, +): CommandLine | null { + if (!command.description) { + return null; } + return { + type: 'description', + text: truncateText(command.description, Math.max(20, width - 4)), + }; +} + +function getCommandSubcommandsLine( + command: SlashCommand, + width: number, +): CommandLine | null { + const subcommands = getCommandSubcommandNames(command); + if (subcommands.length === 0) { + return null; + } + const descWidth = Math.max(20, width - 4); + return { + type: 'subcommands', + text: `${t('subcommands:')} ${truncateText(subcommands.join(', '), descWidth - 13)}`, + }; +} + +function groupCommands( + commands: readonly SlashCommand[], + customOnly: boolean, +): CommandGroup[] { + const groups = new Map(); + + commands + .filter((cmd) => cmd.description && !cmd.hidden) + .forEach((cmd) => { + const group = getCommandSourceGroup(cmd); + if (customOnly ? group.key === 'built-in' : group.key !== 'built-in') { + return; + } + const existing = groups.get(group.key); + if (existing) { + existing.commands.push(cmd); + } else { + groups.set(group.key, { + key: group.key, + title: group.title, + order: group.order, + commands: [cmd], + }); + } + }); + + return Array.from(groups.values()) + .sort((a, b) => a.order - b.order) + .map((group) => ({ + ...group, + commands: group.commands.sort((a, b) => a.name.localeCompare(b.name)), + })); +} - return `${baseLabel} (${altNames.join(', ')})`; +function truncateText(text: string, maxLength: number): string { + if (maxLength <= 1 || text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 1)}…`; } diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index ffbb8d34c5f..6d6f907d4b4 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1625,6 +1625,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1653,6 +1654,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1681,6 +1683,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1709,6 +1712,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1737,6 +1741,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1766,6 +1771,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1794,6 +1800,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1823,6 +1830,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1852,6 +1860,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1881,6 +1890,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1910,6 +1920,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1941,6 +1952,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -1970,6 +1982,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); @@ -2001,6 +2014,7 @@ describe('InputPrompt', () => { expect.any(Object), // active parameter: completion enabled when not just navigated history true, + undefined, ); unmount(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 684ae69d8a6..038d6493dd0 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Box, Text } from 'ink'; import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; +import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; import { theme } from '../semantic-colors.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; import type { TextBuffer } from './shared/text-buffer.js'; @@ -75,6 +76,7 @@ export interface InputPromptProps { config: Config; slashCommands: readonly SlashCommand[]; commandContext: CommandContext; + recentSlashCommands?: RecentSlashCommands; placeholder?: string; focus?: boolean; inputWidth: number; @@ -109,6 +111,7 @@ export const InputPrompt: React.FC = ({ config, slashCommands, commandContext, + recentSlashCommands, placeholder, focus = true, suggestionsWidth, @@ -204,6 +207,7 @@ export const InputPrompt: React.FC = ({ config, // Suppress completion when history navigation just occurred !justNavigatedHistory, + recentSlashCommands, ); // Ref so renderLineWithHighlighting (stable useCallback) can access fresh ghost text @@ -1215,7 +1219,11 @@ export const InputPrompt: React.FC = ({ const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex]; const [logicalLineIdx, logicalStartCol] = mapEntry; const logicalLine = buf.lines[logicalLineIdx] || ''; - const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx); + const tokens = parseInputForHighlighting( + logicalLine, + logicalLineIdx, + slashCommands, + ); const visualStart = logicalStartCol; const visualEnd = logicalStartCol + cpLen(lineText); @@ -1307,7 +1315,7 @@ export const InputPrompt: React.FC = ({ return {renderedLine}; }, - [], + [slashCommands], ); const getActiveCompletion = () => { diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 8b4ba93980c..f95e1d54eea 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -7,14 +7,26 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js'; -import { CommandKind } from '../commands/types.js'; +import type { + CommandKind, + CommandSource, + ExecutionMode, +} from '../commands/types.js'; import { Colors } from '../colors.js'; export interface Suggestion { label: string; value: string; description?: string; matchedIndex?: number; + /** @deprecated Use source/sourceBadge instead. */ commandKind?: CommandKind; + source?: CommandSource; + sourceLabel?: string; + sourceBadge?: string; + argumentHint?: string; + matchedAlias?: string; + supportedModes?: ExecutionMode[]; + modelInvocable?: boolean; } interface SuggestionsDisplayProps { suggestions: Suggestion[]; @@ -61,7 +73,7 @@ export function SuggestionsDisplay({ const visibleSuggestions = suggestions.slice(startIndex, endIndex); const getFullLabel = (s: Suggestion) => - s.label + (s.commandKind === CommandKind.MCP_PROMPT ? ' [MCP]' : ''); + [s.label, s.argumentHint, s.sourceBadge].filter(Boolean).join(' '); const maxLabelLength = Math.max( ...suggestions.map((s) => getFullLabel(s).length), @@ -99,8 +111,14 @@ export function SuggestionsDisplay({ > {labelElement} - {suggestion.commandKind === CommandKind.MCP_PROMPT && ( - [MCP] + {suggestion.argumentHint && ( + + {' '} + {suggestion.argumentHint} + + )} + {suggestion.sourceBadge && ( + {suggestion.sourceBadge} )} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f8c17056be6..4c2a19442a3 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -15,6 +15,8 @@ import type { AuthController } from '../auth/useAuth.js'; import type { HistoryItem } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; +export type HelpTab = 'general' | 'commands' | 'custom-commands'; + export interface UIActions { openThemeDialog: () => void; openEditorDialog: () => void; @@ -81,6 +83,10 @@ export interface UIActions { openDeleteDialog: () => void; closeDeleteDialog: () => void; handleDelete: (sessionId: string) => void; + // Help dialog + openHelpDialog: () => void; + closeHelpDialog: () => void; + setHelpTab: (tab: HelpTab) => void; // Feedback dialog openFeedbackDialog: () => void; closeFeedbackDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 434c25333f4..07eb1a93657 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -20,6 +20,7 @@ import type { import type { TodoItem } from '../components/TodoDisplay.js'; import type { AuthUiState } from '../auth/useAuth.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; +import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { IdeContext, @@ -33,6 +34,7 @@ import type { ExtensionUpdateState } from '../state/extensions.js'; import type { UpdateObject } from '../utils/updateCheck.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import { type HelpTab } from './UIActionsContext.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { type ProviderUpdateRequest } from '../hooks/useProviderUpdates.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; @@ -60,7 +62,10 @@ export interface UIState { isResumeDialogOpen: boolean; resumeMatchedSessions: SessionListItem[] | undefined; isDeleteDialogOpen: boolean; + isHelpDialogOpen: boolean; + activeHelpTab: HelpTab; slashCommands: readonly SlashCommand[]; + recentSlashCommands: RecentSlashCommands; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; shellConfirmationRequest: ShellConfirmationRequest | null; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 55586ed0c45..541df9f4023 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -39,6 +39,7 @@ import type { import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { type CommandContext, type SlashCommand } from '../commands/types.js'; +import type { RecentSlashCommand } from './useSlashCompletion.js'; import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; @@ -106,6 +107,7 @@ export interface SlashCommandProcessorActions { openMcpDialog: () => void; openHooksDialog: () => void; openRewindSelector: () => void; + openHelpDialog: () => void; } /** @@ -131,6 +133,9 @@ export const useSlashCommandProcessor = ( ) => { const { stats: sessionStats, startNewSession } = useSessionStats(); const [commands, setCommands] = useState([]); + const [recentCommands, setRecentCommands] = useState< + ReadonlyMap + >(new Map()); const [reloadTrigger, setReloadTrigger] = useState(0); const reloadCommands = useCallback(() => { @@ -495,6 +500,18 @@ export const useSlashCommandProcessor = ( try { if (commandToExecute) { + if (!commandToExecute.hidden) { + setRecentCommands((previous) => { + const next = new Map(previous); + const existing = next.get(commandToExecute.name); + next.set(commandToExecute.name, { + name: commandToExecute.name, + usedAt: Date.now(), + count: (existing?.count ?? 0) + 1, + }); + return next; + }); + } if (commandToExecute.action) { const fullCommandContext: CommandContext = { ...commandContext, @@ -643,6 +660,7 @@ export const useSlashCommandProcessor = ( actions.openRewindSelector(); return { type: 'handled' }; case 'help': + actions.openHelpDialog(); return { type: 'handled' }; default: { const unhandled: never = result.dialog; @@ -858,6 +876,7 @@ export const useSlashCommandProcessor = ( return { handleSlashCommand, slashCommands: commands, + recentSlashCommands: recentCommands, pendingHistoryItems, btwItem, setBtwItem, diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index 25f2f4924f6..a918348ea54 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -618,6 +618,75 @@ describe('useCommandCompletion', () => { }); }); + it('shows mid-input ghost text for model-invocable commands', () => { + const slashCommands: SlashCommand[] = [ + { + name: 'review', + description: 'Review changed code', + kind: CommandKind.SKILL, + modelInvocable: true, + }, + { + name: 'rewind', + description: 'Rewind conversation', + kind: CommandKind.BUILT_IN, + modelInvocable: false, + }, + ]; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('please /rev'); + const completion = useCommandCompletion( + textBuffer, + testRootDir, + slashCommands, + mockCommandContext, + false, + mockConfig, + ); + return completion; + }); + + expect(result.current.midInputGhostText).toEqual({ + text: 'iew', + insertPosition: 'please /rev'.length, + acceptText: 'iew', + showCursorBeforeText: false, + }); + }); + + it('shows argumentHint for a complete mid-input model-invocable command', () => { + const slashCommands: SlashCommand[] = [ + { + name: 'review', + description: 'Review changed code', + kind: CommandKind.SKILL, + modelInvocable: true, + argumentHint: '[pr-number]', + }, + ]; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('please /review'); + const completion = useCommandCompletion( + textBuffer, + testRootDir, + slashCommands, + mockCommandContext, + false, + mockConfig, + ); + return completion; + }); + + expect(result.current.midInputGhostText).toEqual({ + text: '[pr-number]', + insertPosition: 'please /review'.length, + acceptText: undefined, + showCursorBeforeText: true, + }); + }); + it('does not show argumentHint after arguments have started', () => { const slashCommands: SlashCommand[] = [ { @@ -643,5 +712,38 @@ describe('useCommandCompletion', () => { expect(result.current.midInputGhostText).toBeNull(); }); + + it('returns null midInputGhostText when only non-modelInvocable commands match', () => { + const slashCommands: SlashCommand[] = [ + { + name: 'clear', + description: 'Clear conversation', + kind: CommandKind.BUILT_IN, + modelInvocable: false, + }, + { + name: 'compress', + description: 'Compress context', + kind: CommandKind.BUILT_IN, + modelInvocable: false, + }, + ]; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('please /cl'); + const completion = useCommandCompletion( + textBuffer, + testRootDir, + slashCommands, + mockCommandContext, + false, + mockConfig, + ); + return completion; + }); + + // '/cl' matches 'clear' but it is not modelInvocable, so no ghost text + expect(result.current.midInputGhostText).toBeNull(); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 0b1cfd72feb..eb199785412 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -16,7 +16,10 @@ import { } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; -import { useSlashCompletion } from './useSlashCompletion.js'; +import { + type RecentSlashCommands, + useSlashCompletion, +} from './useSlashCompletion.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { useCompletion } from './useCompletion.js'; import { parseSlashCommand } from '../../utils/commands.js'; @@ -58,6 +61,7 @@ export function useCommandCompletion( config?: Config, // When false, suppresses showing suggestions (e.g., after history navigation) active: boolean = true, + recentCommands?: RecentSlashCommands, ): UseCommandCompletionReturn { const { suggestions, @@ -157,6 +161,7 @@ export function useCommandCompletion( query, slashCommands, commandContext, + recentCommands, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch, @@ -259,12 +264,15 @@ export function useCommandCompletion( const match = getBestSlashCommandMatch( midCmd.partialCommand, slashCommands, + recentCommands, ); if (!match) return null; + const isCompleteCommand = match.suffix.length === 0; return { - text: match.suffix, + text: isCompleteCommand ? (match.argumentHint ?? '') : match.suffix, insertPosition: cursorOffset, - acceptText: match.suffix, + acceptText: isCompleteCommand ? undefined : match.suffix, + showCursorBeforeText: isCompleteCommand, }; } @@ -297,6 +305,7 @@ export function useCommandCompletion( slashCommands, active, reverseSearchActive, + recentCommands, ]); return { diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index ba12002947e..11a165e93f0 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -58,6 +58,10 @@ export interface DialogCloseOptions { showWelcomeBackDialog: boolean; handleWelcomeBackClose: () => void; + // Help dialog + isHelpDialogOpen?: boolean; + closeHelpDialog?: () => void; + // Background tasks dialog isBackgroundTasksDialogOpen: boolean; closeBackgroundTasksDialog: () => void; @@ -96,6 +100,11 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isHelpDialogOpen && options.closeHelpDialog) { + options.closeHelpDialog(); + return true; + } + if (options.isMemoryDialogOpen) { options.closeMemoryDialog(); return true; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index dbe7a601c20..bb8e6665828 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -145,6 +145,10 @@ function useTestHarnessForSlashCompletion( query: string | null, slashCommands: readonly SlashCommand[], commandContext: CommandContext, + recentCommands?: ReadonlyMap< + string, + { name: string; usedAt: number; count: number } + >, ) { const [suggestions, setSuggestions] = useState([]); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); @@ -155,6 +159,7 @@ function useTestHarnessForSlashCompletion( query, slashCommands, commandContext, + recentCommands, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch, @@ -236,12 +241,40 @@ describe('useSlashCompletion', () => { await waitFor(() => { expect(result.current.suggestions).toEqual([ - { + expect.objectContaining({ label: 'memory', value: 'memory', description: 'Manage memory', commandKind: CommandKind.BUILT_IN, - }, + }), + ]); + }); + }); + + it('should not include alias noise for primary-name matches', async () => { + const slashCommands = [ + createTestCommand({ + name: 'help', + altNames: ['?'], + description: 'for help on Qwen Code', + }), + ]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/he', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + expect.objectContaining({ + label: 'help', + value: 'help', + matchedAlias: undefined, + }), ]); }); }); @@ -265,12 +298,13 @@ describe('useSlashCompletion', () => { await waitFor(() => { expect(result.current.suggestions).toEqual([ - { + expect.objectContaining({ label: 'fix-issue', value: 'fix-issue', description: 'Fix GitHub issue', commandKind: CommandKind.BUILT_IN, - }, + argumentHint: '[issue-number]', + }), ]); }); }); @@ -324,16 +358,151 @@ describe('useSlashCompletion', () => { await waitFor(() => { expect(result.current.suggestions).toEqual([ - { - label: 'stats (usage)', + expect.objectContaining({ + label: 'stats (alias: usage)', value: 'stats', description: 'check session stats. Usage: /stats [model|tools]', - commandKind: CommandKind.BUILT_IN, - }, + matchedAlias: 'usage', + }), ]); }); }); + it('should include command metadata in slash suggestions', async () => { + const slashCommands = [ + createTestCommand({ + name: 'review', + description: 'Review changed code', + argumentHint: '[pr-number]', + source: 'bundled-skill', + sourceLabel: 'Skill', + modelInvocable: true, + }), + ]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/rev', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + expect.objectContaining({ + label: 'review', + value: 'review', + argumentHint: '[pr-number]', + source: 'bundled-skill', + sourceLabel: 'Skill', + sourceBadge: '[Skill]', + modelInvocable: true, + }), + ]); + }); + }); + + it('should boost recent commands for root slash suggestions', async () => { + const now = Date.now(); + const slashCommands = [ + createTestCommand({ + name: 'alpha', + description: 'Alpha command', + completionPriority: 100, + }), + createTestCommand({ name: 'beta', description: 'Beta command' }), + ]; + const recentCommands = new Map([ + ['beta', { name: 'beta', usedAt: now, count: 1 }], + ]); + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + recentCommands, + ), + ); + + expect( + result.current.suggestions.map((suggestion) => suggestion.value), + ).toEqual(['beta', 'alpha']); + }); + + it('should boost recent help command above high-priority model for root slash suggestions', async () => { + const now = Date.now(); + const slashCommands = [ + createTestCommand({ + name: 'model', + description: 'Model command', + completionPriority: 100, + }), + createTestCommand({ + name: 'help', + altNames: ['?'], + description: 'for help on Qwen Code', + }), + ]; + const recentCommands = new Map([ + ['help', { name: 'help', usedAt: now, count: 1 }], + ]); + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + recentCommands, + ), + ); + + expect( + result.current.suggestions.map((suggestion) => suggestion.value), + ).toEqual(['help', 'model']); + }); + + it('should boost recent commands for non-root prefix suggestions', async () => { + const now = Date.now(); + const slashCommands = [ + createTestCommand({ + name: 'model', + description: 'Model command', + completionPriority: 5, + }), + createTestCommand({ + name: 'memory', + description: 'Memory command', + completionPriority: 5, + }), + ]; + // Both commands have equal completionPriority; 'memory' used recently + // should be ranked first for '/mo' via recentScore. + const recentCommands = new Map([ + ['memory', { name: 'memory', usedAt: now, count: 1 }], + ]); + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/m', + slashCommands, + mockCommandContext, + recentCommands, + ), + ); + + await waitFor(() => { + const names = result.current.suggestions.map((s) => s.value); + expect(names).toContain('memory'); + expect(names).toContain('model'); + expect(names.indexOf('memory')).toBeLessThan(names.indexOf('model')); + }); + }); + it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { const slashCommands = [ createTestCommand({ @@ -468,18 +637,18 @@ describe('useSlashCompletion', () => { expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ label: 'show', value: 'show', description: 'Show memory', commandKind: CommandKind.BUILT_IN, - }, - { + }), + expect.objectContaining({ label: 'add', value: 'add', description: 'Add to memory', commandKind: CommandKind.BUILT_IN, - }, + }), ]), ); }); @@ -507,18 +676,18 @@ describe('useSlashCompletion', () => { expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ label: 'show', value: 'show', description: 'Show memory', commandKind: CommandKind.BUILT_IN, - }, - { + }), + expect.objectContaining({ label: 'add', value: 'add', description: 'Add to memory', commandKind: CommandKind.BUILT_IN, - }, + }), ]), ); }); @@ -545,12 +714,12 @@ describe('useSlashCompletion', () => { await waitFor(() => { expect(result.current.suggestions).toEqual([ - { + expect.objectContaining({ label: 'add', value: 'add', description: 'Add to memory', commandKind: CommandKind.BUILT_IN, - }, + }), ]); }); }); @@ -776,18 +945,18 @@ describe('useSlashCompletion', () => { expect(result.current.suggestions).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ label: 'summarize', value: 'summarize', description: 'Summarize content', commandKind: CommandKind.MCP_PROMPT, - }, - { + }), + expect.objectContaining({ label: 'help', value: 'help', description: 'Show help', commandKind: CommandKind.BUILT_IN, - }, + }), ]), ); }); @@ -819,12 +988,12 @@ describe('useSlashCompletion', () => { await waitFor(() => { expect(result.current.suggestions).toEqual([ - { + expect.objectContaining({ label: 'summarize', value: 'summarize', description: 'Summarize content', commandKind: CommandKind.MCP_PROMPT, - }, + }), ]); }); }); @@ -863,18 +1032,18 @@ describe('useSlashCompletion', () => { expect(result.current.suggestions).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ label: 'show', value: 'show', description: 'Show memory', commandKind: CommandKind.BUILT_IN, - }, - { + }), + expect.objectContaining({ label: 'add', value: 'add', description: 'Add to memory', commandKind: CommandKind.MCP_PROMPT, - }, + }), ]), ); }); @@ -900,12 +1069,12 @@ describe('useSlashCompletion', () => { await waitFor(() => { expect(result.current.suggestions).toEqual([ - { + expect.objectContaining({ label: 'custom-script', value: 'custom-script', description: 'Run custom script', commandKind: CommandKind.FILE, - }, + }), ]); }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index d056733d652..034291ae567 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -14,6 +14,10 @@ import { type CommandContext, type SlashCommand, } from '../commands/types.js'; +import { + getCommandDisplayName, + getCommandSourceBadge, +} from '../../services/commandMetadata.js'; // Type alias for improved type safety based on actual fzf result structure type FzfCommandResult = { @@ -172,12 +176,24 @@ interface RankedCommandMatch { command: SlashCommand; matchStrength: CommandMatchStrength; completionPriority: number; + recentScore: number; score: number; start: number; itemLength: number; originalIndex: number; + matchedAlias?: string; } +export type RecentSlashCommand = { + name: string; + usedAt: number; + count: number; +}; + +export type RecentSlashCommands = ReadonlyMap; + +const RECENT_DECAY_MS = 10 * 60 * 1000; + function getCompletionPriority(command: SlashCommand): number { return command.completionPriority ?? 0; } @@ -224,6 +240,7 @@ function compareRankedCommandMatches( return ( right.matchStrength - left.matchStrength || right.completionPriority - left.completionPriority || + right.recentScore - left.recentScore || right.score - left.score || left.start - right.start || left.itemLength - right.itemLength || @@ -231,21 +248,67 @@ function compareRankedCommandMatches( ); } +function getRecentScore( + command: SlashCommand, + recentCommands?: RecentSlashCommands, + now = Date.now(), +): number { + const recent = recentCommands?.get(command.name); + if (!recent) { + return 0; + } + + const ageMs = Math.max(0, now - recent.usedAt); + return recent.count * 10 + 10 * Math.max(0, 1 - ageMs / RECENT_DECAY_MS); +} + +function getMatchedAlias( + command: SlashCommand, + matchedValue: string, +): string | undefined { + return command.altNames?.find( + (altName) => altName.toLowerCase() === matchedValue.toLowerCase(), + ); +} + function createRankedCommandMatch( command: SlashCommand, matchedValue: string, query: string, result: Pick, originalIndex: number, + recentCommands?: RecentSlashCommands, ): RankedCommandMatch { return { command, matchStrength: getCommandMatchStrength(matchedValue, query, result.start), completionPriority: getCompletionPriority(command), + recentScore: getRecentScore(command, recentCommands), score: result.score, start: result.start, itemLength: matchedValue.length, originalIndex, + matchedAlias: getMatchedAlias(command, matchedValue), + }; +} + +function toCommandSuggestion( + command: SlashCommand, + matchedAlias?: string, + includeAliases = false, +): Suggestion { + return { + label: getCommandDisplayName(command, { matchedAlias, includeAliases }), + value: command.name, + description: command.description, + commandKind: command.kind, + source: command.source, + sourceLabel: command.sourceLabel, + sourceBadge: getCommandSourceBadge(command) ?? undefined, + argumentHint: command.argumentHint, + matchedAlias, + supportedModes: command.supportedModes, + modelInvocable: command.modelInvocable, }; } @@ -258,7 +321,8 @@ function useCommandSuggestions( getPrefixSuggestions: ( commands: readonly SlashCommand[], partial: string, - ) => SlashCommand[], + ) => RankedCommandMatch[], + recentCommands?: RecentSlashCommands, ): SuggestionsResult { const [suggestions, setSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -329,13 +393,33 @@ function useCommandSuggestions( if (commandsToSearch.length > 0) { const performFuzzySearch = async () => { if (signal.aborted) return; - let potentialSuggestions: SlashCommand[] = []; + let rankedSuggestions: RankedCommandMatch[] = []; if (partial === '') { - // If no partial query, show all available commands - potentialSuggestions = commandsToSearch.filter( - (cmd) => cmd.description && !cmd.hidden, - ); + // If no partial query, recently used commands should be the most prominent. + rankedSuggestions = commandsToSearch + .flatMap((cmd, index) => { + if (!cmd.description || cmd.hidden) { + return []; + } + return [ + createRankedCommandMatch( + cmd, + cmd.name, + partial, + { score: 0, start: 0 }, + index, + recentCommands, + ), + ]; + }) + .sort((left, right) => { + const recentDifference = right.recentScore - left.recentScore; + if (recentDifference !== 0) { + return recentDifference; + } + return compareRankedCommandMatches(left, right); + }); } else { // Use fuzzy search for non-empty partial queries with fallback const fzfInstance = getFzfForCommands(commandsToSearch); @@ -358,6 +442,7 @@ function useCommandSuggestions( partial, result, originalIndex, + recentCommands, ); const existingRank = rankedMatches.get(cmd); if ( @@ -368,36 +453,34 @@ function useCommandSuggestions( } } }); - potentialSuggestions = Array.from(rankedMatches.values()) - .sort(compareRankedCommandMatches) - .map((match) => match.command); + rankedSuggestions = Array.from(rankedMatches.values()).sort( + compareRankedCommandMatches, + ); } catch (error) { logErrorSafely( error, 'Fuzzy search - falling back to prefix matching', ); // Fallback to prefix-based filtering - potentialSuggestions = getPrefixSuggestions( + rankedSuggestions = getPrefixSuggestions( commandsToSearch, partial, ); } } else { // Fallback to prefix-based filtering when fzf instance creation fails - potentialSuggestions = getPrefixSuggestions( - commandsToSearch, - partial, - ); + rankedSuggestions = getPrefixSuggestions(commandsToSearch, partial); } } if (!signal.aborted) { - const finalSuggestions = potentialSuggestions.map((cmd) => ({ - label: formatSlashCommandLabel(cmd), - value: cmd.name, - description: cmd.description, - commandKind: cmd.kind, - })); + const finalSuggestions = rankedSuggestions.map((match) => + toCommandSuggestion( + match.command, + match.matchedAlias, + partial === '', + ), + ); setSuggestions(finalSuggestions); } @@ -416,7 +499,13 @@ function useCommandSuggestions( setSuggestions([]); return () => abortController.abort(); - }, [parserResult, commandContext, getFzfForCommands, getPrefixSuggestions]); + }, [ + parserResult, + commandContext, + getFzfForCommands, + getPrefixSuggestions, + recentCommands, + ]); return { suggestions, isLoading }; } @@ -498,6 +587,7 @@ export interface UseSlashCompletionProps { query: string | null; slashCommands: readonly SlashCommand[]; commandContext: CommandContext; + recentCommands?: RecentSlashCommands; setSuggestions: (suggestions: Suggestion[]) => void; setIsLoadingSuggestions: (isLoading: boolean) => void; setIsPerfectMatch: (isMatch: boolean) => void; @@ -512,6 +602,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { query, slashCommands, commandContext, + recentCommands, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch, @@ -611,6 +702,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { start: 0, }, index, + recentCommands, ), ) .sort(compareRankedCommandMatches)[0]; @@ -618,11 +710,9 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { return bestMatch ? [bestMatch] : []; }); - return rankedMatches - .sort(compareRankedCommandMatches) - .map((match) => match.command); + return rankedMatches.sort(compareRankedCommandMatches); }, - [], + [recentCommands], ); // Use extracted hooks for better separation of concerns @@ -632,6 +722,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { commandContext, getFzfForCommands, getPrefixSuggestions, + recentCommands, ); const { start: calculatedStart, end: calculatedEnd } = useCompletionPositions( query, @@ -679,14 +770,3 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { completionEnd, }; } - -function formatSlashCommandLabel(command: SlashCommand): string { - const baseLabel = command.name; - const altNames = command.altNames?.filter(Boolean); - - if (!altNames || altNames.length === 0) { - return baseLabel; - } - - return `${baseLabel} (${altNames.join(', ')})`; -} diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 37c6b33223f..c9e4337fc49 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -15,7 +15,10 @@ import { getUrlOpenCommand, CodePage, findMidInputSlashCommand, + findSlashCommandTokens, + getBestSlashCommandMatch, } from './commandUtils.js'; +import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; // Mock child_process vi.mock('child_process'); @@ -545,3 +548,198 @@ describe('findMidInputSlashCommand', () => { expect(findMidInputSlashCommand('hello/review', 12)).toBeNull(); }); }); + +describe('findSlashCommandTokens', () => { + const mockCommands = [ + { + name: 'review', + description: 'Review code', + kind: 'built-in' as const, + modelInvocable: true, + userInvocable: true, + hidden: false, + }, + { + name: 'clear', + description: 'Clear conversation', + kind: 'built-in' as const, + modelInvocable: false, + userInvocable: true, + hidden: false, + }, + { + name: 'hidden-cmd', + description: 'Hidden', + kind: 'built-in' as const, + modelInvocable: true, + userInvocable: true, + hidden: true, + }, + ] as Parameters[1]; + + it('returns empty array for empty text', () => { + expect(findSlashCommandTokens('', mockCommands)).toEqual([]); + }); + + it('marks line-start known command as valid', () => { + const tokens = findSlashCommandTokens('/clear some args', mockCommands); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ commandName: 'clear', valid: true }); + }); + + it('marks line-start hidden command as invalid', () => { + const tokens = findSlashCommandTokens('/hidden-cmd', mockCommands); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ + commandName: 'hidden-cmd', + valid: false, + }); + }); + + it('marks mid-input modelInvocable command as valid', () => { + const tokens = findSlashCommandTokens( + 'please /review this code', + mockCommands, + ); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ commandName: 'review', valid: true }); + }); + + it('marks mid-input non-modelInvocable command as invalid', () => { + const tokens = findSlashCommandTokens( + 'please /clear everything', + mockCommands, + ); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ commandName: 'clear', valid: false }); + }); + + it('marks unknown token as invalid', () => { + const tokens = findSlashCommandTokens('/usr/bin/something', mockCommands); + // /usr matches nothing, so invalid + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ commandName: 'usr', valid: false }); + }); + + it('returns correct start and end positions', () => { + const text = 'run /review now'; + const tokens = findSlashCommandTokens(text, mockCommands); + expect(tokens).toHaveLength(1); + expect(tokens[0].start).toBe(4); + expect(tokens[0].end).toBe(11); // '/review' is 7 chars, starts at 4 + }); + + it('marks altName token as valid (line-start)', () => { + const commandsWithAlt = [ + ...mockCommands, + { + name: 'stats', + description: 'Show stats', + kind: 'built-in' as const, + modelInvocable: false, + userInvocable: true, + hidden: false, + altNames: ['usage'], + }, + ] as Parameters[1]; + + const tokens = findSlashCommandTokens('/usage', commandsWithAlt); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ commandName: 'usage', valid: true }); + }); +}); + +// --------------------------------------------------------------------------- +// getBestSlashCommandMatch +// --------------------------------------------------------------------------- +describe('getBestSlashCommandMatch', () => { + const makeCommand = ( + name: string, + opts: { + modelInvocable?: boolean; + completionPriority?: number; + argumentHint?: string; + altNames?: string[]; + } = {}, + ) => + ({ + name, + description: `${name} desc`, + kind: 'built-in', + modelInvocable: opts.modelInvocable ?? true, + completionPriority: opts.completionPriority ?? 0, + argumentHint: opts.argumentHint, + altNames: opts.altNames, + userInvocable: true, + hidden: false, + }) as Parameters[1][number]; + + const cmds = [ + makeCommand('review', { completionPriority: 5 }), + makeCommand('refactor', { completionPriority: 3 }), + makeCommand('run', { completionPriority: 1 }), + ]; + + it('returns null for empty partialCommand', () => { + expect(getBestSlashCommandMatch('', cmds)).toBeNull(); + }); + + it('returns null when no commands match', () => { + expect(getBestSlashCommandMatch('xyz', cmds)).toBeNull(); + }); + + it('returns null for non-modelInvocable commands', () => { + const nonInvocable = [makeCommand('reset', { modelInvocable: false })]; + expect(getBestSlashCommandMatch('re', nonInvocable)).toBeNull(); + }); + + it('returns the best prefix match by completionPriority', () => { + // 'r' matches review(5), refactor(3), run(1) — highest priority wins + const result = getBestSlashCommandMatch('r', cmds); + expect(result).not.toBeNull(); + expect(result!.fullCommand).toBe('review'); + expect(result!.suffix).toBe('eview'); + }); + + it('returns argumentHint when command has one', () => { + const withHint = [makeCommand('ask', { argumentHint: '' })]; + const result = getBestSlashCommandMatch('as', withHint); + expect(result!.argumentHint).toBe(''); + }); + + it('respects recentCommands ordering (recent overrides lower priority)', () => { + // 'r' matches review(5), refactor(3), run(1) + // Make 'run' recently used — but completionPriority takes precedence + const recentCommands: RecentSlashCommands = new Map([ + ['run', { name: 'run', usedAt: Date.now(), count: 10 }], + ]); + const result = getBestSlashCommandMatch('r', cmds, recentCommands); + // completionPriority is checked first, so review (priority=5) still wins + expect(result!.fullCommand).toBe('review'); + }); + + it('uses recentCommands to break a tie in completionPriority', () => { + const tied = [ + makeCommand('alpha', { completionPriority: 5 }), + makeCommand('albet', { completionPriority: 5 }), + ]; + const recentCommands: RecentSlashCommands = new Map([ + ['albet', { name: 'albet', usedAt: Date.now(), count: 1 }], + ]); + const result = getBestSlashCommandMatch('al', tied, recentCommands); + expect(result!.fullCommand).toBe('albet'); + }); + + it('excludes exact-match commands without argumentHint', () => { + // 'review' exactly matches 'review' with no argumentHint → excluded + const result = getBestSlashCommandMatch('review', cmds); + expect(result).toBeNull(); + }); + + it('includes exact-match command when it has argumentHint', () => { + const withHint = [makeCommand('review', { argumentHint: '' })]; + const result = getBestSlashCommandMatch('review', withHint); + expect(result).not.toBeNull(); + expect(result!.suffix).toBe(''); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index ed023b97185..0e851372ce1 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -8,6 +8,7 @@ import type { SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { SlashCommand } from '../commands/types.js'; +import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; /** * Common Windows console code pages (CP) used for encoding conversions. @@ -259,21 +260,118 @@ export function findMidInputSlashCommand( export function getBestSlashCommandMatch( partialCommand: string, commands: readonly SlashCommand[], -): { suffix: string; fullCommand: string } | null { + recentCommands?: RecentSlashCommands, +): { + suffix: string; + fullCommand: string; + command: SlashCommand; + argumentHint?: string; +} | null { if (!partialCommand) return null; const query = partialCommand.toLowerCase(); - let best: { suffix: string; fullCommand: string } | null = null; + + const matches = commands + .filter((cmd) => { + // Only suggest model-invocable commands for mid-input completion, + // since built-in commands typed in the middle of text won't be executed. + if (!cmd.modelInvocable) return false; + const name = cmd.name.toLowerCase(); + return name.startsWith(query) && (name !== query || !!cmd.argumentHint); + }) + .sort((left, right) => { + const leftRecent = recentCommands?.get(left.name); + const rightRecent = recentCommands?.get(right.name); + const recentOrder = + (rightRecent?.usedAt ?? 0) - (leftRecent?.usedAt ?? 0); + return ( + (right.completionPriority ?? 0) - (left.completionPriority ?? 0) || + recentOrder || + left.name.localeCompare(right.name) + ); + }); + + const best = matches[0]; + if (!best) return null; + return { + suffix: best.name.slice(partialCommand.length), + fullCommand: best.name, + command: best, + argumentHint: best.argumentHint, + }; +} + +/** + * Represents a slash command token found in input text (potentially mid-input). + */ +export type SlashCommandToken = { + /** Start index (character position) of the token in the text */ + start: number; + /** End index (exclusive) of the token in the text */ + end: number; + /** The matched command name (without the leading slash) */ + commandName: string; + /** + * Whether the token corresponds to a known command. + * Mid-input tokens are only valid when they match a model-invocable command. + * Line-start tokens are valid for all interactive commands. + */ + valid: boolean; +}; + +const SLASH_TOKEN_RE = /(?:^|(?<=\s))\/([a-zA-Z][a-zA-Z0-9:_-]*)/g; + +/** + * Finds slash command tokens in input text and marks them as valid/invalid + * based on the provided command list. + * + * - Tokens at position 0 are valid if they match any command. + * - Mid-input tokens (preceded by whitespace) are valid only if they match a + * `modelInvocable` command, since built-in commands typed mid-text won't be + * executed. + */ +export function findSlashCommandTokens( + text: string, + commands: readonly SlashCommand[], +): SlashCommandToken[] { + if (!text) return []; + + const commandMapEntries: Array<[string, SlashCommand]> = []; for (const cmd of commands) { - // Only suggest model-invocable commands for mid-input completion, - // since built-in commands typed in the middle of text won't be executed. - if (!cmd.modelInvocable) continue; - const name = cmd.name.toLowerCase(); - if (name.startsWith(query) && name !== query) { - const suffix = cmd.name.slice(partialCommand.length); - if (!best || cmd.name < best.fullCommand) { - best = { suffix, fullCommand: cmd.name }; + commandMapEntries.push([cmd.name.toLowerCase(), cmd]); + for (const altName of cmd.altNames ?? []) { + commandMapEntries.push([altName.toLowerCase(), cmd]); + } + } + const commandMap = new Map(commandMapEntries); + + const tokens: SlashCommandToken[] = []; + let match: RegExpExecArray | null; + SLASH_TOKEN_RE.lastIndex = 0; + + while ((match = SLASH_TOKEN_RE.exec(text)) !== null) { + const fullMatch = match[0]; + const commandName = match[1]; + const start = match.index; + const end = start + fullMatch.length; + + // Determine if this is a line-start token (position 0 or preceded by newline) + const precedingChar = start > 0 ? text[start - 1] : null; + const isLineStart = start === 0 || precedingChar === '\n'; + + const cmd = commandMap.get(commandName.toLowerCase()); + let valid = false; + if (cmd) { + if (isLineStart) { + // Line-start: valid if command is user-invocable (interactive) + valid = cmd.userInvocable !== false && !cmd.hidden; + } else { + // Mid-input: only valid if model-invocable + valid = cmd.modelInvocable === true; } } + + tokens.push({ start, end, commandName, valid }); } - return best; + + return tokens; } diff --git a/packages/cli/src/ui/utils/highlight.test.ts b/packages/cli/src/ui/utils/highlight.test.ts index 8d4c5ce620f..75a9789df72 100644 --- a/packages/cli/src/ui/utils/highlight.test.ts +++ b/packages/cli/src/ui/utils/highlight.test.ts @@ -5,8 +5,30 @@ */ import { describe, it, expect } from 'vitest'; +import { CommandKind, type SlashCommand } from '../commands/types.js'; import { parseInputForHighlighting } from './highlight.js'; +const slashCommands: SlashCommand[] = [ + { + name: 'help', + description: 'Help', + kind: CommandKind.BUILT_IN, + userInvocable: true, + }, + { + name: 'review', + description: 'Review', + kind: CommandKind.SKILL, + modelInvocable: true, + }, + { + name: 'clear', + description: 'Clear', + kind: CommandKind.BUILT_IN, + modelInvocable: false, + }, +]; + describe('parseInputForHighlighting', () => { it('should handle an empty string', () => { expect(parseInputForHighlighting('', 0)).toEqual([ @@ -45,10 +67,12 @@ describe('parseInputForHighlighting', () => { ]); }); - it('should not highlight a command in the middle', () => { + it('should highlight a command in the middle when preceded by whitespace', () => { const text = 'I need /help with this'; expect(parseInputForHighlighting(text, 0)).toEqual([ - { text: 'I need /help with this', type: 'default' }, + { text: 'I need ', type: 'default' }, + { text: '/help', type: 'command' }, + { text: ' with this', type: 'default' }, ]); }); @@ -61,12 +85,16 @@ describe('parseInputForHighlighting', () => { ]); }); - it('should highlight files but not commands not at the start', () => { + it('should highlight commands and files when commands are preceded by whitespace', () => { const text = 'Use /run with @file.js and also /format @another/file.ts'; expect(parseInputForHighlighting(text, 0)).toEqual([ - { text: 'Use /run with ', type: 'default' }, + { text: 'Use ', type: 'default' }, + { text: '/run', type: 'command' }, + { text: ' with ', type: 'default' }, { text: '@file.js', type: 'file' }, - { text: ' and also /format ', type: 'default' }, + { text: ' and also ', type: 'default' }, + { text: '/format', type: 'command' }, + { text: ' ', type: 'default' }, { text: '@another/file.ts', type: 'file' }, ]); }); @@ -79,10 +107,11 @@ describe('parseInputForHighlighting', () => { ]); }); - it('should not highlight command at the end of the string', () => { + it('should highlight command at the end of the string when preceded by whitespace', () => { const text = 'Get help with /help'; expect(parseInputForHighlighting(text, 0)).toEqual([ - { text: 'Get help with /help', type: 'default' }, + { text: 'Get help with ', type: 'default' }, + { text: '/help', type: 'command' }, ]); }); @@ -94,10 +123,12 @@ describe('parseInputForHighlighting', () => { ]); }); - it('should not highlight command with dashes and numbers not at start', () => { + it('should highlight command with dashes and numbers when preceded by whitespace', () => { const text = 'Run /command-123 now'; expect(parseInputForHighlighting(text, 0)).toEqual([ - { text: 'Run /command-123 now', type: 'default' }, + { text: 'Run ', type: 'default' }, + { text: '/command-123', type: 'command' }, + { text: ' now', type: 'default' }, ]); }); @@ -126,6 +157,15 @@ describe('parseInputForHighlighting', () => { ]); }); + it('should highlight mid-input slash command (the key use case)', () => { + const text = 'hello /review sssss'; + expect(parseInputForHighlighting(text, 0)).toEqual([ + { text: 'hello ', type: 'default' }, + { text: '/review', type: 'command' }, + { text: ' sssss', type: 'default' }, + ]); + }); + it('should highlight a file path with escaped spaces', () => { const text = 'cat @/my\\ path/file.txt'; expect(parseInputForHighlighting(text, 0)).toEqual([ @@ -133,4 +173,20 @@ describe('parseInputForHighlighting', () => { { text: '@/my\\ path/file.txt', type: 'file' }, ]); }); + + it('should only highlight valid slash commands when command metadata is provided', () => { + const text = '/help please /review this /clear and /missing plus /usr/bin'; + expect(parseInputForHighlighting(text, 0, slashCommands)).toEqual([ + { text: '/help', type: 'command' }, + { text: ' please ', type: 'default' }, + { text: '/review', type: 'command' }, + { text: ' this ', type: 'default' }, + { text: '/clear', type: 'default' }, + { text: ' and ', type: 'default' }, + { text: '/missing', type: 'default' }, + { text: ' plus ', type: 'default' }, + { text: '/usr', type: 'default' }, + { text: '/bin', type: 'default' }, + ]); + }); }); diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index 1e48b36f13d..f3158feb0f6 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { SlashCommand } from '../commands/types.js'; +import { findSlashCommandTokens } from './commandUtils.js'; import { cpLen, cpSlice } from './textUtils.js'; export type HighlightToken = { @@ -11,20 +13,30 @@ export type HighlightToken = { type: 'default' | 'command' | 'file'; }; -const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g; +const HIGHLIGHT_REGEX = + /(^\/[a-zA-Z][a-zA-Z0-9:_-]*)|((?<=\s)\/[a-zA-Z][a-zA-Z0-9:_-]*)|(@(?:\\ |[a-zA-Z0-9_./-])+)/g; export function parseInputForHighlighting( text: string, index: number, + slashCommands?: readonly SlashCommand[], ): readonly HighlightToken[] { if (!text) { return [{ text: '', type: 'default' }]; } const tokens: HighlightToken[] = []; + const validSlashTokenStarts = new Set( + slashCommands + ? findSlashCommandTokens(text, slashCommands) + .filter((token) => token.valid) + .map((token) => token.start) + : undefined, + ); let lastIndex = 0; let match; + HIGHLIGHT_REGEX.lastIndex = 0; while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) { const [fullMatch] = match; const matchIndex = match.index; @@ -38,19 +50,22 @@ export function parseInputForHighlighting( } // Add the matched token - const type = fullMatch.startsWith('/') ? 'command' : 'file'; - // Only highlight slash commands if the index is 0. - if (type === 'command' && index !== 0) { - tokens.push({ - text: fullMatch, - type: 'default', - }); + let type: HighlightToken['type']; + if (match[1] !== undefined || match[2] !== undefined) { + if (slashCommands) { + type = validSlashTokenStarts.has(matchIndex) ? 'command' : 'default'; + } else if (match[1] !== undefined) { + // Group 1: line-start slash command — only highlight on logical line 0 + type = index === 0 ? 'command' : 'default'; + } else { + // Backwards-compatible fallback when no command metadata is provided. + type = 'command'; + } } else { - tokens.push({ - text: fullMatch, - type, - }); + // Group 3: @file pattern + type = 'file'; } + tokens.push({ text: fullMatch, type }); lastIndex = matchIndex + fullMatch.length; } diff --git a/packages/core/src/utils/forkedAgent.agent.test.ts b/packages/core/src/utils/forkedAgent.agent.test.ts index 92d57e17b10..f92c428564a 100644 --- a/packages/core/src/utils/forkedAgent.agent.test.ts +++ b/packages/core/src/utils/forkedAgent.agent.test.ts @@ -294,21 +294,17 @@ describe('runForkedAgent (AgentHeadless path) bound-tool isolation', () => { }, ); - const createSpy = vi - .spyOn(AgentHeadless, 'create') - .mockImplementation( - async (..._args: unknown[]): Promise => - ({ - execute: vi - .fn() - .mockRejectedValue(new Error('headless-execute-blew-up')), - getTerminateMode: vi - .fn() - .mockReturnValue(AgentTerminateMode.GOAL), - getFinalText: vi.fn().mockReturnValue(''), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, - ); + const createSpy = vi.spyOn(AgentHeadless, 'create').mockImplementation( + async (..._args: unknown[]): Promise => + ({ + execute: vi + .fn() + .mockRejectedValue(new Error('headless-execute-blew-up')), + getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getFinalText: vi.fn().mockReturnValue(''), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); try { await expect(