Skip to content

fix(composer): 斜杠命令跨来源重名去重,Enter/Tab 与高亮项保持一致(#84) - #93

Merged
Jaxton07 merged 1 commit into
Jaxton07:mainfrom
MrTangLuyao:fix/slash-command-conflicts
Oct 8, 2026
Merged

Jaxton07 merged 1 commit into
Jaxton07:mainfrom
MrTangLuyao:fix/slash-command-conflicts

Conversation

@MrTangLuyao

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #84

我顺着执行链路看了一下,重名的条目其实只对应一个执行入口:渲染端的 runSlashCommand 会先拦下 compact/name/export/settings,剩下的才交给 SDK 的 prompt(),而 SDK 又是先查扩展命令,再展开 /skill:,最后才套模板。所以在 main 上,点「扩展 /compact」或「模板 /compact」跑的都是内置压缩(胶囊上显示的也是「压缩」),点「模板 /review」「技能 /skill:lint」跑的却是同名扩展。菜单上看着是不同的命令,实际跑的是同一个入口。

冲突策略我直接照这个分发顺序来定:同一个 /名字 只保留执行时真正会命中的那条,优先级是内置 > 扩展 > skill > 模板,被占名的不再列出。这样内置命令的行为一点没变;被遮住的扩展或模板在桌面端本来就调不到,列出来只会误导人。pi CLI 自己也是这么处理的,交互模式里和内置同名的扩展命令同样不进补全。扩展之间的 :N 不受影响:两个扩展都注册 compact 时,它们本来就叫 /compact:1、/compact:2,跟内置不冲突,照常列出、照常执行。规则放在 slash-commands.ts 的 mergeCommands 里,会话态和新会话页的两份清单都走它,所以两边结果一致。

核对「鼠标、Enter、Tab 一致」这一条时,我还碰到一个跟重名无关的问题:SlashMenu 按 SOURCE_ORDER(内置/模板/skill/扩展)分组渲染,use-slash-menu 里的 Enter/Tab 却是按下标去取 filterCommands 的结果,而它原来把扩展排在 skill 前面。只要 skill 和扩展同时命中就会错位,比如输入 /sy,有 skill:sync-docs 和扩展 sync,高亮停在 /sync 上按 Enter,选中的却是 /skill:sync-docs。现在 filterCommands 直接按 SOURCE_ORDER 输出(它的注释原本就是这么写的),两边的下标就对上了。

另外顺手把 docs/INDEX.md 里 slash-commands.ts 那一行同步了一下。

How was it tested?

回归测试先在没改动的 main 上跑过,确认能复现。packages/backend/test/slash-commands.test.ts 用真实的 DefaultResourceLoader 和 createAgentSession(临时目录,不调用模型),准备了模板 compact/review/plain、skill lint、一个注册 compact/review/skill:lint 的扩展,以及两个都注册 deploy 的扩展。测试断言每个名字只出现一次、标注的来源就是执行时命中的那个、会话态与新会话页清单一致,并对列出的扩展条目调用 session.prompt(),确认跑到的是对应 handler;两个扩展都注册 compact 的情况也单独测了。在 main 上这 4 条里有 3 条失败,「会话态与新会话页一致」那条能过,因为两边重复得一模一样。SlashMenu.test.ts 里新加的顺序用例在 main 上也是失败的。改完之后都通过了。

本地 npm run lint、npm run typecheck、npm run build 和 npx electron-builder --dir 都通过。npm run test 是在 Windows 11 上跑的:backend 有 24 条、desktop 有 3 条失败,都跟 POSIX 路径、/tmp、文件权限有关(比如 show-image-tool、permission-tmp-zone、path-target)。我在 main 上也完整跑了一遍,失败集合完全相同,跟这次改动无关,Ubuntu 的 CI 上应该是干净的。

我还用 playwright-core 驱动真实的 Electron 应用,在隔离的 PI_CODING_AGENT_DIR 和 userData 里放了同样的模板、skill 和扩展(扩展 handler 会写日志并 notify),main 和本分支跑的是同一套脚本。在 main 上,菜单里 /compact 有三条、/review 和 /skill:lint 各两条;点「模板 /review」和「技能 /skill:lint」执行的都是扩展;点「扩展 /compact」时后端日志里只有 PiBackend.compact,扩展 handler 一次都没被调到;输入 /sy 后高亮 /sync 按 Enter,得到的是 /skill:sync-docs。本分支我用开发构建和 electron-builder --dir 打出来的 Percho.exe 各跑了一遍:每个名字只剩一条,新会话页和会话内的菜单完全一致;鼠标点 /review、Enter 选 /deploy:1、↓ + Tab 选 /deploy:2、/sy ↓ + Enter 选 /sync、点 /skill:lint,执行的都是对应扩展;/compact 只剩内置那条,走的是 PiBackend.compact。

没覆盖到的部分:模板真正展开后发给模型那一步(本地没配置模型),以及 macOS/Linux 上的手动验证。

如果你更希望被遮住的扩展命令换个别名继续保留可调用,或者像 CLI 那样在扩展诊断里加一条「与内置命令同名,已在面板中跳过」的提示,告诉我一声,我再补上。

Checklist

  • npm run lint passes
  • npm run typecheck passes
  • npm run test passes(新增用例全部通过;Windows 本地另有 27 条在 main 上同样失败的路径类用例,见上)
  • New user-facing strings were added to both zh and en dictionaries(本次没有新增文案)
  • No API keys or sensitive data included

同一个 /名字 只列执行时真正命中的那条,按实际分发顺序先到先得:
内置(渲染端 runSlashCommand 先拦截)> 扩展(SDK prompt() 先查扩展命令)
> skill > 模板。会话态与新会话页共用同一规则,扩展之间的 :N 消歧不变。

filterCommands 按 SOURCE_ORDER 输出,与 SlashMenu 分组渲染顺序一致,
键盘按下标取到的就是高亮的那条。
@MrTangLuyao
MrTangLuyao requested a review from Jaxton07 as a code owner October 7, 2026 20:51
@Jaxton07

Jaxton07 commented Oct 8, 2026

Copy link
Copy Markdown
Owner

复核过了,同意合并。下面是我独立验证的过程和几条非阻塞建议。

复核结果

复核项 结果
「分发顺序 = 内置 > 扩展 > skill > 模板」 ✅ 属实,证据见下
去重会不会误删可达命令 ✅ 不会
新测试在 main 上是否真红 ✅ 红(backend 3/4 条失败、desktop 1 条失败),换回 PR 版本全绿
全量测试(macOS) ✅ backend 70 files / 630 tests、desktop 61 files / 738 tests 全过
lint / typecheck ✅ 过

顺序证据(@earendil-works/pi-coding-agent@1.0.4):core/agent-session.js 的 prompt() 里依次是 _tryExecuteExtensionCommand()(文本以 / 开头即查扩展命令)→ 压缩守卫 → input handlers → _expandSkillCommand()(只认 /skill:)→ expandPromptTemplate()。渲染端 use-composer-send.ts:75 的 switch 拦下 compact/name/export/settings,早于 prompt()。所以 mergeCommands 的认领顺序就是真实执行顺序,被挤掉的条目本来就永久不可达,删掉是对的。

另外两点也核对过:

  • 扩展之间 :N 不受影响 —— :N 消歧在 mergeCommands 之前完成,且 backend 手写的 extensionCommands() 规则与 SDK 的 core/extensions/runner.js:556-584(含 taken 冲突回退循环)逐字一致,draft / session 两态因此一致(测试也断言了 deploy:1/2)。
  • 全仓库只有这一处按 / 前缀特判(backend / main / shared 都 grep 过),渲染端拦截表恰好等于 BUILTIN_SLASH_COMMANDS 那 4 条,没有第五个隐藏拦截点。

非阻塞建议(可另开 issue)

  1. slash-filter.ts:47 的 if (!query) return commands 还是不做重排,现在能对只因为后端恰好按 SOURCE_ORDER 输出。这正是本 PR 修的那类「菜单按 SOURCE_ORDER 分组、索引按原序」隐患的残留形态——建议把重排应用到所有分支,契约就不用靠「数据源恰好有序」维持。同理 SOURCE_ORDER.flatMap 会静默丢掉不在 SOURCE_ORDER 的 source(当前 union 只有 4 个值所以安全,将来加第 5 个不会编译报错、命令会凭空消失),可以加兜底或 satisfies 卡住。
  2. renderer switch 的 4 个 case 与 backend BUILTIN_SLASH_COMMANDS 是两份硬编码名单、没有测试连起来:将来加内置命令只改一边,「内置优先」就会悄悄失真(本 PR 唯一让我觉得「将来会烂」的地方)。可选把名字提到 shared,或加一致性断言。
  3. 被遮住的命令现在静默消失。pi CLI 的做法是补全里跳过 + 发一条诊断(modes/interactive/interactive-mode.js:450-459);桌面端已有 getLoadedResources 这个诊断出口,要加提示有地方放。你和 reviewer 都提过这个可选项,我倾向 follow-up,不阻塞本次合并。
  4. 描述里有一处叙述略不准确(不影响实现):CLI 只对「内置 vs 扩展」按 raw name 去重(interactive-mode.js:521-524),模板与扩展的重名它并不去重(补全列表是 [...builtin, ...templates, ...extensions, ...skills],同名模板与扩展会同时出现,实际执行时扩展赢)。也就是说本 PR 覆盖得比 CLI 更完整,不需要拿 CLI 当挡箭牌。
  5. 边角一条,知道即可:use-composer-send.ts:125 的拦截条件是 content.startsWith("/") && images.length === 0,带图发 /compact 会原样进 SDK,此时若有同名扩展则扩展赢,与菜单标注不一致。属既有边角(带图发斜杠命令本身没意义),不必修。

加分项

测试写法很扎实:真实 DefaultResourceLoader + createAgentSession、无模型调用,最后真的 session.prompt("/deploy:1") 验证落到哪个 handler(不是只断言清单),并用 FIXTURE_NAMES 筛掉本机全局资源、对全局条目只断言不变量,抗污染。docs/INDEX.md 已同步、无新增文案所以未动 i18n,都对。

按项目惯例 squash 合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 内置 slash 命令与扩展同名时,命令面板出现重复条目

2 participants