소스 검색

Merge remote-tracking branch 'origin/master' into turtle/issue-3848-headless-machine-readable-run

lsdsjy 3 주 전
부모
커밋
ef34e9d888
100개의 변경된 파일과 1510개의 추가작업 그리고 287개의 파일을 삭제
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml
  2. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md
  3. 33 0
      .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
  5. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
  6. 43 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml
  8. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
  9. 29 0
      .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md
  10. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
  11. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
  12. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
  13. 3 2
      apps/web/tests/agent-preset-selection.e2e.ts
  14. 1 3
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  15. 1 3
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  16. 1 3
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  17. 1 3
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  18. 1 3
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  19. 1 3
      apps/web/tests/expected/markdown-images/ui.expected.md
  20. 1 3
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  21. 1 3
      apps/web/tests/expected/math-rendering/ui.expected.md
  22. 1 3
      apps/web/tests/expected/reference-composer/order.expected.md
  23. 1 3
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  24. 1 3
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  25. 1 3
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  26. 1 3
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  27. 1 3
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  28. 1 3
      apps/web/tests/expected/steer-all/settled.expected.md
  29. 1 1
      apps/web/tests/feedback-command.e2e.ts
  30. 1 1
      apps/web/tests/goal-command-presentation.e2e.ts
  31. 68 5
      apps/web/tests/lifecycle-chrome.e2e.ts
  32. 1 1
      apps/web/tests/navigation-panes.e2e.ts
  33. 1 1
      apps/web/tests/seeded-history.e2e.ts
  34. 3 0
      apps/web/tests/settings-chrome.e2e.ts
  35. 2 1
      apps/web/tests/shipped-composition.e2e.ts
  36. 1 1
      apps/web/tests/sidebar-scrollbar.e2e.ts
  37. 1 3
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  38. 2 2
      docs/config-catalog.i18n.yaml
  39. 3 3
      docs/config-catalog.md
  40. 3 3
      docs/config-catalog.zh.md
  41. 2 2
      docs/event-producer-consumer.i18n.yaml
  42. 1 1
      docs/event-producer-consumer.md
  43. 1 1
      docs/event-producer-consumer.zh.md
  44. 2 2
      docs/persistence-catalog.i18n.yaml
  45. 4 4
      docs/persistence-catalog.md
  46. 4 4
      docs/persistence-catalog.zh.md
  47. 2 2
      docs/subsystems/commands.i18n.yaml
  48. 4 0
      docs/subsystems/commands.md
  49. 4 0
      docs/subsystems/commands.zh.md
  50. 4 4
      packages/client/connection/src/client/fixture.ts
  51. 11 4
      packages/client/ui-chat/src/client/chat/message-chrome.ts
  52. 2 0
      packages/client/ui-chat/src/client/locale.ts
  53. 39 1
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  54. 2 2
      packages/client/ui-commands/README.i18n.yaml
  55. 8 4
      packages/client/ui-commands/README.md
  56. 8 4
      packages/client/ui-commands/README.zh.md
  57. 11 5
      packages/client/ui-commands/src/client/contract.ts
  58. 4 3
      packages/client/ui-commands/src/client/directory.ts
  59. 2 3
      packages/client/ui-commands/src/client/index.ts
  60. 42 9
      packages/client/ui-commands/src/client/locales.ts
  61. 86 0
      packages/client/ui-commands/src/client/presentation.ts
  62. 59 0
      packages/client/ui-commands/src/client/resolution.ts
  63. 51 45
      packages/client/ui-commands/src/client/service.ts
  64. 14 2
      packages/client/ui-commands/tests/browser-plugin.client.spec.ts
  65. 23 0
      packages/client/ui-commands/tests/directory.client.spec.ts
  66. 171 15
      packages/client/ui-commands/tests/service.client.spec.ts
  67. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  68. 5 1
      packages/client/ui-conversation/README.md
  69. 5 1
      packages/client/ui-conversation/README.zh.md
  70. 23 0
      packages/client/ui-conversation/src/client/apply.ts
  71. 10 1
      packages/client/ui-conversation/src/client/contract/input.ts
  72. 2 1
      packages/client/ui-conversation/src/client/input/editor/claim-decor.ts
  73. 16 0
      packages/client/ui-conversation/src/client/input/editor/keymap.ts
  74. 1 1
      packages/client/ui-conversation/src/client/input/editor/text-ref.ts
  75. 33 1
      packages/client/ui-conversation/src/client/input/facade.ts
  76. 17 0
      packages/client/ui-conversation/src/client/input/hub.ts
  77. 9 3
      packages/client/ui-conversation/src/client/input/machine.ts
  78. 4 4
      packages/client/ui-conversation/src/client/locales.ts
  79. 8 0
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  80. 12 17
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  81. 9 8
      packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
  82. 46 1
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  83. 93 13
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  84. 4 4
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  85. 30 0
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  86. 34 1
      packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx
  87. 5 5
      packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
  88. 29 1
      packages/client/ui-conversation/tests/submit-machine.client.spec.ts
  89. 2 2
      packages/client/ui-input-trigger/README.i18n.yaml
  90. 1 2
      packages/client/ui-input-trigger/README.md
  91. 2 3
      packages/client/ui-input-trigger/README.zh.md
  92. 36 3
      packages/client/ui-input-trigger/src/client/MenuView.module.css
  93. 33 8
      packages/client/ui-input-trigger/src/client/MenuView.tsx
  94. 13 2
      packages/client/ui-input-trigger/src/types.ts
  95. 37 2
      packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
  96. 1 1
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  97. 3 0
      packages/client/ui-model-selection/src/client/index.ts
  98. 2 0
      packages/client/ui-model-selection/src/client/locales.ts
  99. 57 3
      packages/client/ui-primitives/src/icons/index.tsx
  100. 22 8
      packages/client/ui-primitives/src/rank-by-name.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md
+2026-09-10-command-identities-and-composer-file-action.md: 696c3486c5e007416da3fd5e7be1d4b41cb5f582
+2026-09-10-command-identities-and-composer-file-action.zh.md: 2ce803bad5d39a6d4f40b665fb4ef7456e987912

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md

@@ -0,0 +1,33 @@
+# Agent Note: Command identities and composer-owned File action
+
+Status: implemented
+
+English | [中文](2026-09-10-command-identities-and-composer-file-action.zh.md)
+
+## Problem
+
+Matching a command's English description to a client dictionary makes punctuation changes affect localization and inserted command tokens. A same-name override can also copy that description without implementing the first-party command. The File menu entry needs the composer's live attachment policy, including mount, lock, and submission state; a separate command-plugin check cannot determine that state.
+
+## Decision
+
+The command registry preserves an optional branded `CommandDefinitionId` as `definitionId` on the effective definition and descriptor. First-party producers choose their package name as the stable identity. Scoped shadowing selects the complete descriptor and never inherits the shadowed definition's identity. The identifier is discovery metadata, not an authorization claim, and does not enter command lifecycle events.
+
+The client command directory resolves input through its private `resolution.ts`. Exact registered names take priority; Chinese and English aliases select only the corresponding first-party definition in the effective Session catalog. Menu claims use the current locale's spelling; typed claims retain the supplied spelling; submissions use the resolved registered name. `presentation.ts` owns only sections, labels, descriptions, and icons. Resolution helpers and section constants are not exported from the plugin entrypoint.
+
+Conversation registers the File action through the injected command service and owns its localized label. The mounted input binds its file-dialog opener and one live availability query. Both menu filtering and invocation use that query, so lock, unmount, subagent, and submission state apply consistently. The binding and dispatch remain package-internal callbacks; no cross-plugin pick-files event is needed. The assembly uses a narrow structural action-registration face because command UI consumes Conversation's input types; a reverse compiler-project dependency would form a cycle. Its registration test checks against the command plugin's contribution type.
+
+This note supersedes only identity matching, input-resolution placement, and File-action ownership in the [composer menu decision](../feature/2026-09-08-composer-menu-sections-and-localized-rows.md). That note retains the menu, scrolling, claim-retention, and composition-timing decisions.
+
+## Alternatives considered
+
+**Match names and English descriptions.** Copy is editable and can be duplicated by unrelated definitions. A stable identity separates presentation selection from copy without moving localized text onto the Host.
+
+**Match names alone.** An agent-scoped override may deliberately provide a different command under the same name. It must not inherit first-party presentation or aliases unless it explicitly carries that identity.
+
+**Keep the File action in the command plugin.** The input owns attachment acceptance and the DOM lifetime. Maintaining a second availability condition splits one policy between owners.
+
+**Export helpers for reuse.** No production consumer needs the section constants or resolution helper as a plugin API. Tests import internal modules directly.
+
+## Consequences
+
+First-party producers and client identity mappings must agree on stable identifiers; third-party definitions may omit them. Display copy can evolve independently. Registry tests cover descriptor preservation and shadowing; client tests cover description edits in both locales, alias resolution, and exact-name priority. Composer tests cover live availability, opener replacement, unmount, and action-registration disposal. The existing Session-driven menu and command scenarios continue to own assembled browser output.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 命令标识与输入框拥有的文件动作
+
+Status: implemented
+
+[English](2026-09-10-command-identities-and-composer-file-action.md) | 中文
+
+## 问题
+
+用命令的英文说明匹配客户端词典,会让标点修改影响本地化和填入的命令 token。同名覆盖也可能复制这段说明,却没有实现内置命令的行为。「文件」菜单项需要输入框实时的附件策略,包括挂载、锁定和提交状态,命令插件中的另一套检查无法确定这些状态。
+
+## 决策
+
+命令注册表在有效定义和描述符上保留可选的品牌类型 `CommandDefinitionId` 字段 `definitionId`。内置命令的提供方用包名作为稳定标识。作用域覆盖选择完整描述符,不继承被遮蔽定义的标识。该标识是发现元数据,不代表授权,也不进入命令生命周期事件。
+
+客户端命令目录通过内部的 `resolution.ts` 解析输入。精确注册名优先,中英文别名只选择会话有效目录中对应的内置定义。菜单认领使用当前语言的写法,手输认领保留原写法,提交使用解析到的注册名。`presentation.ts` 只负责分节、标题、说明和图标。解析辅助函数与小节常量不从插件入口导出。
+
+Conversation 通过注入的命令服务注册「文件」动作,并负责其本地化标题。已挂载输入框绑定文件选择器回调和一个实时可用性查询。菜单过滤与实际调用使用同一个查询,统一处理锁定、卸载、subagent 和提交状态。绑定与调用留在包内回调中,不需要跨插件的文件选择事件。组装层使用窄的结构化动作注册接口,因为命令 UI 已消费 Conversation 的输入类型,反向增加编译项目依赖会形成循环。注册测试使用命令插件的贡献项类型进行校验。
+
+本记录只接管[输入框菜单决策](../feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md)中的标识匹配、输入解析归属和文件动作归属。原记录继续负责菜单、滚动、认领保留和组合输入时序的决策。
+
+## 考虑过的替代方案
+
+**按名称和英文说明匹配。** 文案可以修改,也可以被无关定义复制。稳定标识将展示选择与文案分开,无需把本地化文本移到宿主。
+
+**只按名称匹配。** agent(智能体)作用域覆盖可能刻意在同名下提供另一种命令。除非它显式携带对应标识,否则不应继承内置展示或别名。
+
+**把文件动作留在命令插件。** 输入框负责附件接收和 DOM 生命周期。维护另一套可用性条件会把同一策略分给两个模块。
+
+**导出辅助函数以供复用。** 没有生产调用方需要把小节常量或解析辅助函数作为插件 API。测试直接导入内部模块。
+
+## 后果
+
+内置命令提供方与客户端映射必须使用一致的稳定标识,第三方定义可以省略标识。显示文案可以独立调整。注册表测试覆盖描述符保留和作用域覆盖,客户端测试覆盖两种语言下修改说明、别名解析和精确名称优先。输入框测试覆盖实时可用性、回调替换、卸载和动作注册的 dispose(资源释放)。现有会话驱动的菜单和命令场景继续负责组装后的浏览器输出。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
+2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e
+2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224

+ 43 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md

@@ -0,0 +1,43 @@
+# Agent Note: Composer menu sections, localized rows, and the File row
+
+Status: implemented
+
+English | [中文](2026-09-08-composer-menu-sections-and-localized-rows.zh.md)
+
+## Problem
+
+The composer's `+` button and a typed `/` listed every command in Host registration order as `name description`, all lowercase, with no glyphs and no grouping, beside a separate paperclip button for files. Under Chinese the rows stayed English because Host descriptors carry English text only, and a user who knew a command by its Chinese title could neither find it by that title nor see what to type. Issue #3567 and the design doc for it ask for two sections in usage order, a glyph and a left-aligned title per row with the description right-aligned, capitalized English titles, Chinese titles and descriptions that stay searchable in both languages and show the English command name, a Chinese fill for the Plan and Goal claims, and a File entry inside the menu.
+
+## Decision
+
+`ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first.
+
+The six built-in Host commands get their localized title, description, glyph, and claim token from the client. Stable identity selection and input resolution follow the [command identity decision](../architecture/2026-09-10-command-identities-and-composer-file-action.md). Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration.
+
+A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. The effective Session catalog resolves Chinese and English aliases through the same input path in every locale.
+
+The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands".
+
+`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
+
+The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
+
+## Alternatives considered
+
+**Release a claim when its separator is deleted.** The complete command name still identifies the selected command. Keeping the claim until the name changes preserves its highlight through argument replacement and avoids relying on an IME-generated space to run ordinary keydown adjudication. The shared input machine applies this rule to every command token, including failure recovery; neither the command name nor the locale selects a separate implementation.
+
+**Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window.
+
+**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; localized product copy belongs to the client.
+
+**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete.
+
+**Keep the paperclip beside the menu.** The design doc and the issue's acceptance criteria integrate the entry into the menu and forbid the old icon from showing twice; the hidden file input and the drop gate stay where they were.
+
+**A contribution-owned copy for the built-in Host rows.** Host packages have no client half to register from, so the copy has to live on the client; one table keyed by name in the package that already owns the `/` source keeps the design decision in one place.
+
+**A `token` alias only under the active locale.** Resolving through every dictionary costs nothing and lets a draft persisted under one locale submit under another.
+
+## Consequences
+
+The menu and localized presentation remain client-owned, while the Host owns effective command definitions. Adding a localized first-party command requires its identity mapping, dictionary entries, icon, and menu position. Session-driven browser scenarios and owner-local ARIA expectations cover the menu. Unrelated same-name definitions keep their own copy and no first-party glyph. The command identity decision owns this distinction and the File action's lifecycle.

+ 43 - 0
.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: Composer 菜单分节、本地化行与「文件」行
+
+Status: implemented
+
+[English](2026-09-08-composer-menu-sections-and-localized-rows.md) | 中文
+
+## 问题
+
+composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列成 `name description`,全部小写,没有图标也不分组,旁边还有一个单独的回形针按钮用于添加文件。中文界面下各行仍是英文,因为宿主描述符只携带英文文案;只知道命令中文名的用户既搜不到它,也看不出该输入什么。Issue #3567 及其设计稿要求:按使用频次分成两个小节,每行带图标、标题左对齐、说明右对齐,英文标题首字母大写,中文标题与说明在两种语言下都能搜索并同时显示英文命令名,计划与目标的声明用中文填入,以及把「文件」入口放进菜单。
+
+## 决策
+
+`ui-commands` 在 `src/client/presentation.ts` 里拥有菜单的展示:「添加」小节(`file`、`goal`、`plan`、`feedback`)与「指令」小节(`compact`、`permission`、`model`、`export`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。
+
+六个内置宿主命令从客户端取得本地化标题、说明、图标和认领 token。稳定标识选择与输入解析遵循[命令标识决策](../architecture/2026-09-10-command-identities-and-composer-file-action.zh.md)。贡献项携带 `label()`、`description()` 和 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。
+
+菜单选中填入当前语言的认领 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输 token 保留原写法作为认领,因为输入框从它持有的 token 之后读取参数。会话有效目录在所有界面语言下通过同一输入路径解析中英文别名。
+
+「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。
+
+`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
+
+菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
+
+## 考虑过的替代方案
+
+**删除分隔空格时释放命令认领。** 完整命令名仍能标识已选命令。保留认领直到命令名改变,可以在替换参数时保持高亮,也无需依赖输入法生成的空格触发普通按键裁决。共享输入状态机对所有命令 token 使用这个规则,提交失败后的恢复也相同;命令名和界面语言都不选择另一套实现。
+
+**在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。
+
+**在宿主侧本地化说明。** 宿主没有语言设置,目录由所有客户端共享,本地化产品文案属于客户端。
+
+**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。
+
+**在菜单旁保留回形针。** 设计稿与 issue 的验收条件都要求把入口整合进菜单且旧图标不再重复显示;隐藏的文件输入框与拖放门槛保持原位。
+
+**由贡献项拥有内置宿主行的文案。** 宿主包没有客户端一半可供注册,文案只能放在客户端;在已经拥有 `/` source 的包里用一张按名字索引的表,能把设计决策集中在一处。
+
+**只在当前语言下解析 `token` 别名。** 遍历所有词典解析没有成本,还能让在一种语言下持久化的草稿在另一种语言下提交。
+
+## 后果
+
+菜单与本地化展示由客户端负责,宿主负责有效命令定义。新增本地化内置命令需要对应的标识映射、词典条目、图标和菜单位置。会话驱动的浏览器场景与归属模块的 ARIA 预期覆盖菜单。无关的同名定义保留自己的文案,不获得内置图标。命令标识决策负责这一划分和文件动作的生命周期。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md
+2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d
+2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md

@@ -0,0 +1,29 @@
+# Agent Note: Turn duration labels gain an hour unit
+
+Status: implemented
+
+English | [中文](2026-09-09-turn-duration-hour-unit.zh.md)
+
+## Problem
+
+The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour.
+
+## Decision
+
+`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys.
+
+The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format.
+
+## Alternatives considered
+
+**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running.
+
+**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles.
+
+**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change.
+
+**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs.
+
+## Consequences
+
+Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 回合耗时标签增加小时单位
+
+Status: implemented
+
+[English](2026-09-09-turn-duration-hour-unit.md) | 中文
+
+## 问题
+
+Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。
+
+## 决定
+
+`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。
+
+改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。
+
+## 考虑过的替代方案
+
+**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。
+
+**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。
+
+**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。
+
+**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。
+
+## 后果
+
+长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
-2026-09-08-ci-readiness-and-completion.md: 2249df5467189975aca2d73ec56c6cf82ec7b62f
-2026-09-08-ci-readiness-and-completion.zh.md: ccf8e7c16903b66e39bc48e99460e5c5179ab51b
+2026-09-08-ci-readiness-and-completion.md: c72048ef90b987e7d27992b1754736b2ce895093
+2026-09-08-ci-readiness-and-completion.zh.md: 1188d66249b3092416435da2e8496a657ad00677

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md

@@ -14,6 +14,8 @@ The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/acti
 
 A [worker-runtime coverage failure](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932) exhausts the slow-binding fixture's one-second compute allowance. Concurrent native Windows reproductions exceed that allowance before calling the binding. Worker initialization contributes measured active time; the delayed binding contributes idle time.
 
+The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193) reports an SDK subprocess exit beyond a fixture's 200 ms confirmation window and an Inspector Worker startup beyond its ten-second default. Neither the protocol-error routing case nor the Cordis tree projection case measures those latency guarantees.
+
 ## Decision
 
 The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first.
@@ -28,6 +30,8 @@ The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) o
 
 The [worker-runtime binding test](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) allows five seconds of compute for source-worker initialization and delays the binding for 6.5 seconds. Charging that idle delay would still exceed the entire compute allowance. The case retains its 15-second test limit and 30-second wall ceiling, registers Context and reply-timer cleanup, and leaves the hot-loop, decoy-dispatch, wall-ceiling, and abort controls at their existing limits. Production budgets remain unchanged.
 
+The [SDK subagent protocol-error test](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts) uses the provider's normal shutdown and exit grace periods and registers disposal before its assertions. The [Inspector tree tests](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts) pass the active test budget to Worker startup and register cleanup while startup is still pending. A cancelled test cannot receive a late-ready handle; cleanup awaits initialization and closes a successfully started Worker. Failed initialization already terminates the Worker before rejecting. A controlled late-start test verifies cancellation and closure through the real Worker's HTTP endpoint. Production defaults remain unchanged.
+
 ## Alternatives considered
 
 **Larger independent waits.** Rejected where a completion promise already exists. A separate polling deadline continues to compete with the execution lane's budget.

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md

@@ -14,6 +14,8 @@ Status: implemented
 
 一次 [worker runtime coverage 失败](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932)耗尽了慢 binding 夹具的一秒计算额度。原生 Windows 并发复现在调用 binding 前已超过该额度。Worker 初始化会累计所测的活跃时间;延迟的 binding 累计空闲时间。
 
+[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193)报告了 SDK 子进程退出超过测试设置的 200 毫秒确认期限,以及 Inspector Worker 启动超过十秒默认期限。协议错误转发用例和 Cordis 树投影用例都不衡量这些延迟保证。
+
 ## 决策
 
 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。
@@ -28,6 +30,8 @@ Status: implemented
 
 [Worker runtime binding 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)为源码 worker 初始化保留五秒计算额度,并将 binding 延迟设为 6.5 秒。若将该空闲延迟计费,仍会超过整个计算额度。用例保留 15 秒测试期限与 30 秒墙钟上限,登记 Context 和回复定时器的清理,并保持热循环、诱饵 dispatch、墙钟上限及取消控制用例的原有限制。生产预算不变。
 
+[SDK 子 Agent 协议错误测试](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts)使用提供方正常的关闭和退出等待时间,并在断言前登记清理。[Inspector 树测试](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts)将当前测试预算传给 Worker 启动,并在启动尚未完成时登记清理。取消后的测试不会收到随后才就绪的实例;清理等待初始化完成,并关闭成功启动的 Worker。初始化失败时,启动操作会在拒绝前终止 Worker。受控的延迟启动测试通过真实 Worker 的 HTTP 端点验证取消和关闭。生产默认值不变。
+
 ## 考虑过的替代方案
 
 **增大独立等待时限。** 已有完成 Promise 时不采用。独立轮询期限仍会与执行通道的预算竞争。

+ 3 - 2
apps/web/tests/agent-preset-selection.e2e.ts

@@ -320,7 +320,8 @@ describe('web e2e: agent-preset selection', () => {
     await writeComposerDraft(page, composer, '/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })
       .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
-    const onMinimal = await menuOptions(page)
+    // Rows read as `Title Description`; the title is the capitalized command name.
+    const onMinimal = (await menuOptions(page)).map(option => option.toLowerCase())
     expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
     expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
     // Preset-scoped commands follow the switch; the client's own model command
@@ -340,7 +341,7 @@ describe('web e2e: agent-preset selection', () => {
     await writeComposerDraft(page, composer, '/')
     await expect.poll(() => menuOptions(page), { timeout: 15_000 })
       .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
-    const onStandard = await menuOptions(page)
+    const onStandard = (await menuOptions(page)).map(option => option.toLowerCase())
     expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
     expect(onStandard.some(option => option.startsWith('goal'))).toBe(true)
     expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)

+ 1 - 3
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -192,9 +192,7 @@
 - button "Back to bottom":
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -50,9 +50,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":

+ 1 - 3
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -42,9 +42,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":

+ 1 - 3
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -16,9 +16,7 @@
   - img
   - text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -50,9 +50,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -36,9 +36,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -41,9 +41,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -45,9 +45,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/reference-composer/order.expected.md

@@ -15,9 +15,7 @@
   - img
   - text: Session recall Research notes
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -46,9 +46,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -38,9 +38,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -461,9 +461,7 @@
 - button "Back to bottom":
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -33,9 +33,7 @@
 - button "Copy":
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -52,9 +52,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/steer-all/settled.expected.md

@@ -40,9 +40,7 @@
   - text: Ran for {{duration}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 1
apps/web/tests/feedback-command.e2e.ts

@@ -82,7 +82,7 @@ describe('web e2e: /feedback command acknowledgement', () => {
     await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
     expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1)
     await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
-    await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
     const expanded = await captureExpandedTurnProcessAria(

+ 1 - 1
apps/web/tests/goal-command-presentation.e2e.ts

@@ -51,7 +51,7 @@ describe('web e2e: /goal human transcript presentation', () => {
     const input = page.locator('[data-composer-input]').first()
     await input.fill('/go')
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
-    await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' })
+    await menu.getByRole('option', { name: 'Goal Set or view the goal for a long-running task' })
       .waitFor({ timeout: 10_000 })
     await input.press('Tab')
     await expect.poll(() => input.textContent()).toBe('/goal ')

+ 68 - 5
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -78,13 +78,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await writeComposerDraft(page, input, '')
       await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' })
     })
-    const launcher = page.getByRole('button', { name: 'Commands' })
+    const launcher = page.getByRole('button', { name: 'Add files or run commands' })
     await launcher.click()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
     await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
     await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
+    expect(snapshot).toContain('text: Add')
     expect(snapshot).toContain('text: Commands')
     expect(snapshot).not.toContain('text: Skills')
     expect(snapshot).not.toContain('text: Subagents')
@@ -103,7 +104,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     )).toBeLessThan(1)
     await writeComposerDraft(page, input, '/cpt')
     await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
-      'compactCompact older conversation history',
+      'CompactCompact older conversation history',
     ])
     const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
@@ -118,7 +119,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     try {
       await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
       await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-      const launcher = zhPage.getByRole('button', { name: '指令' })
+      const launcher = zhPage.getByRole('button', { name: '添加文件或调用指令' })
       await launcher.click()
       const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
       await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
@@ -132,6 +133,68 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     }
   })
 
+  it.skipIf(MODE === 'record').each([
+    { locale: 'en-US', token: '/goal', row: 'Goal Set or view the goal for a long-running task', hint: 'describe the objective for a long-running task' },
+    { locale: 'en-US', token: '/plan', row: 'Plan Enter or leave plan mode', hint: 'describe your task to generate plan' },
+    { locale: ZH_BROWSER_LOCALE, token: '/目标', row: '目标 goal 设置或查看长期任务目标', hint: '输入目标,智能体将持续执行' },
+    { locale: ZH_BROWSER_LOCALE, token: '/计划', row: '计划 plan 进入或退出计划模式', hint: '描述你的任务以生成计划' },
+  ])('keeps $token claimed across separator edits and hides hints during IME composition', async ({ locale, token, row, hint }) => {
+    const inputPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale })
+    const inputTripwire = watchConsole(inputPage)
+    onTestFailed(() => saveFailureShot(inputPage, `web-e2e-command-input-${locale}-${token.slice(1)}`))
+    try {
+      await inputPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+      await inputPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      const input = inputPage.locator('[data-composer-input]').first()
+      await writeComposerDraft(inputPage, input, '/')
+      await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click()
+      await expect.poll(() => input.textContent()).toBe(`${token} `)
+      await input.press('End')
+      await inputPage.keyboard.insertText('这是任务')
+      await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`)
+      for (let i = 0; i < 5; i++) await input.press('Backspace')
+      const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent()
+      await expect.poll(() => input.textContent()).toBe(token)
+      await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
+      await expect.poll(tokenText).toBe(token)
+      await input.press('Space')
+      await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
+      await expect.poll(() => input.textContent()).toBe(`${token} `)
+      await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token)
+      const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content)
+      await expect.poll(shownHint).toBe(JSON.stringify(hint))
+      const cdp = await inputPage.context().newCDPSession(inputPage)
+      await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
+      await expect.poll(shownHint).toBe('none')
+      await cdp.send('Input.imeSetComposition', { text: 'zh', selectionStart: 2, selectionEnd: 2 })
+      await expect.poll(shownHint).toBe('none')
+      await cdp.send('Input.insertText', { text: '这' })
+      await expect.poll(() => input.textContent()).toBe(`${token} 这`)
+      await expect.poll(shownHint).toBe('none')
+      await input.press('Backspace')
+      await expect.poll(shownHint).toBe(JSON.stringify(hint))
+      await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
+      await expect.poll(shownHint).toBe('none')
+      await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
+      await expect.poll(shownHint).toBe(JSON.stringify(hint))
+      await input.press('Backspace')
+      await input.press('Backspace')
+      await expect.poll(() => input.getAttribute('data-phase')).toBe('plain')
+      await writeComposerDraft(inputPage, input, '')
+      const placeholder = inputPage.locator('[data-composer-placeholder]').first()
+      await expect.poll(() => placeholder.isVisible()).toBe(true)
+      await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
+      await expect.poll(() => placeholder.isVisible()).toBe(false)
+      await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
+      await expect.poll(() => placeholder.isVisible()).toBe(true)
+      await cdp.detach()
+      expect(inputTripwire.pageErrors).toEqual([])
+      expect(inputTripwire.warnings).toEqual([])
+    } finally {
+      await inputPage.close()
+    }
+  })
+
   it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
     const activeScaffold = await launchWebScaffold()
     const activePage = await newEnglishPage(browser)
@@ -141,10 +204,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
       const input = activePage.locator('[data-composer-input]').first()
-      await activePage.getByRole('button', { name: 'Commands' }).click()
+      await activePage.getByRole('button', { name: 'Add files or run commands' }).click()
       const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
       await menu.waitFor({ timeout: 10_000 })
-      await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
+      await menu.getByRole('option', { name: 'Plan Enter or leave plan mode' }).click()
       await expect.poll(() => input.textContent()).toBe('/plan ')
       await input.press('Enter')
       const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })

+ 1 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -335,7 +335,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       const input = page.locator('[data-composer-input]').first()
       const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 })
       await input.fill('/export')
-      await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 })
+      await page.getByRole('option', { name: /^Export/u }).waitFor({ timeout: 10_000 })
       await input.press('Enter')
       const slashDownload = await slashDownloadPromise
       expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename())

+ 1 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -506,7 +506,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
 
       // command/done can arrive before the submit reply releases the composer.
       await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
-      await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true)
+      await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true)
       const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
         .split(SEED_ID).join('{{seededId}}')
         .split(userId).join('{{userId}}')

+ 3 - 0
apps/web/tests/settings-chrome.e2e.ts

@@ -293,6 +293,9 @@ describe('web e2e: settings modal and General preferences', () => {
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const restoredDialog = page.getByRole('dialog', { name: '设置' })
     const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
+    // The boot palette precedes the settings mirror's saved preference.
+    const restoredDarkCube = restoredDialog.getByRole('button', { name: '深色' })
+    await expect.poll(() => restoredDarkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
     await selectTheme(systemCube, 'system')
     await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
     await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), {

+ 2 - 1
apps/web/tests/shipped-composition.e2e.ts

@@ -183,8 +183,9 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
   })
   try {
     expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
+      definitionId: '@deepseek-ai/dsh-command-feedback',
       name: 'feedback',
-      description: 'record feedback about this session',
+      description: 'Record feedback about this session',
       input: { hint: '<text>' },
     })
   } finally {

+ 1 - 1
apps/web/tests/sidebar-scrollbar.e2e.ts

@@ -83,7 +83,7 @@ function measureList(page: Page): Promise<ListMetrics> {
       })
       .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
       .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
-      .map(rule => rule.style.getPropertyValue('background'))
+      .map(rule => rule.style.getPropertyValue('background-color'))
     const style = getComputedStyle(list)
     const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
     const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)

+ 1 - 3
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -26,9 +26,7 @@
 - code: "const first: number = 1 const second = \"two\" let tail"
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current streaming-fence-highlight-test/streaming-fence":

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 42e2e234d737879e5f802c69873028e2b5972545
-config-catalog.zh.md: 4d1384381e396ff4bd630bf146d342076703c9f5
+config-catalog.md: 1325bac3254792ca5fe46f6434a936554e9a8cdc
+config-catalog.zh.md: f04959d941ea4e9787989ac91d1d9e24b86dafa8

+ 3 - 3
docs/config-catalog.md

@@ -1635,7 +1635,7 @@ export interface PresetSpec {
 
 Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
 
-Source: [`packages/interaction/permission-presets/src/index.ts:143`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:144`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 
@@ -1680,7 +1680,7 @@ export interface PlanModeConfig {
 }
 ```
 
-Source: [`packages/plan/plan-mode/src/index.ts:63`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -1921,7 +1921,7 @@ export interface Config {
 export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
 ```
 
-Source: [`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts)
+Source: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-jsonl"></a>
 

+ 3 - 3
docs/config-catalog.zh.md

@@ -1637,7 +1637,7 @@ export interface PresetSpec {
 
 依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
 
-来源:[`packages/interaction/permission-presets/src/index.ts:143`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:144`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 
@@ -1682,7 +1682,7 @@ export interface PlanModeConfig {
 }
 ```
 
-来源:[`packages/plan/plan-mode/src/index.ts:63`](../packages/plan/plan-mode/src/index.ts)
+来源:[`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
@@ -1923,7 +1923,7 @@ export interface Config {
 export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
 ```
 
-来源:[`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts)
+来源:[`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-jsonl"></a>
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: ddda7792a5ddb213dd96c8694b970248b4293fc6
-event-producer-consumer.zh.md: 16f7d4bd297c0928a96a4bd740e623bc4185137b
+event-producer-consumer.md: 9c918756389c71651de5a838ada0ad366379aaa6
+event-producer-consumer.zh.md: c2fdb42032ecf95d084635347fdccedf33a4a643

+ 1 - 1
docs/event-producer-consumer.md

@@ -29,7 +29,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -31,7 +31,7 @@
 | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68
-persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f
+persistence-catalog.md: 7d6c9649628596b04dbe1d75c564a7f5965be940
+persistence-catalog.zh.md: c1736a1f5654f82ea9995b6389630648fc8185eb

+ 4 - 4
docs/persistence-catalog.md

@@ -263,7 +263,7 @@ Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/
 }
 ```
 
-Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts)
+Source: [`packages/interaction/commands/src/types.ts:112`](../packages/interaction/commands/src/types.ts)
 
 <a id="commandrun--log-only"></a>
 
@@ -283,7 +283,7 @@ Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interacti
 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
 ```
 
-Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
+Source: [`packages/interaction/commands/src/types.ts:105`](../packages/interaction/commands/src/types.ts)
 
 ### `compaction/*`
 
@@ -570,7 +570,7 @@ Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/sess
 'permission/preset': { preset: string }
 ```
 
-Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:54`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 
@@ -587,7 +587,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/
 'plan/mode': { active: boolean }
 ```
 
-Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/src/index.ts)
 
 ### `request/*`
 

+ 4 - 4
docs/persistence-catalog.zh.md

@@ -265,7 +265,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts)
+来源:[`packages/interaction/commands/src/types.ts:112`](../packages/interaction/commands/src/types.ts)
 
 <a id="commandrun--log-only"></a>
 
@@ -285,7 +285,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
 ```
 
-来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
+来源:[`packages/interaction/commands/src/types.ts:105`](../packages/interaction/commands/src/types.ts)
 
 ### `compaction/*`
 
@@ -572,7 +572,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'permission/preset': { preset: string }
 ```
 
-来源:[`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:54`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 
@@ -589,7 +589,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'plan/mode': { active: boolean }
 ```
 
-来源:[`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/src/index.ts)
+来源:[`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/src/index.ts)
 
 ### `request/*`
 

+ 2 - 2
docs/subsystems/commands.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/commands.md
-commands.md: 4705048e06796792d8e013450b72035b1f87315b
-commands.zh.md: f3af9f6e81745514efd1e073868292a02f90dca8
+commands.md: dc6ad74da872166fbf584dcab8e1ed21878c243a
+commands.zh.md: 57a60a87f78cd264acdbffa3f7514b4ac287073c

+ 4 - 0
docs/subsystems/commands.md

@@ -33,6 +33,8 @@ interface CommandInputDescriptor {
 ```ts type-equiv
 /** Plugin-owned command registration. */
 interface CommandDefinition {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */
@@ -97,6 +99,8 @@ Adapters receive handler-free immutable descriptors after scope resolution. `par
 ```ts type-equiv
 /** Handler-free immutable command view returned to UI adapters. */
 interface CommandDescriptor {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */

+ 4 - 0
docs/subsystems/commands.zh.md

@@ -33,6 +33,8 @@ interface CommandInputDescriptor {
 ```ts type-equiv
 /** Plugin-owned command registration. */
 interface CommandDefinition {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */
@@ -97,6 +99,8 @@ type CommandResult =
 ```ts type-equiv
 /** Handler-free immutable command view returned to UI adapters. */
 interface CommandDescriptor {
+  /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */
+  readonly definitionId?: CommandDefinitionId
   /** Lowercase command name without the leading slash. */
   readonly name: string
   /** Human-readable summary used in discovery UI. */

+ 4 - 4
packages/client/connection/src/client/fixture.ts

@@ -34,7 +34,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
 // Type-only: the brand constructor is host-side; the fixture casts at its
 // wire-fabrication boundary (the schema layer's one-cast-point posture).
-import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
+import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands/brand'
 import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
 import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
 import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
@@ -2262,9 +2262,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         value: [
           { name: 'compact', description: 'fixture:压缩当前会话上下文' },
           { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
-          { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
-          { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
-          { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
+          { definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
+          { definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
+          { definitionId: brandString<CommandDefinitionId>('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
         ],
       }
     },

+ 11 - 4
packages/client/ui-chat/src/client/chat/message-chrome.ts

@@ -6,7 +6,8 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
 
 /** The elapsed-duration share of the conversation dictionary. */
-export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
+export type RunDurationTranslate =
+  Translate<'duration.seconds' | 'duration.minutes' | 'duration.hours'>
 function pad2(n: number): string {
   return String(n).padStart(2, '0')
 }
@@ -37,14 +38,20 @@ export function msUntilNextLocalMidnight(ms: number): number {
  * Localized elapsed-time label shared by running and settled turn chrome.
  * @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
  * @param t - Translate seat supplying the duration templates.
- * @returns Display string in whole seconds.
+ * @returns Display string in whole seconds; minutes and seconds once the
+ * duration reaches a minute; hours, minutes, and seconds once it reaches an
+ * hour, with the smaller units zero-padded.
  */
 export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
   const total = Math.max(0, Math.floor(ms / 1000))
-  const minutes = Math.floor(total / 60)
+  const hours = Math.floor(total / 3600)
+  const minutes = Math.floor(total / 60) % 60
   const seconds = total % 60
+  if (hours > 0) {
+    return t('duration.hours', { hours, minutes: pad2(minutes), seconds: pad2(seconds) })
+  }
   return minutes > 0
-    ? t('duration.minutes', { minutes, seconds: String(seconds).padStart(2, '0') })
+    ? t('duration.minutes', { minutes, seconds: pad2(seconds) })
     : t('duration.seconds', { seconds })
 }
 

+ 2 - 0
packages/client/ui-chat/src/client/locale.ts

@@ -99,6 +99,7 @@ export const zh = {
   'message.turnTime.ttft': '首 token 用时(TTFT)',
   'duration.seconds': '{seconds}秒',
   'duration.minutes': '{minutes}分{seconds}秒',
+  'duration.hours': '{hours}小时{minutes}分{seconds}秒',
   'command.running': '执行中…',
   'command.failed': '指令失败',
   'command.done': '已完成',
@@ -209,6 +210,7 @@ export const en = {
   'message.turnTime.ttft': 'Time to first token (TTFT)',
   'duration.seconds': '{seconds}s',
   'duration.minutes': '{minutes}m {seconds}s',
+  'duration.hours': '{hours}h {minutes}m {seconds}s',
   'command.running': 'Running…',
   'command.failed': 'Command failed',
   'command.done': 'Completed',

+ 39 - 1
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -24,12 +24,13 @@ import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
 import { ChatView } from '../src/client/chat/ChatView.tsx'
 import { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx'
 import { useTurnDataValue } from '../src/client/chat/use-turn-data.ts'
-import { zh } from '../src/client/locale.ts'
+import { en, zh } from '../src/client/locale.ts'
 import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
 import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
 import {
@@ -539,6 +540,16 @@ describe('Chat node rendering', () => {
     expect(formatRunDuration(-500, t)).toBe('0秒')
     expect(formatRunDuration(15_999, t)).toBe('15秒')
     expect(formatRunDuration(125_000, t)).toBe('2分05秒')
+    // The hour rolls at exactly 3600s, never at 60 displayed minutes.
+    expect(formatRunDuration(3_599_999, t)).toBe('59分59秒')
+    expect(formatRunDuration(3_600_000, t)).toBe('1小时00分00秒')
+    expect(formatRunDuration(3_903_000, t)).toBe('1小时05分03秒')
+    expect(formatRunDuration(7_261_000, t)).toBe('2小时01分01秒')
+  })
+
+  it('formatRunDuration uses the English hour template', () => {
+    const t = makeTranslate(en, commonEn)
+    expect(formatRunDuration(3_903_000, t)).toBe('1h 05m 03s')
   })
 
 })
@@ -1840,6 +1851,22 @@ describe('ChatView', () => {
     expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
   })
 
+  it('the actions-owning assistant footer shows an hour-scale run time', () => {
+    const h = makeHarness({
+      nodes: [
+        user(1, 'hi'),
+        assistant(2, 'mid-turn text', 1, 1),
+        assistant(16, 'final answer', 1, 2),
+        toolResult(18, 'trailing'),
+      ],
+      turnTimings: new Map([[1, { startTime: 1_000, endTime: 3_904_000 }]]),
+      turnEnds: new Map([[1, 20]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent)
+      .toContain('用时 1小时05分03秒')
+  })
+
   it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => {
     const first: AssistantMessageNode = {
       kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
@@ -2166,6 +2193,17 @@ describe('ChatView', () => {
     expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
   })
 
+  it('the running clock reads hours once the turn passes an hour', () => {
+    const startTime = Date.now() - 3_903_000
+    const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
+    const h = makeHarness(
+      { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) },
+      { running: true },
+    )
+    const view = render(<h.ChatView {...h.props} />)
+    expect(view.getByRole('status').textContent).toMatch(/^深度求索中\.\.\.1小时05分0\d秒$/)
+  })
+
   it('hands each ordered root call to the keyed business-node slot', () => {
     const block = toolResult(3, 'a')
     const h = makeHarness({ nodes: [block] })

+ 2 - 2
packages/client/ui-commands/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
-README.md: 67c05bb2cd22f63e386c4646dfb3472d9e01eea1
-README.zh.md: f5701c491bd4127106a32d936f6434c1e572e3e5
+README.md: 147cce99ab24ac3c1a8a111b4b95f4175f89d26b
+README.zh.md: 11f47e1e64915198295ee20ee6e051c413d30dc8

+ 8 - 4
packages/client/ui-commands/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`: a popupSelect spec (`/model`, `/permission`) or an action (`/feedback`), registered as a command or decorating an existing host command while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is its kind, and everything else is `execute`.
+Typing a `/` command opens a registered popup, a client action, a host command's input, or direct execution; a command line is never silently downgraded to a plain prompt. Business packages register popupSelect specs (`/model`, `/permission`) or actions through `ctx.commandUi`, or decorate existing host commands with either kind while preserving their catalog rows and argument claims. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect` or `action`, and everything else is `execute`.
 
 ## Table of Contents
 
@@ -25,11 +25,15 @@ Typing a `/` command in the composer opens the matching surface — a registered
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly.
+Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
 
 ### Kinds and decorations
 
-A contribution is a client-owned command — a host-name collision fails loudly. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first.
+A contribution is a client-owned command; a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, without submitting a message. Business packages own their actions and availability; the composer registers File through this same API. A decoration adds a bare-invocation popup or action to an existing host command while preserving its catalog row, argument claim, and lifecycle logging; it never fires without a matching host row. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles, with prefixes first and no section headings.
+
+### Built-in row faces
+
+First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands.
 
 ### Attachment-carrying submissions
 
@@ -43,7 +47,7 @@ When the composer submits with images or generic files, only a host command decl
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution.
+`src/client/contract.ts` defines contribution and decoration registration. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin.
 
 </details>
 

+ 8 - 4
packages/client/ui-commands/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:popupSelect 贡献项(`/model`、`/permission`)或 action(`/feedback`),既可注册为命令,也可装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的按其种类派发,其余全部是 `execute`。
+键入 `/` 命令会打开已注册的弹窗、运行客户端动作、进入宿主命令的输入或直接执行,命令行不会被静默降级为普通提示词。业务包通过 `ctx.commandUi` 注册 popupSelect(`/model`、`/permission`)或 action,也可用这两种方式装饰既有宿主命令,同时保留其目录行与参数声明。空格与回车根据会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect` 或 `action`,其余是 `execute`。
 
 ## 目录
 
@@ -25,11 +25,15 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。
+与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
 
 ### 种类与装饰
 
-贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。
+贡献项是客户端自有命令,与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用消费触发 token 后运行回调,不提交消息。业务包负责自己的动作及可用性,输入框通过同一 API 注册「文件」。装饰为已有宿主命令添加裸调用弹窗或动作,并保留其目录行、参数认领与生命周期记录;没有匹配的宿主行时不触发。菜单查询按顺序、不区分大小写地模糊匹配命令名与标题的子序列,前缀优先,不显示小节标题。
+
+### 内置行的展示面
+
+内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。
 
 ### 带附件提交
 
@@ -43,7 +47,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
 <details>
 <summary>实现细节——点击展开</summary>
 
-`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。
+`src/client/contract.ts` 定义贡献项和装饰的注册接口。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。
 
 </details>
 

+ 11 - 5
packages/client/ui-commands/src/client/contract.ts

@@ -3,8 +3,10 @@
  * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages
  * consume `register` alone.
  */
+import type { ComponentType } from 'react'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 
 /** Copy for an option that must be acknowledged before onSelect can run. */
 export interface SelectConfirmation {
@@ -39,8 +41,7 @@ export interface PopupSelectSpec {
 
 /**
  * Business registration for the action command kind: a bare invocation
- * consumes the trigger token and runs one client-side callback (the Feedback
- * row opens the feedback dialog). It submits nothing, so an
+ * consumes the trigger token and runs one client-side callback. It submits nothing, so an
  * attachment-carrying draft never refuses it.
  */
 export interface ActionSpec {
@@ -59,13 +60,18 @@ export type CommandUiSpec = PopupSelectSpec | ActionSpec
  * One client-owned command contribution: a slash-menu entry whose behavior
  * lives entirely on the client (no host descriptor). Merged with the host
  * catalog by name — a collision with a host command fails loud at candidate
- * synthesis, never shadows.
+ * synthesis, never shadows. Row copy is read on every candidate pass, so a
+ * locale change reaches the next menu open without re-registration.
  */
 export interface CommandContribution {
   /** Command name without the leading slash (unique across contributions). */
   readonly name: string
-  /** Resolve the localized menu row description when candidates are requested. */
-  readonly description: () => string
+  /** Localized menu row title; the name itself when absent. */
+  label?(): string
+  /** Localized menu row description; the row shows none when absent. */
+  description?(): string
+  /** Menu row glyph from the shared icon set. */
+  readonly icon?: ComponentType<IconProps>
   /** Capability filter, called with a fresh projection per candidate pass. */
   available(session: ClientSessionContext): boolean
   /** The command's UI behavior. */

+ 4 - 3
packages/client/ui-commands/src/client/directory.ts

@@ -7,6 +7,7 @@
  */
 import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { resolveCommand } from './resolution.ts'
 
 export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
 
@@ -46,15 +47,15 @@ export class CommandDirectory {
   }
 
   /**
-   * Synchronous exact-name lookup over one session's hot snapshot.
+   * Synchronous command lookup over one Session's ready catalog; exact names precede localized aliases.
    * @param sessionId - session key.
-   * @param name - command name without the leading slash.
+   * @param name - typed command spelling without the leading slash.
    * @returns the descriptor, or undefined when absent or the entry is not ready.
    */
   resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined {
     const entry = this.entries.get(sessionId)
     if (entry === undefined || entry.state !== 'ready') return undefined
-    return entry.commands.find(c => c.name === name)
+    return resolveCommand(name, entry.commands)
   }
 
   /** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */

+ 2 - 3
packages/client/ui-commands/src/client/index.ts

@@ -40,7 +40,7 @@ declare module '@deepseek-ai/cordis' {
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** The popupSelect shell's copy. */
+    /** The menu rows' and the popupSelect shell's copy. */
     command: CommandKey
   }
 }
@@ -52,8 +52,7 @@ const NS = 'command'
 export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale']
 
 /**
- * Client plugin body: mount the service, then register the popupSelect shell
- * into the input overlay once its declarer is up.
+ * Mount the command service and its per-session popupSelect overlay.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {

+ 42 - 9
packages/client/ui-commands/src/client/locales.ts

@@ -1,13 +1,32 @@
-/** `command` namespace dictionaries (the popupSelect shell's copy). */
+/**
+ * `command` namespace dictionaries: the composer menu's section headings,
+ * the client face (title, description, claim token) of the built-in Host
+ * commands whose catalog descriptors carry English text only, and the
+ * popupSelect shell's copy.
+ */
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
-  'description.compact': '压缩以上对话内容',
-  'description.export': '将当前会话内容导出为 ZIP',
-  'description.feedback': '发送关于当前会话的反馈',
+  'section.add': '添加',
+  'section.commands': '指令',
+  'label.goal': '目标',
+  'label.plan': '计划',
+  'label.feedback': '反馈',
+  'label.compact': '压缩',
+  'label.permission': '权限',
+  'label.export': '下载日志',
   'description.goal': '设置或查看长期任务目标',
-  'description.permission': '切换权限预设(沙箱模式与审批策略)',
   'description.plan': '进入或退出计划模式',
+  'description.feedback': '发送关于当前会话的反馈',
+  'description.compact': '压缩以上对话内容',
+  'description.permission': '切换权限预设(沙箱模式与审批策略)',
+  'description.export': '将当前会话内容导出为 ZIP',
+  'token.goal': '目标',
+  'token.plan': '计划',
+  'token.feedback': '反馈',
+  'token.compact': '压缩',
+  'token.permission': '权限',
+  'token.export': '导出',
   'search.placeholder': '搜索…',
   'search.aria': '筛选选项',
   'status.loading': '正在加载选项…',
@@ -23,12 +42,26 @@ export type CommandKey = keyof typeof zh
 
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
+  'section.add': 'Add',
+  'section.commands': 'Commands',
+  'label.goal': 'Goal',
+  'label.plan': 'Plan',
+  'label.feedback': 'Feedback',
+  'label.compact': 'Compact',
+  'label.permission': 'Permission',
+  'label.export': 'Export',
+  'description.goal': 'Set or view the goal for a long-running task',
+  'description.plan': 'Enter or leave plan mode',
+  'description.feedback': 'Record feedback about this session',
   'description.compact': 'Compact older conversation history',
-  'description.export': 'Download this Session log as a ZIP archive',
-  'description.feedback': 'record feedback about this session',
-  'description.goal': 'set or view the goal for a long-running task',
   'description.permission': 'Switch the permission preset (sandbox mode + approval policy)',
-  'description.plan': 'Enter or leave plan mode',
+  'description.export': 'Download this Session log as a ZIP archive',
+  'token.goal': 'goal',
+  'token.plan': 'plan',
+  'token.feedback': 'feedback',
+  'token.compact': 'compact',
+  'token.permission': 'permission',
+  'token.export': 'export',
   'search.placeholder': 'Search…',
   'search.aria': 'Filter options',
   'status.loading': 'Loading options…',

+ 86 - 0
packages/client/ui-commands/src/client/presentation.ts

@@ -0,0 +1,86 @@
+/** Composer menu grouping, localized labels, descriptions, and icons. */
+import type { ComponentType } from 'react'
+import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import {
+  IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPlanOutline14, IconSendOutline16,
+  IconShieldOutline16,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
+import type { CommandKey } from './locales.ts'
+import { builtinCommandName } from './resolution.ts'
+import type { BuiltinCommandName } from './resolution.ts'
+
+/** The menu's two sections. */
+export type MenuSection = 'add' | 'commands'
+
+/** Row names per section, highest usage first; rows outside both lists close the Commands section in catalog order. */
+const SECTION_ROWS: Readonly<Record<MenuSection, readonly string[]>> = {
+  add: ['file', 'goal', 'plan', 'feedback'],
+  commands: ['compact', 'permission', 'model', 'export'],
+}
+
+/** The dictionary keys and glyph of one built-in Host command's client face. */
+interface HostFace {
+  readonly label: CommandKey
+  readonly description: CommandKey
+  readonly icon: ComponentType<IconProps>
+}
+
+/** One built-in Host command's face, keyed by its dictionary entries. */
+function hostFace(name: BuiltinCommandName, icon: ComponentType<IconProps>): readonly [BuiltinCommandName, HostFace] {
+  return [name, {
+    label: `label.${name}`,
+    description: `description.${name}`,
+    icon,
+  }]
+}
+
+/** Built-in Host commands whose client face this package owns. */
+const HOST_FACES: ReadonlyMap<BuiltinCommandName, HostFace> = new Map([
+  hostFace('goal', IconGoalOutline16),
+  hostFace('plan', IconPlanOutline14),
+  hostFace('feedback', IconSendOutline16),
+  hostFace('compact', IconCompactOutline16),
+  hostFace('permission', IconShieldOutline16),
+  hostFace('export', IconDownloadOutline16),
+])
+
+/**
+ * The localized menu face of a catalog row.
+ * @param descriptor - effective Host command descriptor.
+ * @param t - the `command` namespace translator.
+ * @returns title, description, and glyph for a built-in command; undefined
+ * for any other row, which keeps its catalog description.
+ */
+export function builtinRowFace(
+  descriptor: CommandDescriptor,
+  t: TranslateNS<'command'>,
+): Pick<InputTriggerCandidate, 'label' | 'description' | 'icon'> | undefined {
+  const name = builtinCommandName(descriptor)
+  const face = name === undefined ? undefined : HOST_FACES.get(name)
+  return face === undefined ? undefined : { label: t(face.label), description: t(face.description), icon: face.icon }
+}
+
+/**
+ * Arrange the empty-query menu: the Add section, then the Commands section,
+ * each in usage order, with unlisted rows closing Commands in their input
+ * order; each row carries its section heading.
+ * @param rows - the visible candidates in catalog-then-contribution order.
+ * @param t - the `command` namespace translator.
+ * @returns the sectioned rows.
+ */
+export function sectionRows(rows: readonly InputTriggerCandidate[], t: TranslateNS<'command'>): readonly InputTriggerCandidate[] {
+  const listed = new Set([...SECTION_ROWS.add, ...SECTION_ROWS.commands])
+  const byName = new Map(rows.map(row => [row.name, row]))
+  const pick = (names: readonly string[]): InputTriggerCandidate[] =>
+    names.flatMap((name) => {
+      const row = byName.get(name)
+      return row === undefined ? [] : [row]
+    })
+  const add = pick(SECTION_ROWS.add).map(row => ({ ...row, section: t('section.add') }))
+  const commands = [...pick(SECTION_ROWS.commands), ...rows.filter(row => !listed.has(row.name))]
+    .map(row => ({ ...row, section: t('section.commands') }))
+  return [...add, ...commands]
+}

+ 59 - 0
packages/client/ui-commands/src/client/resolution.ts

@@ -0,0 +1,59 @@
+/** Command identity and localized input spelling over the effective Host catalog. */
+import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import { en, zh } from './locales.ts'
+
+const BUILTINS = {
+  goal: '@deepseek-ai/dsh-command-goal',
+  plan: '@deepseek-ai/dsh-plan-mode',
+  feedback: '@deepseek-ai/dsh-command-feedback',
+  compact: '@deepseek-ai/dsh-command-compact',
+  permission: '@deepseek-ai/dsh-permission-presets',
+  export: '@deepseek-ai/dsh-session-log-export',
+} as const
+
+/** Names whose first-party definitions have localized client presentation. */
+export type BuiltinCommandName = keyof typeof BUILTINS
+
+/**
+ * Identify a first-party definition without interpreting its display copy.
+ * @param descriptor - effective Host descriptor after scoped shadowing.
+ * @returns its first-party name, or undefined for another definition.
+ */
+export function builtinCommandName(descriptor: CommandDescriptor): BuiltinCommandName | undefined {
+  return (Object.keys(BUILTINS) as BuiltinCommandName[])
+    .find(name => descriptor.definitionId === BUILTINS[name])
+}
+
+/**
+ * Select the input spelling for a menu-picked command.
+ * @param descriptor - effective Host descriptor.
+ * @param t - command-namespace translator.
+ * @returns localized spelling for a known definition, otherwise its registered name.
+ */
+export function claimToken(descriptor: CommandDescriptor, t: TranslateNS<'command'>): string {
+  const name = builtinCommandName(descriptor)
+  return name === undefined ? descriptor.name : t(`token.${name}`)
+}
+
+const TOKEN_ALIASES = new Map(
+  (Object.keys(BUILTINS) as BuiltinCommandName[]).flatMap(name =>
+    [zh[`token.${name}`], en[`token.${name}`]].map(token => [token, name] as const)),
+)
+
+/**
+ * Resolve typed spelling against the current Session's effective definitions.
+ * @param token - typed name without its leading slash.
+ * @param descriptors - effective descriptors in the Session's ready catalog.
+ * @returns the matching descriptor; aliases never select an unrelated scoped override.
+ */
+export function resolveCommand(
+  token: string,
+  descriptors: readonly CommandDescriptor[],
+): CommandDescriptor | undefined {
+  const exact = descriptors.find(descriptor => descriptor.name === token)
+  if (exact !== undefined) return exact
+  const name = TOKEN_ALIASES.get(token)
+  if (name === undefined) return undefined
+  return descriptors.find(descriptor => descriptor.definitionId === BUILTINS[name])
+}

+ 51 - 45
packages/client/ui-commands/src/client/service.ts

@@ -2,10 +2,12 @@
  * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the
  * session-keyed directory, the client-contribution registry, and the
  * per-session popupSelect controllers. Candidate synthesis merges the host
- * catalog with contributions by availability, then position filtering and
- * the `/` menu's shared name ranking (ui-primitives `rankByName`); a
- * host/contribution name collision fails loud. Every execute
- * addresses the session's agent by sessionId — sessions are always
+ * catalog with contributions by availability, gives built-in Host rows their
+ * localized face (presentation.ts), then position-filters; an empty query
+ * lists the Add and Commands sections in usage order, a typed query ranks
+ * every row by the `/` menu's shared name-and-label ranking (ui-primitives
+ * `rankByName`). A host/contribution name collision fails loud. Every
+ * execute addresses the session's agent by sessionId — sessions are always
  * agent-backed.
  */
 import { Service } from '@deepseek-ai/cordis'
@@ -26,8 +28,9 @@ import type {
 import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts'
 import type { CommandDescriptor } from './directory.ts'
 import { CommandDirectory } from './directory.ts'
-import { en, type CommandKey } from './locales.ts'
 import { PopupSelectController } from './popup.ts'
+import { builtinRowFace, sectionRows } from './presentation.ts'
+import { claimToken } from './resolution.ts'
 import type { TokenSegment } from './popup.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -59,16 +62,6 @@ interface LiveState {
   readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
 }
 
-/** Locale keys for the canonical first-party Host command descriptions. */
-const HOST_DESCRIPTION_KEYS = new Map<string, CommandKey>([
-  ['compact', 'description.compact'],
-  ['export', 'description.export'],
-  ['feedback', 'description.feedback'],
-  ['goal', 'description.goal'],
-  ['permission', 'description.permission'],
-  ['plan', 'description.plan'],
-])
-
 /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
 export class CommandUiRuntime extends Service implements CommandUiContract {
   static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands']
@@ -196,7 +189,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     }
   }
 
-  /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */
+  /**
+   * Menu candidates: host catalog + contribution availability, built-in rows
+   * localized, then position filtering; sections for an empty query, the
+   * shared name-and-label ranking for a typed one.
+   */
   private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]> {
     const list = await this.directory.ensureReady(session.sessionId, req.signal)
     const rows: InputTriggerCandidate[] = []
@@ -205,7 +202,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       seen.add(c.name)
       rows.push({
         name: c.name,
-        description: this.hostDescription(c),
+        ...(builtinRowFace(c, this.t) ?? { description: c.description }),
         ...(c.input !== undefined ? { hint: c.input.hint } : {}),
       })
     }
@@ -214,18 +211,15 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       if (seen.has(contribution.name)) {
         throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`)
       }
-      rows.push({ name: contribution.name, description: contribution.description() })
+      rows.push({
+        name: contribution.name,
+        ...(contribution.label === undefined ? {} : { label: contribution.label() }),
+        ...(contribution.description === undefined ? {} : { description: contribution.description() }),
+        ...(contribution.icon === undefined ? {} : { icon: contribution.icon }),
+      })
     }
-    return rankByName(
-      rows.filter(c => req.position === 'leading' || c.hint === undefined),
-      req.query,
-    )
-  }
-
-  /** Translate exact built-in Host copy while preserving scoped or third-party descriptors verbatim. */
-  private hostDescription(command: CommandDescriptor): string {
-    const key = HOST_DESCRIPTION_KEYS.get(command.name)
-    return key !== undefined && command.description === en[key] ? this.t(key) : command.description
+    const visible = rows.filter(c => req.position === 'leading' || c.hint === undefined)
+    return req.query === '' ? sectionRows(visible, this.t) : rankByName(visible, req.query)
   }
 
   /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */
@@ -246,7 +240,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
-    if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
+    if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc, this.t)) }
     // Menu-pick execute consumes the trigger span before the detached run
     // (scoped event; the input owns the CAS guard).
     this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
@@ -257,11 +251,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
   /** Decision table, space column: hot-key sync check; only host leadingInput claims. */
   private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
     if (!token.startsWith('/')) return undefined
-    const name = token.slice(1)
-    if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space
-    const desc = this.directory.resolve(session.sessionId, name)
+    if (this.live.contributions.has(token.slice(1))) return undefined // popup and action kinds never claim on space
+    const desc = this.directory.resolve(session.sessionId, token.slice(1))
     if (desc === undefined || desc.input === undefined) return undefined
-    return { claim: this.leadingClaim(desc, session) }
+    return { claim: this.leadingClaim(desc, session, token.slice(1)) }
   }
 
   /**
@@ -276,6 +269,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * refusal so the machine surfaces one composer notice and the draft and
    * attachments stay in place; nothing executes and nothing is dropped. An
    * action submits nothing and runs regardless.
+   *
+   * A typed token is resolved through the localized claim tokens, so a line
+   * written as `/计划` reaches the `plan` descriptor and executes as `/plan`.
    */
   private async matchEnter(
     session: ClientSessionContext,
@@ -288,21 +284,23 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     const ws = trimmed.search(/\s/)
     const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
     const bare = ws === -1
-    const name = token.slice(1)
-    if (name === '') return undefined
+    const typedName = token.slice(1)
+    if (typedName === '') return undefined
     const refuseAttachments = (): never => {
-      throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
+      throw new Error(this.t('notice.attachmentsUnsupported', { command: typedName }))
     }
-    const contribution = this.live.contributions.get(name)
+    const contribution = this.live.contributions.get(typedName)
     if (contribution !== undefined && contribution.available(session)) {
       if (!bare) return undefined
       if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments()
-      this.invoke(name, contribution.ui, session, { via: 'enter', token })
+      this.invoke(typedName, contribution.ui, session, { via: 'enter', token })
       return 'handled'
     }
     await this.directory.ensureReady(session.sessionId, signal)
-    const desc = this.directory.resolve(session.sessionId, name)
+    const desc = this.directory.resolve(session.sessionId, typedName)
     if (desc === undefined) return undefined
+    const name = desc.name
+    const canonical = `/${name}${trimmed.slice(token.length)}`
     // Bare enter on a decorated host command opens its popup; an argued line
     // never consults the decoration (the claim/detached paths below own it).
     if (bare) {
@@ -315,12 +313,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     }
     if (desc.input !== undefined) {
       if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments()
-      return { claim: this.leadingClaim(desc, session) }
+      return { claim: this.leadingClaim(desc, session, token.slice(1)) }
     }
     if (!bare) return undefined
     if (envelope.attachments > 0) refuseAttachments()
     this.consumeVia(session.sessionId, { via: 'enter', token })
-    this.runDetached(desc, session, trimmed)
+    this.runDetached(desc, session, canonical)
     return 'handled'
   }
 
@@ -344,14 +342,22 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     this.popupFor(actx).open(name, ui, session, segment)
   }
 
-  /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
-  private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
-    const token = `/${desc.name} `
+  /**
+   * Build the leadingInput claim. The composer keeps the claimed token in
+   * the draft and reads the arguments after it, so the token is the spelling
+   * the draft will carry: the locale's token for a menu pick, the typed
+   * spelling for Space and Enter. The command.execute submit transaction
+   * always sends the catalog name.
+   */
+  private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext, shown: string): CommandClaim {
+    const token = `/${shown} `
+    const line = `/${desc.name} `
     return {
+      name: desc.name,
       token,
       ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
       ...(desc.input?.attachments === true ? { attachments: true } : {}),
-      submit: (args, _actx, attachments) => this.execute(session, token + args, attachments),
+      submit: (args, _actx, attachments) => this.execute(session, line + args, attachments),
     }
   }
 

+ 14 - 2
packages/client/ui-commands/tests/browser-plugin.client.spec.ts

@@ -7,7 +7,7 @@
  * (HMR safety), and the service satisfies the frozen CommandUiContract.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished } from 'vitest'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -32,8 +32,11 @@ async function bench() {
   ctx.provide('sessions', {
     scope: (id: SessionId) => scopes.get(id),
     scopeOf: (c: Context) => scopeOf(c),
+    subagentAddress: (id: SessionId) => id === sid('child')
+      ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
+      : undefined,
   })
-  const commandsRemote = { list: () => Promise.resolve([]) }
+  const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) }
   // The service subscribes its cache-invalidation events on construction, so
   // the Remote face needs `$on` even where this spec dispatches none.
   ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
@@ -73,6 +76,15 @@ describe('apply', () => {
     expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
   })
 
+  it('provides no File action without its composer owner', async () => {
+    const { fiber, sources } = await bench()
+    onTestFinished(() => fiber.dispose())
+    const source = sources.get('/ command')!
+    const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal }
+    expect(await source.candidates({ sessionId: sid('s1') }, req)).toEqual([])
+    expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([])
+  })
+
   it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
     const { ctx, slots, mint } = await bench()
     const command = ctx.get('commandUi') as CommandUiRuntime

+ 23 - 0
packages/client/ui-commands/tests/directory.client.spec.ts

@@ -7,6 +7,7 @@
  */
 import { describe, expect, it } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandDirectory } from '../src/client/directory.ts'
 
@@ -52,6 +53,28 @@ function bench() {
 }
 
 describe('status and resolve (per key)', () => {
+  it('resolves bilingual aliases by definition identity and retains exact-name priority', async () => {
+    const goal = {
+      definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'),
+      name: 'objective',
+      description: 'Reworded description.',
+    }
+    const exact = { name: 'goal', description: 'independent exact-name entry' }
+    const { dir, pull } = bench()
+    const refreshed = dir.refresh(S1)
+    pull(S1, 0).resolve([goal])
+    await refreshed
+    expect(dir.resolve(S1, '目标')).toEqual(goal)
+    expect(dir.resolve(S1, 'goal')).toEqual(goal)
+    expect(dir.resolve(S1, '计划')).toBeUndefined()
+    expect(dir.resolve(S1, 'unregistered')).toBeUndefined()
+    const next = dir.refresh(S1)
+    pull(S1, 1).resolve([goal, exact])
+    await next
+    expect(dir.resolve(S1, 'goal')).toEqual(exact)
+    expect(dir.resolve(S1, '目标')).toEqual(goal)
+  })
+
   it('starts cold and resolves nothing', () => {
     const { dir } = bench()
     expect(dir.status(S1)).toBe('cold')

+ 171 - 15
packages/client/ui-commands/tests/service.client.spec.ts

@@ -8,15 +8,18 @@
  * lifecycle, and the directory invalidation event subscriptions.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
+import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { IconGoalOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandUiRuntime } from '../src/client/service.ts'
+import { en, zh, type CommandKey } from '../src/client/locales.ts'
 
 const sid = (k: string): SessionId => k as SessionId
 
@@ -226,7 +229,7 @@ describe('candidates', () => {
     const { source, listCalls } = await bench()
     const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
     expect(listCalls).toEqual([{ sessionId: sid('s2') }])
-    expect(names).toEqual(['plan', 'goal', 'attach'])
+    expect(names).toEqual(['goal', 'plan', 'attach'])
   })
 
   it('hides leadingInput commands at inline position', async () => {
@@ -240,7 +243,7 @@ describe('candidates', () => {
     const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
     command.register(themeContribution({ available }))
     const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
-    expect(s1Names).toEqual(['plan', 'goal', 'theme'])
+    expect(s1Names).toEqual(['goal', 'plan', 'theme'])
     expect(available).toHaveBeenLastCalledWith(proj('s1'))
     const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
     expect(s2Names).not.toContain('theme')
@@ -256,7 +259,7 @@ describe('candidates', () => {
   it('localizes canonical built-in and contribution descriptions on every candidate request', async () => {
     let locale = 'zh'
     const commands: CommandDescriptor[] = [
-      { name: 'compact', description: 'Compact older conversation history' },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' },
       { name: 'goal', description: 'scoped goal override' },
       { name: 'custom', description: 'plugin-authored copy' },
     ]
@@ -266,19 +269,21 @@ describe('candidates', () => {
     })
     command.register(themeContribution({ description: () => `${locale}:theme` }))
 
-    await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([
-      { name: 'compact', description: 'zh:command:description.compact' },
-      { name: 'goal', description: 'scoped goal override' },
-      { name: 'custom', description: 'plugin-authored copy' },
-      { name: 'theme', description: 'zh:theme' },
+    // Rows read as [name, label, description]; the empty query orders by section.
+    const faces = async () => (await source.candidates(proj('s1'), req(''))).map(c => [c.name, c.label, c.description])
+    await expect(faces()).resolves.toEqual([
+      ['goal', undefined, 'scoped goal override'],
+      ['compact', 'zh:command:label.compact', 'zh:command:description.compact'],
+      ['custom', undefined, 'plugin-authored copy'],
+      ['theme', undefined, 'zh:theme'],
     ])
 
     locale = 'en'
-    await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([
-      { name: 'compact', description: 'en:command:description.compact' },
-      { name: 'goal', description: 'scoped goal override' },
-      { name: 'custom', description: 'plugin-authored copy' },
-      { name: 'theme', description: 'en:theme' },
+    await expect(faces()).resolves.toEqual([
+      ['goal', undefined, 'scoped goal override'],
+      ['compact', 'en:command:label.compact', 'en:command:description.compact'],
+      ['custom', undefined, 'plugin-authored copy'],
+      ['theme', undefined, 'en:theme'],
     ])
   })
 
@@ -288,6 +293,157 @@ describe('candidates', () => {
     await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
   })
 
+  describe('menu presentation (design doc for #3567)', () => {
+    /** First-party definitions plus an unrelated command, in Host registration order. */
+    const SHIPPED: CommandDescriptor[] = [
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-session-log-export'), name: 'export', description: 'Download this Session log as a ZIP archive' },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'), name: 'feedback', description: 'Record feedback about this session', input: { hint: '<text>' } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
+      { definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
+      { name: 'deploy', description: 'third-party command' },
+    ]
+    const Glyph = () => null
+    const fileContribution = (run = vi.fn()): CommandContribution => ({
+      name: 'file',
+      label: () => 'command:label.file',
+      icon: Glyph,
+      available: () => true,
+      ui: { kind: 'action', run },
+    })
+    const modelContribution = (): CommandContribution => ({
+      name: 'model',
+      label: () => '模型',
+      description: () => '选择本会话使用的模型',
+      icon: Glyph,
+      available: () => true,
+      ui: themeUi(),
+    })
+
+    it('an empty query lists Add then Commands in usage order; built-in rows carry their localized face, other rows their own text', async () => {
+      const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
+      command.register(modelContribution())
+      command.register(fileContribution())
+      const rows = await source.candidates(proj('s1'), req(''))
+      expect(rows.map(row => row.name)).toEqual([
+        'file', 'goal', 'plan', 'feedback', 'compact', 'permission', 'model', 'export', 'deploy',
+      ])
+      expect(rows.map(row => row.section)).toEqual([
+        ...Array<string>(4).fill('command:section.add'),
+        ...Array<string>(5).fill('command:section.commands'),
+      ])
+      expect(rows[1]).toEqual({
+        name: 'goal',
+        label: 'command:label.goal',
+        description: 'command:description.goal',
+        icon: IconGoalOutline16,
+        hint: '<objective>',
+        section: 'command:section.add',
+      })
+      expect(rows[0]).toEqual({ name: 'file', label: 'command:label.file', icon: Glyph, section: 'command:section.add' })
+      expect(rows[6]).toMatchObject({ name: 'model', label: '模型', description: '选择本会话使用的模型', icon: Glyph })
+      // A third-party command keeps its catalog text and gets no glyph.
+      expect(rows[8]).toEqual({ name: 'deploy', description: 'third-party command', section: 'command:section.commands' })
+    })
+
+    it('a same-name override keeps its own presentation even when it copies the first-party description', async () => {
+      const commands: CommandDescriptor[] = [{ name: 'goal', description: en['description.goal'], input: { hint: 'x' } }]
+      const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
+      const [row] = await source.candidates(proj('s1'), req(''))
+      expect(row).toEqual({ name: 'goal', description: en['description.goal'], hint: 'x', section: 'command:section.add' })
+      expect(source.matchSpace!(proj('s1'), '/目标')).toBeUndefined()
+      expect(await source.matchEnter!(proj('s1'), '/目标 x', new AbortController().signal, { attachments: 0 })).toBeUndefined()
+      expect(source.matchSpace!(proj('s1'), '/goal')).toHaveProperty('claim.name', 'goal')
+    })
+
+    it.each([['en', en], ['zh', zh]] as const)('description edits preserve menu claims and bilingual parsing under %s', async (_locale, dictionary) => {
+      const commands = SHIPPED.map(command => ({ ...command, description: command.description + '.' }))
+      const { fiber, source, warm } = await bench({
+        commands: () => Promise.resolve({ commands }),
+        translate: (_namespace, key) => dictionary[key as CommandKey],
+      })
+      onTestFinished(() => fiber.dispose())
+      await warm(proj('s1'))
+      const rows = await source.candidates(proj('s1'), req(''))
+      for (const name of ['goal', 'plan', 'feedback'] as const) {
+        expect(rows.find(row => row.name === name)?.label).toBe(dictionary[`label.${name}`])
+        const picked = menuPick(source, name, proj('s1'))
+        expect(picked).toHaveProperty('claim.token', `/${dictionary[`token.${name}`]} `)
+        for (const spelling of [en[`token.${name}`], zh[`token.${name}`]]) {
+          expect(source.matchSpace!(proj('s1'), `/${spelling}`)).toHaveProperty('claim.name', name)
+          expect(await source.matchEnter!(proj('s1'), `/${spelling} text`, new AbortController().signal, { attachments: 0 }))
+            .toHaveProperty('claim.token', `/${spelling} `)
+        }
+      }
+    })
+
+    it('a typed query ranks rows flat by name or label and drops the section headings', async () => {
+      const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
+      command.register(modelContribution())
+      const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
+      await expect(names('模型')).resolves.toEqual(['model'])
+      await expect(names('label.goal')).resolves.toEqual(['goal'])
+      await expect(names('ex')).resolves.toEqual(['export'])
+      // Prefix hits lead; the empty-query section order no longer applies.
+      await expect(names('pl')).resolves.toEqual(['plan', 'deploy'])
+      const rows = await source.candidates(proj('s1'), req('pl'))
+      expect(rows.every(row => row.section === undefined)).toBe(true)
+    })
+
+    it('an action contribution: the menu pick consumes the span and runs it; bare enter runs it even with attachments; an argued line misses', async () => {
+      const run = vi.fn()
+      const { command, source, mint, warm } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
+      command.register(fileContribution(run))
+      const scope = mint('s1')
+      const consumes: ConsumeTokenRequest[] = []
+      scope.ctx.on('slash/input-consume-token', (r) => { consumes.push(r); return true })
+      await warm(proj('s1'))
+      expect(menuPick(source, 'file', proj('s1'))).toBe('handled')
+      expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
+      expect(run).toHaveBeenCalledExactlyOnceWith(proj('s1'))
+      expect(await source.matchEnter!(proj('s1'), '/file', new AbortController().signal, { attachments: 2 })).toBe('handled')
+      expect(consumes[1]).toEqual({ guard: { kind: 'bare-token', token: '/file' } })
+      expect(run).toHaveBeenCalledTimes(2)
+      expect(await source.matchEnter!(proj('s1'), '/file now', new AbortController().signal, { attachments: 0 })).toBeUndefined()
+      expect(source.matchSpace!(proj('s1'), '/file')).toBeUndefined()
+    })
+
+    it('a built-in claim shows the localized token and submits the catalog name', async () => {
+      const { source, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
+      await warm(proj('s1'))
+      const outcome = menuPick(source, 'plan', proj('s1'))
+      if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the claim')
+      expect(outcome.claim.token).toBe('/command:token.plan ')
+      await outcome.claim.submit('do x', new Context(), [])
+      expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan do x', images: [] }])
+    })
+
+    it('a typed localized token resolves to the built-in command on space and enter; the Host line carries the catalog name', async () => {
+      const { source, mint, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
+      mint('s1')
+      await warm(proj('s1'))
+      const space = source.matchSpace!(proj('s1'), '/计划')
+      if (space === undefined || space === 'handled' || !('claim' in space)) throw new Error('expected the plan claim')
+      expect(space.claim.hint).toBe('[off|message]')
+      // The claim keeps the typed spelling (the draft carries it and the
+      // arguments are read after it); the submission sends the catalog name.
+      expect(space.claim.token).toBe('/计划 ')
+      expect(space.claim.name).toBe('plan')
+      const enter = await source.matchEnter!(proj('s1'), '/目标 ship it', new AbortController().signal, { attachments: 0 })
+      if (enter === undefined || enter === 'handled' || !('claim' in enter)) throw new Error('expected the goal claim')
+      expect(enter.claim.attachments).toBe(true)
+      expect(enter.claim.token).toBe('/目标 ')
+      await enter.claim.submit('ship it', new Context(), [])
+      expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }])
+      const typed = await source.matchEnter!(proj('s1'), '/plan now', new AbortController().signal, { attachments: 0 })
+      if (typed === undefined || typed === 'handled' || !('claim' in typed)) throw new Error('expected the plan claim')
+      expect(typed.claim.token).toBe('/plan ')
+      executeCalls.length = 0
+      expect(await source.matchEnter!(proj('s1'), '/压缩', new AbortController().signal, { attachments: 0 })).toBe('handled')
+      await vi.waitFor(() => { expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/compact', images: [] }]) })
+    })
+  })
 })
 
 describe('decorations (bare-invocation UI on host commands)', () => {
@@ -302,7 +458,7 @@ describe('decorations (bare-invocation UI on host commands)', () => {
     const { command, source } = await bench()
     command.decorate(goalDecoration())
     const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
-    expect(names).toEqual(['plan', 'goal'])
+    expect(names).toEqual(['goal', 'plan'])
   })
 
   it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 6471419852b7fdd09539f5dbd669f1d24f06460e
-README.zh.md: c6fabbc1bb85ee5c6f2c6ca559feda59ef88aac2
+README.md: 8d68dfdb61943c7c45b38d9244134edecc86a593
+README.zh.md: b1d548ba8fc9276a6864548802a45796e74c0c7a

+ 5 - 1
packages/client/ui-conversation/README.md

@@ -36,6 +36,10 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 <a id="shell-and-standard-props"></a>
 ## Shell and standard props
 
+The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
+
+Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
+
 Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
 
 The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
@@ -50,7 +54,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a
 
 Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
 
-Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
+Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no File row, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
 
 File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission.
 

+ 5 - 1
packages/client/ui-conversation/README.zh.md

@@ -36,6 +36,10 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 <a id="shell-and-standard-props"></a>
 ## Shell 与标准 props
 
+输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
+
+已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
+
 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
 
 本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
@@ -50,7 +54,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
 
-Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
+Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供「文件」菜单项、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
 
 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
 

+ 23 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -2,6 +2,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -92,6 +93,17 @@ interface WorkspaceNavigation {
   ): Promise<void>
 }
 
+/** Action registration used by the composer without importing its command-UI consumer. */
+interface FileCommandRegistry {
+  register(contribution: {
+    name: string
+    label(): string
+    icon: typeof IconPaperclipOutline16
+    available(session: { sessionId: SessionId }): boolean
+    ui: { kind: 'action'; run(session: { sessionId: SessionId }): void }
+  }): () => void
+}
+
 /** Resolve the session-scoped Conversation action face, failing loud. */
 function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
   const scoped = sessions.scope(id)
@@ -197,6 +209,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
   const inputHub = new InputHub(ctx, t)
   const composerBlocks = new ComposerBlockRegistry()
 
+  ctx.inject(['commandUi'], (scope) => {
+    const commands = scope.get('commandUi') as FileCommandRegistry
+    scope.effect(() => commands.register({
+      name: 'file',
+      label: () => t('input.file'),
+      icon: IconPaperclipOutline16,
+      available: session => inputHub.canPickFiles(session.sessionId),
+      ui: { kind: 'action', run: (session) => { inputHub.pickFiles(session.sessionId) } },
+    }), 'ui-conversation: File action')
+  })
+
   // Conversation assembly and input share the Session binding lifecycle. The
   // source roster is installed before any consuming Slot entry.
   ctx.uiSession.provide({

+ 10 - 1
packages/client/ui-conversation/src/client/contract/input.ts

@@ -43,6 +43,9 @@ export interface SubmitOutcome {
 
 /** Command-mode credential supplied by one input-trigger source. */
 export interface CommandClaim {
+  /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */
+  readonly name: string
+  /** Inserted command text with its argument separator; the bare complete name also retains the claim. */
   readonly token: string
   readonly hint?: string
   readonly attachments?: boolean
@@ -288,6 +291,12 @@ export interface ComposerKeyboard {
   space(): boolean
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   dismissPopup(): void
+  /**
+   * Bind the mounted composer's file action and live intake availability.
+   * @param picker - availability query and native file-dialog opener.
+   * @returns the unbind disposer.
+   */
+  bindFilePicker(picker: { available(): boolean; open(): void }): () => void
 }
 
 /** One independently addressable row projected from the transient queue snapshot. */
@@ -341,7 +350,7 @@ export interface InputState {
   readonly draftRev: number
   readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
   /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
-  readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean }
+  readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean }
   /** Reference occurrence view of the editor's chips, sorted by offset. */
   readonly occurrences: readonly Occurrence[]
   /** Read-only transient inbox projection from Session control, including pending steering. */

+ 2 - 1
packages/client/ui-conversation/src/client/input/editor/claim-decor.ts

@@ -34,8 +34,9 @@ export function registerClaimDecoration(editor: LexicalEditor, activeToken: () =
       if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
       return
     }
-    const token = activeToken()
     const text = node.getTextContent()
+    const active = activeToken()
+    const token = text === active?.trimEnd() ? text : active
     if (token === null || !text.startsWith(token)) {
       if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
       return

+ 16 - 0
packages/client/ui-conversation/src/client/input/editor/keymap.ts

@@ -11,6 +11,8 @@
  * keydown AFTER compositionend, so a root-element composition watch holds the
  * guard for 10ms more (the old textarea's proven window); keyCode
  * 229 is the legacy signal engines emit without isComposing.
+ * The root's composition attribute suppresses placeholders until both the
+ * native composition and the editor's final text reconciliation finish.
  */
 import type { LexicalEditor } from 'lexical'
 import {
@@ -57,12 +59,20 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
   // root element and re-arms on root swaps.
   let composing = false
   let composingUntil = 0
+  let rootElement: HTMLElement | null = null
+  const syncComposition = (): void => {
+    rootElement?.toggleAttribute('data-composer-composing', composing || editor.isComposing())
+  }
   const onCompositionStart = (): void => {
     composing = true
+    syncComposition()
   }
   const onCompositionEnd = (): void => {
     composing = false
     composingUntil = Date.now() + 10
+    // The native event can precede the committed draft, including an empty
+    // cancellation. The callback also runs when no document text changed.
+    editor.update(() => {}, { onUpdate: syncComposition })
   }
   const recentlyComposing = (): boolean => composing || Date.now() < composingUntil
 
@@ -79,9 +89,15 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
     editor.registerRootListener((root, prevRoot) => {
       prevRoot?.removeEventListener('compositionstart', onCompositionStart)
       prevRoot?.removeEventListener('compositionend', onCompositionEnd)
+      prevRoot?.removeAttribute('data-composer-composing')
+      composing = false
+      composingUntil = 0
+      rootElement = root
       root?.addEventListener('compositionstart', onCompositionStart)
       root?.addEventListener('compositionend', onCompositionEnd)
+      syncComposition()
     }),
+    editor.registerUpdateListener(syncComposition),
     editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
     editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
     // Tab acts only when the trigger menu has a highlighted completion;

+ 1 - 1
packages/client/ui-conversation/src/client/input/editor/text-ref.ts

@@ -88,7 +88,7 @@ export function registerTextRefDecoration(
   const getMatch = (text: string): { start: number; end: number } | null => {
     const claim = activeToken()
     for (const range of scanTextRefs(text, lexiconOf())) {
-      if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim) continue
+      if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim.trimEnd()) continue
       return { start: range.start, end: range.end }
     }
     return null

+ 33 - 1
packages/client/ui-conversation/src/client/input/facade.ts

@@ -21,7 +21,7 @@ import { registerPlainText } from '@lexical/plain-text'
 import { createEmptyHistoryState, registerHistory } from '@lexical/history'
 import { mergeRegister } from '@lexical/utils'
 import type {
-  ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
+  ArbitrateKey, ArbitrateOutcome, CommandClaim, ComposerKeyboard, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
   Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
   SubmitOutcome, TokenSpan,
@@ -155,6 +155,8 @@ export class SessionInputShell implements SessionInput {
   private disposed = false
   /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
   private mirrorFn: ((text: string) => void) | undefined
+  /** The mounted composer's file-picker opener (scoped pick-files event target). */
+  private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
   /** Live lexicon subscription disposer; undefined until the controller resolves. */
   private lexiconOff: (() => void) | undefined
   /** Default sends retained until admission settles or scope disposal releases their attachments. */
@@ -610,6 +612,36 @@ export class SessionInputShell implements SessionInput {
     }
   }
 
+  /**
+   * Bind the mounted composer's file action and live intake availability.
+   * @param picker - availability query and native file-dialog opener.
+   * @returns the unbind disposer.
+   */
+  bindFilePicker(picker: Parameters<ComposerKeyboard['bindFilePicker']>[0]): () => void {
+    this.filePicker = picker
+    return () => {
+      if (this.filePicker === picker) this.filePicker = undefined
+    }
+  }
+
+  /**
+   * Read the mounted composer's live file-intake availability.
+   * @returns false when no accepting composer is mounted.
+   */
+  canPickFiles(): boolean {
+    return this.filePicker?.available() === true
+  }
+
+  /**
+   * Open the native file dialog when the mounted composer accepts files.
+   * @returns whether the opener was called.
+   */
+  pickFiles(): boolean {
+    if (this.filePicker === undefined || !this.filePicker.available()) return false
+    this.filePicker.open()
+    return true
+  }
+
   // ---- effect executor ----
 
   /** The claim token the decoration transform styles; null while unclaimed. */

+ 17 - 0
packages/client/ui-conversation/src/client/input/hub.ts

@@ -158,6 +158,23 @@ export class InputHub implements SessionInputResolver {
     return this.shell(id)
   }
 
+  /**
+   * Query file intake without creating a Session input.
+   * @param id - target Session.
+   * @returns whether its mounted composer currently accepts files.
+   */
+  canPickFiles(id: SessionId): boolean {
+    return this.shells.get(id)?.canPickFiles() === true
+  }
+
+  /**
+   * Open the target composer's file dialog under its live intake policy.
+   * @param id - target Session.
+   */
+  pickFiles(id: SessionId): void {
+    this.shells.get(id)?.pickFiles()
+  }
+
   /**
    * Resolve the optional slash controller for composer chrome that launches
    * the shared candidate menu without typing a trigger.

+ 9 - 3
packages/client/ui-conversation/src/client/input/machine.ts

@@ -27,6 +27,11 @@ function argsAfter(draft: string, token: string): string {
   return ''
 }
 
+/** A claimed name may stand alone; arguments require the token's separator. */
+function retainsClaim(draft: string, token: string): boolean {
+  return draft.startsWith(token) || draft === token.trimEnd()
+}
+
 /** The submit-plane slice of the published InputState. */
 export interface SubmitSnapshot {
   readonly phase: InputState['phase']
@@ -53,6 +58,7 @@ export class SubmitMachine {
       ...(c
         ? {
           claim: {
+            name: c.name,
             token: c.token,
             ...(c.hint !== undefined ? { hint: c.hint } : {}),
             ...(c.attachments === true ? { attachments: true } : {}),
@@ -82,9 +88,9 @@ export class SubmitMachine {
     }
   }
 
-  /** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */
+  /** The complete command name retains its claim with or without the argument separator. */
   private onDraftChanged(draft: string): readonly InputEffect[] {
-    if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) {
+    if (this.phase === 'claimed' && this.claim !== undefined && !retainsClaim(draft, this.claim.token)) {
       this.phase = 'plain'
       this.claim = undefined
     }
@@ -199,7 +205,7 @@ export class SubmitMachine {
     }
     const text = ev.message ?? ev.outcome?.text
     if (ev.draft === flight.attempt.draftSnapshot
-      && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) {
+      && this.claim !== undefined && retainsClaim(ev.draft, this.claim.token)) {
       this.phase = 'claimed'
       return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
     }

+ 4 - 4
packages/client/ui-conversation/src/client/locales.ts

@@ -19,7 +19,8 @@ export const zh = {
   'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话',
   'placeholder.workspace': '选择一个工作区开始',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
-  'input.commands': '指令',
+  'input.commands': '添加文件或调用指令',
+  'input.file': '文件',
   'input.stop': '停止生成',
   'input.send': '发送消息',
   'input.send.queue': '排队发送',
@@ -49,7 +50,6 @@ export const zh = {
   'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
   'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
   'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
-  'file.attach': '添加附件',
   'file.pending': '待发送文件',
   'file.remove': '移除文件 {name}',
   'file.uploading': '上传中…',
@@ -185,7 +185,8 @@ export const en = {
   'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions',
   'placeholder.workspace': 'Choose a workspace to start',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
-  'input.commands': 'Commands',
+  'input.commands': 'Add files or run commands',
+  'input.file': 'File',
   'input.stop': 'Stop generating',
   'input.send': 'Send message',
   'input.send.queue': 'Queue message',
@@ -215,7 +216,6 @@ export const en = {
   'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again',
   'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
   'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
-  'file.attach': 'Add attachment',
   'file.pending': 'Pending files',
   'file.remove': 'Remove file {name}',
   'file.uploading': 'Uploading…',

+ 8 - 0
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -195,6 +195,14 @@
   color: var(--dsw-alias-label-caption);
 }
 
+.input[data-composer-composing] p:last-child::after {
+  content: none;
+}
+
+.input[data-composer-composing] + .placeholder {
+  visibility: hidden;
+}
+
 /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
    Insets mirror .input's pads (the other right 4px sits on .scroll). One
    ellipsized line: a wrapped second line would clip against the docked

+ 12 - 17
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import {
-  IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
+  IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 // Type-only: the `plan` projection key merge (the TodoDock posture — the
 // composer reads a host-computed value; the domain owns the key).
@@ -266,13 +266,21 @@ export const InputBar = memo(function InputBar({
   // registration survives re-renders without re-arming per keystroke.
   const gate = useRef({
     locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
-    intakeFiles, uploadsPending, showToast, t,
+    intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
   })
   gate.current = {
     locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
-    intakeFiles, uploadsPending, showToast, t,
+    intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
   }
 
+  useEffect(() => {
+    if (keyboard === undefined) return
+    return keyboard.bindFilePicker({
+      available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
+      open: () => { fileInputRef.current?.click() },
+    })
+  }, [keyboard])
+
   useEffect(() => {
     if (editor === null || keyboard === undefined) return
     return registerComposerKeymap(editor, {
@@ -382,8 +390,7 @@ export const InputBar = memo(function InputBar({
     : null
   const hint = ((): string | null => {
     if (rawHint === null) return null
-    // Claim tokens have the `/name ` format (trailing space); trim to the bare name.
-    const commandName = input?.claim?.token.slice(1).trim() ?? ''
+    const commandName = input?.claim?.name ?? ''
     const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
     // Dynamic lookup by claimed command name: unknown commands miss the
     // dictionary and keep the machine's own hint, so the call is wide.
@@ -493,18 +500,6 @@ export const InputBar = memo(function InputBar({
                 <IconPlusOutline16 size={14} />
               </button>
             </Tooltip>
-            <Tooltip label={t('file.attach')} side="top" delayMs={500}>
-              <button
-                type="button"
-                className={css.add}
-                aria-label={t('file.attach')}
-                disabled={subagent !== null || locked || machineBusy || addFiles === undefined}
-                onMouseDown={keepFocus}
-                onClick={() => { fileInputRef.current?.click() }}
-              >
-                <IconPaperclipOutline16 size={14} />
-              </button>
-            </Tooltip>
             <input
               ref={fileInputRef}
               type="file"

+ 9 - 8
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx

@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'
 import type { ReactNode } from 'react'
 import clsx from 'clsx'
 import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission-presets/client'
-import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
+import {
+  IconChevronDownOutline14, Menu, RiskConfirmation, SHIELD_OUTLINE_PATH, SHIELD_OUTLINE_STROKE,
+} from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import { en } from '../locales.ts'
@@ -10,16 +12,15 @@ import css from './PermissionSelect.module.css'
 
 const FULL_ACCESS = 'danger-full-access'
 
-/* Shield glyphs (design set 1556): check = read-only, pencil = workspace
-   write, exclamation = full access. currentColor so the trigger and menu
-   rows tint them with their own text color. */
-
-const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
+/* Shield glyphs (design set 1556) over the ui-primitives shield contour:
+   check = read-only, pencil = workspace write, exclamation = full access.
+   currentColor so the trigger and menu rows tint them with their own text
+   color. */
 
 const permissionGlyphs = new Map<string, ReactNode>([
   ['read-only', (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
-      <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
+      <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
       <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
     </svg>
   )],
@@ -34,7 +35,7 @@ const permissionGlyphs = new Map<string, ReactNode>([
   )],
   [FULL_ACCESS, (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
-      <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
+      <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
       <path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
       <path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
     </svg>

+ 46 - 1
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -113,6 +114,50 @@ async function bench() {
 }
 
 describe('Conversation inject API', () => {
+  it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => {
+    const b = await bench()
+    onTestFinished(() => b.runtime.dispose())
+    const contributions = new Map<string, CommandContribution>()
+    const registry = {
+      register: (contribution: CommandContribution) => {
+        contributions.set(contribution.name, contribution)
+        return () => { contributions.delete(contribution.name) }
+      },
+    } satisfies Pick<CommandUiContract, 'register'>
+    b.runtime.ctx.provide('commandUi', registry)
+    await vi.waitFor(() => { expect(contributions.has('file')).toBe(true) })
+    const file = contributions.get('file')!
+    const target = { sessionId: ROOT }
+    expect(file.label!()).toBe('文件')
+    expect(file.available(target)).toBe(false)
+    expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false)
+    if (file.ui.kind !== 'action') throw new Error('File must be an action')
+    file.ui.run({ sessionId: 'missing' as SessionId })
+    const keyboard = b.composerApi(ROOT).keyboard!
+    const open = vi.fn()
+    let available = true
+    const unbind = keyboard.bindFilePicker({ open, available: () => available })
+    expect(file.available(target)).toBe(true)
+    file.ui.run(target)
+    expect(open).toHaveBeenCalledOnce()
+    available = false
+    expect(file.available(target)).toBe(false)
+    file.ui.run(target)
+    expect(open).toHaveBeenCalledOnce()
+    const replacement = vi.fn()
+    const removeReplacement = keyboard.bindFilePicker({ open: replacement, available: () => true })
+    unbind()
+    expect(file.available(target)).toBe(true)
+    file.ui.run(target)
+    expect(replacement).toHaveBeenCalledOnce()
+    removeReplacement()
+    expect(file.available(target)).toBe(false)
+    file.ui.run(target)
+    expect(replacement).toHaveBeenCalledOnce()
+    await b.feature.dispose()
+    expect(contributions.size).toBe(0)
+  })
+
   it('assembles the target-neutral read face without Session side effects', async () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)

+ 93 - 13
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -846,7 +846,7 @@ describe('running and lock semantics', () => {
     act(() => {
       claimed.shell.setDraft('/goal ')
       claimed.shell.beginCommand(
-        { token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) },
+        { name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) },
         { start: 0, end: 6, draftRev: claimed.shell.snapshot.draftRev },
       )
     })
@@ -935,7 +935,7 @@ describe('running and lock semantics', () => {
   })
 
   it('running continuable subagent keeps Send beside an independent Stop', () => {
-    const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({
+    const { button, interruptButton, textarea, sink, stop, view, slotCalls, shell } = bench({
       running: true,
       draft: '后续消息',
       subagent: {
@@ -950,9 +950,14 @@ describe('running and lock semantics', () => {
     expect(button.getAttribute('aria-label')).toBe('排队发送')
     expect(interruptButton).not.toBeNull()
     expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
-    expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
     expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
     expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
+    const click = vi.spyOn(HTMLInputElement.prototype, 'click')
+    onTestFinished(() => { click.mockRestore() })
+    expect(shell.canPickFiles()).toBe(false)
+    expect(shell.pickFiles()).toBe(false)
+    expect(click).not.toHaveBeenCalled()
+    click.mockRestore()
     fireEvent.click(button)
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     fireEvent.click(interruptButton!)
@@ -985,10 +990,41 @@ describe('running and lock semantics', () => {
     ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
   ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
     const added = vi.fn(() => null)
-    const { view, slotCalls } = bench({ ...projection, addFiles: added })
-    expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false)
+    const { view, slotCalls, shell } = bench({ ...projection, addFiles: added })
     expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(false)
     expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
+    // The menu's File row opens the hidden input through the bound picker.
+    const click = vi.spyOn(HTMLInputElement.prototype, 'click')
+    onTestFinished(() => { click.mockRestore() })
+    expect(shell.canPickFiles()).toBe(true)
+    expect(shell.pickFiles()).toBe(true)
+    expect(click).toHaveBeenCalledOnce()
+    click.mockRestore()
+  })
+
+  it.each([
+    ['removed session', { disabled: true }],
+    ['inert composer', { inert: true }],
+    ['blocked composer', { blocked: { reason: 'waiting' } }],
+  ])('%s hides and refuses the File action', (_name, state) => {
+    const { shell, view } = bench({ ...state, addFiles: () => null })
+    const input = view.container.querySelector<HTMLInputElement>('input[type="file"]')!
+    const open = vi.spyOn(input, 'click')
+    expect(shell.canPickFiles()).toBe(false)
+    expect(shell.pickFiles()).toBe(false)
+    expect(open).not.toHaveBeenCalled()
+  })
+
+  it('file action availability follows mount and live composer state', () => {
+    const { props, view, shell } = bench({ addFiles: () => null })
+    expect(shell.canPickFiles()).toBe(true)
+    view.rerender(<InputBar {...props} disabled />)
+    expect(shell.canPickFiles()).toBe(false)
+    view.rerender(<InputBar {...props} />)
+    expect(shell.canPickFiles()).toBe(true)
+    view.unmount()
+    expect(shell.canPickFiles()).toBe(false)
+    expect(shell.pickFiles()).toBe(false)
   })
 
   it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
@@ -1006,7 +1042,7 @@ describe('running and lock semantics', () => {
     })
     expect(textarea.getAttribute('aria-disabled')).toBe('true')
     expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
-    expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(button.disabled).toBe(true)
     expect(interruptButton?.disabled).toBe(false)
@@ -1058,7 +1094,7 @@ describe('running and lock semantics', () => {
     const { textarea, view } = bench({ disabled: true })
     expect(textarea.getAttribute('aria-disabled')).toBe('true')
     expect(placeholderOf(view.container)).toBe('会话不可用')
-    expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
   })
 
   it('idle primary sends and disables on empty draft', () => {
@@ -1191,7 +1227,7 @@ describe('running and lock semantics', () => {
     expect(editableOf(textarea)).toBe(false)
     expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea.getAttribute('aria-expanded')).toBe('false')
-    expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
 
     fireEvent.click(textarea)
     fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -1236,6 +1272,7 @@ describe('machine pending lock', () => {
       shell.setDraft('/goal ')
       shell.beginCommand(
         {
+          name: 'goal',
           token: '/goal ',
           submit: () => new Promise<never>(() => {}), // never settles: stays submitting
         },
@@ -1262,7 +1299,7 @@ describe('decorations', () => {
     act(() => {
       shell.setDraft('/goal ')
       shell.beginCommand(
-        { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
+        { name: 'goal', token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
       shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh
@@ -1281,13 +1318,56 @@ describe('decorations', () => {
     act(() => {
       shell.setDraft('/goal ')
       shell.beginCommand(
-        { token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
+        { name: 'goal', token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
     })
     expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
   })
 
+  it('the hint lookup keys on the claim name, so a localized claim token keeps the locale entry', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('/目标 ')
+      shell.beginCommand(
+        { name: 'goal', token: '/目标 ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
+        { start: 0, end: 4, draftRev: shell.snapshot.draftRev },
+      )
+    })
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
+  })
+
+  it('suppresses placeholders throughout native composition, including a temporarily empty draft', async () => {
+    const { shell, textarea, view } = bench()
+    expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
+    fireEvent.compositionStart(textarea)
+    expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
+    act(() => { shell.setDraft('z') })
+    act(() => { shell.setDraft('') })
+    expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
+    fireEvent.compositionEnd(textarea, { data: '' })
+    expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
+    await act(async () => {})
+    expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
+
+    act(() => {
+      shell.setDraft('/目标 ')
+      shell.beginCommand(
+        { name: 'goal', token: '/目标 ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
+        { start: 0, end: 4, draftRev: shell.snapshot.draftRev },
+      )
+    })
+    fireEvent.compositionStart(textarea)
+    act(() => { shell.setDraft('/目标 z') })
+    act(() => { shell.setDraft('/目标 ') })
+    expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
+    fireEvent.compositionEnd(textarea, { data: '这' })
+    act(() => { shell.setDraft('/目标 这') })
+    await act(async () => {})
+    expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
+  })
+
   it('an inserted reference renders a real chip capsule with its icon and label', () => {
     const { view, shell } = bench()
     const reference = {
@@ -1494,7 +1574,7 @@ describe('strips and variants', () => {
 describe('command launcher chrome and control seats', () => {
   it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => {
     const { view, slotCalls } = bench()
-    expect(view.getByLabelText('指令')).toBeTruthy()
+    expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy()
     // Capability absent (no projection value): the chip renders nothing.
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
     // Every seat dispatched, nothing rendered (render passes may repeat; the
@@ -1513,7 +1593,7 @@ describe('command launcher chrome and control seats', () => {
     const toggleCommandMenu = vi.fn()
     const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
     act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
-    const launcher = view.getByLabelText('指令')
+    const launcher = view.getByLabelText('添加文件或调用指令')
     expect(launcher.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(launcher)
     expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
@@ -1683,7 +1763,7 @@ describe('command launcher chrome and control seats', () => {
   it('disabled locks the Access chip and command launcher (running does not)', () => {
     const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
     const { view } = bench({ disabled: true, permissions })
-    expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
     expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
     cleanup()
     const live = bench({ running: true, permissions })

+ 4 - 4
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -111,7 +111,7 @@ function bench(over?: {
       shell.setDraft(token)
       shell.beginCommand(
         {
-          token, hint,
+          name: token.slice(1).trim(), token, hint,
           ...(attachments === true ? { attachments: true } : {}),
           submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })),
         },
@@ -141,7 +141,7 @@ describe('matrix row: claimed', () => {
     const { view, textarea, shell, claim } = bench()
     claim()
     act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
-    expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
+    expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标' })
     expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ')
     // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
     expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
@@ -201,7 +201,7 @@ describe('matrix row: claimed with attachments', () => {
     const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) })
     claim('/goal ', '目标', true)
     // The claim currency carries the acceptance flag the pre-gate reads.
-    expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true })
+    expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标', attachments: true })
     act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) })
@@ -318,7 +318,7 @@ describe('matrix row: locked (session disabled)', () => {
   it('disables the textarea and chrome; the machine currency is untouched', () => {
     const { view, textarea, shell } = bench({ disabled: true })
     expect(textarea.getAttribute('aria-disabled')).toBe('true')
-    expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
     expect(shell.snapshot.phase).toBe('plain')
   })
 

+ 30 - 0
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -28,6 +28,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { DraftAttachmentId } from '../src/client/contract/input.ts'
 import { SessionInputShell } from '../src/client/input/facade.ts'
+import { $replaceDetectSpanWithText } from '../src/client/input/editor/span-map.ts'
 import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
@@ -58,6 +59,7 @@ function commandSource(
 ) {
   const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name)
   const leadingClaim = (desc: FakeCommand): CommandClaim => ({
+    name: desc.name,
     token: `/${desc.name} `,
     ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
     ...(desc.input?.attachments === true ? { attachments: true } : {}),
@@ -305,6 +307,34 @@ describe('scenario: images ride an accepting command through the real pipeline',
 })
 
 describe('scenario H: backspace breaks the token', () => {
+  it.each(['goal', '目标', 'plan', '计划', 'feedback', '反馈'])('keeps /%s claimed when its arguments and separator are deleted', async (name) => {
+    const { source } = commandSource([{ name, description: name, input: { hint: '目标内容' } }],
+      () => Promise.resolve({ kind: 'success' }))
+    const b = await scopedBench((triggers) => { triggers.registerSource(source) })
+    b.type(`/${name}`)
+    fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
+    expect(b.shell.snapshot.phase).toBe('claimed')
+    b.type(`/${name} 这是目标`)
+    for (let i = 0; i < 5; i++) {
+      act(() => {
+        b.shell.editor.update(() => {
+          const end = b.shell.snapshot.draft.length
+          $replaceDetectSpanWithText({ start: end - 1, end }, '')
+        }, { discrete: true })
+      })
+    }
+    expect(b.shell.snapshot.draft).toBe(`/${name}`)
+    expect(b.shell.snapshot.phase).toBe('claimed')
+    expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name}`)
+    b.type(`/${name} `)
+    await act(async () => {})
+    expect(b.shell.snapshot.phase).toBe('claimed')
+    expect(b.shell.snapshot.draft).toBe(`/${name} `)
+    expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name} `)
+    b.type(`/${name}x`)
+    expect(b.shell.snapshot.phase).toBe('plain')
+  })
+
   it('claim releases automatically; the enter after that goes through adjudication again', async () => {
     const b = await bench()
     b.type('/goal')

+ 34 - 1
packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx

@@ -4,13 +4,46 @@
  * contenteditable reach the registered composer commands (the jsdom lane's
  * gesture entry, below the full component bench).
  */
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { fireEvent } from '@testing-library/react'
 import { createEditor } from 'lexical'
 import { registerPlainText } from '@lexical/plain-text'
 import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts'
 
 describe('keymap keydown routing', () => {
+  it('clears composition presentation on root swaps and unregisters pending callbacks', async () => {
+    const editor = createEditor({ namespace: 'composition-root', onError: (e) => { throw e } })
+    const first = document.createElement('div')
+    const second = document.createElement('div')
+    document.body.append(first, second)
+    onTestFinished(() => {
+      editor.setRootElement(null)
+      first.remove()
+      second.remove()
+    })
+    editor.setRootElement(first)
+    const unregister = registerComposerKeymap(editor, {
+      arbitrate: () => 'pass', space: () => false, dismissPopup: () => {},
+      canSubmit: () => false, submit: () => {}, intakeFiles: () => {}, pasteText: () => {},
+    })
+    onTestFinished(unregister)
+    fireEvent.compositionStart(first)
+    expect(first.hasAttribute('data-composer-composing')).toBe(true)
+    editor.setRootElement(second)
+    expect(first.hasAttribute('data-composer-composing')).toBe(false)
+    expect(second.hasAttribute('data-composer-composing')).toBe(false)
+    fireEvent.compositionStart(first)
+    expect(first.hasAttribute('data-composer-composing')).toBe(false)
+    fireEvent.compositionStart(second)
+    expect(second.hasAttribute('data-composer-composing')).toBe(true)
+    fireEvent.compositionEnd(second, { data: '' })
+    unregister()
+    await Promise.resolve()
+    expect(second.hasAttribute('data-composer-composing')).toBe(false)
+    fireEvent.compositionStart(second)
+    expect(second.hasAttribute('data-composer-composing')).toBe(false)
+  })
+
   it('routes Enter to the keymap submit handler', () => {
     const editor = createEditor({ namespace: 'keymap-routing', onError: (e) => { throw e } })
     const root = document.createElement('div')

+ 5 - 5
packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx

@@ -429,9 +429,9 @@ describe('claim precedence over text-ref entities', () => {
   const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)'
   const LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map([['/', ['plan']]])
 
-  it('keeps a claimed lexicon-listed token plain and warn-styled until release', () => {
+  it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and warn-styled until release', (token) => {
     const editor = makeEditor()
-    let claim: string | null = '/plan'
+    let claim: string | null = token
     registerClaimDecoration(editor, () => claim)
     registerTextRefDecoration(editor, () => LEXICON, () => claim)
     editor.update(() => {
@@ -449,7 +449,7 @@ describe('claim precedence over text-ref entities', () => {
         }
       })
     // Claimed: the entity transform yields the seat, the claim transform styles it.
-    expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' })
+    expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token })
     // Released (the shell's refresh nudges the seat dirty): the entity captures it.
     claim = null
     editor.update(() => {
@@ -458,11 +458,11 @@ describe('claim precedence over text-ref entities', () => {
     }, { discrete: true })
     expect(leaf()).toEqual({ type: 'composer-text-ref', style: '', text: '/plan' })
     // Re-claimed: the entity reverts to plain text and the warn style returns.
-    claim = '/plan'
+    claim = token
     editor.update(() => {
       const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild()
       if ($isTextNode(first)) first.markDirty()
     }, { discrete: true })
-    expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' })
+    expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token })
   })
 })

+ 29 - 1
packages/client/ui-conversation/tests/submit-machine.client.spec.ts

@@ -13,6 +13,7 @@ import { scanTextRefs } from '../src/client/input/decorations.ts'
 
 function claimOf(name: string, hint?: string): CommandClaim {
   return {
+    name,
     token: `/${name} `,
     ...(hint !== undefined ? { hint } : {}),
     submit: async () => ({ kind: 'success' }),
@@ -191,7 +192,7 @@ describe('submit-machine: claimed lifecycle', () => {
     expect(m.state.phase).toBe('submitting')
   })
 
-  it('breaking startsWith(token) auto-releases back to plain', () => {
+  it('editing the command name releases back to plain', () => {
     const m = new SubmitMachine()
     m.dispatch({ type: 'claim', claim: claimOf('goal') })
     m.dispatch({ type: 'draft-changed', draft: '/goal args fine' })
@@ -201,6 +202,33 @@ describe('submit-machine: claimed lifecycle', () => {
     expect(m.state.claim).toBeUndefined()
   })
 
+  it.each([
+    ['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '],
+    ['feedback', '/feedback '], ['feedback', '/反馈 '],
+  ])('retains %s as %s without its separator and submits an empty argument', (name, token) => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: { ...claimOf(name), token } })
+    for (const draft of [token + '这是目标', token, token.trimEnd(), token, token.trimEnd()]) {
+      m.dispatch({ type: 'draft-changed', draft })
+      expect(m.state.phase).toBe('claimed')
+      expect(m.state.claim?.name).toBe(name)
+    }
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() })
+    const begin = effectAt(fx, 0, 'begin-submit')
+    expect(begin.args).toBe('')
+    m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd() })
+    expect(m.state.phase).toBe('claimed')
+    expect(m.state.claim?.name).toBe(name)
+  })
+
+  it.each(['/目', '/目标x', '/目标/文件', '', '看看 /目标'])('releases a goal claim for %j', (draft) => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } })
+    m.dispatch({ type: 'draft-changed', draft })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
   it('explicit release returns to plain when nothing is in flight', () => {
     const m = new SubmitMachine()
     m.dispatch({ type: 'claim', claim: claimOf('goal') })

+ 2 - 2
packages/client/ui-input-trigger/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
-README.md: aa8919ed9aa0b883fef2bbd33e9bce36ffb0e535
-README.zh.md: cb552d9ade693bdd4a3a7e93d4ed87f0e217f8e6
+README.md: ad10001829dea48b961b922c4e90cad0ce9efa92
+README.zh.md: d34d05410902ee6f98175339f0c881f958da621c

+ 1 - 2
packages/client/ui-input-trigger/README.md

@@ -25,7 +25,7 @@ When users type `/` or `@` at the caret in the Web GUI, this package opens a gro
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation`; the menu then appears in the input overlay when the user types a trigger under the caret. Grouped candidates render under title rows; a pick routes to the source, and the consuming surface applies the result — a slash command opens its popup or executes, a reference inserts its inline token.
+Mount this plugin alongside `ui-conversation`; the menu then appears in the input overlay when the user types a trigger under the caret. Grouped candidates render under title rows, or under the section headings a source attaches to its own rows; a pick routes to the source, and the consuming surface applies the result — a slash command opens its popup or executes, a reference inserts its inline token. A row shows its icon, its title (the candidate `label`, or the `name` when no label is given), the `name` as a trailing alias when the label is not the name in another letter case, and the description right-aligned; a query matches either the name or the label.
 
 ### Keyboard and mouse
 
@@ -76,7 +76,6 @@ None; this package neither assembles nor sends a provider request.
 These limits define the current trigger pipeline. They are current package constraints, not a general menu comparison or a task backlog.
 
 - **Global source layer only** — session-scope source registration (per-session shadowing) is designed but not enabled; the ledger tracks the trigger condition, a real per-session source need.
-- **`InputTriggerCandidate.icon` renders as text** — `MenuView` drops the string into the icon slot verbatim; wiring to the design-system icon enum lands when that enum ships.
 - **Overlay SlotMap merge home is split from slot ownership** — the sole `conversation.input.overlay` merge lives here, while ui-conversation owns its anchor, children declaration, and lifecycle because the dependency direction is ui-conversation → ui-input-trigger.
 
 <a id="dev-note"></a>

+ 2 - 3
packages/client/ui-input-trigger/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 一起挂载本插件;用户在光标处键入触发器时,菜单随即出现在输入浮层中。分组候选项渲染在标题行之下;pick 路由到 source,消费方表面应用其结果——斜杠命令打开其弹窗或执行,引用插入其行内 token。
+与 `ui-conversation` 一起挂载本插件;用户在光标处键入触发器时,菜单随即出现在输入浮层中。分组候选项渲染在标题行之下,或渲染在 source 附加在自己各行上的小节标题之下;pick 路由到 source,消费方表面应用其结果——斜杠命令打开其弹窗或执行,引用插入其行内 token。每一行显示图标、标题(候选项的 `label`,没有 label 时显示 `name`)、当标题不是 `name` 的另一种大小写写法时跟在标题后的 `name` 别名,以及右对齐的说明;查询同时匹配 name 与 label。
 
 ### 键盘与鼠标
 
@@ -76,8 +76,7 @@ kind: "package-reference"
 这些限制界定了当前触发流水线。它们是当前包约束,不是通用菜单对比或任务积压。
 
 - **只有全局 source 层**——会话 scope 的 source 注册(逐会话遮蔽)已有设计但未启用;台账记录着触发条件,即真实的逐会话 source 需求。
-- **`InputTriggerCandidate.icon` 以文本渲染**——`MenuView` 把该字符串原样放进图标位;与设计系统图标枚举的接入将在该枚举交付后完成。
-- **overlay 的 SlotMap 合并归属与 slot 所有权分离**——唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
+- **overlay 的 SlotMap 合并归属与 slot 所有权分离**:唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 36 - 3
packages/client/ui-input-trigger/src/client/MenuView.module.css

@@ -6,14 +6,20 @@
   left: 0;
   right: 0;
   z-index: 100;
-  /* Height cap: the 320px design maximum, clamped at runtime to the space
-   * above the composer (inline max-height set in MenuView.tsx). */
-  max-height: 320px;
+  box-sizing: border-box;
+  /* Fits the built-in rows without scrolling and still clamps at runtime to
+   * the space above the composer (inline max-height set in MenuView.tsx). */
+  max-height: 400px;
   overflow: hidden;
   /* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
      (see ui-theme styles/scrollbar.css for the rebinding contract). */
   --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
   --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+  /* A 10px draggable rail carries a quiet 4px thumb, inset from the card's
+     rounded ends. ui-theme's global scrollbar sheet renders these values. */
+  --dsh-scrollbar-width: 10px;
+  --dsh-scrollbar-thumb-border: 3px;
+  --dsh-scrollbar-track-margin: 12px;
   padding: 4px;
   display: flex;
   flex-direction: column;
@@ -24,6 +30,19 @@
   box-shadow: var(--dsw-elevation-prominent);
 }
 
+/* The hint covers content, not the scrollbar rail, and disappears once the
+   viewport reaches the final row. */
+.menu[data-overflow-below]::after {
+  content: '';
+  position: absolute;
+  right: 14px;
+  bottom: 4px;
+  left: 4px;
+  height: 16px;
+  background: linear-gradient(to bottom, transparent, var(--dsw-specific-menu));
+  pointer-events: none;
+}
+
 .viewport {
   display: flex;
   flex-direction: column;
@@ -86,12 +105,26 @@
   white-space: nowrap;
 }
 
+/* The command name beside a localized title, in the description's ink so
+   the row reads title first. */
+.itemAlias {
+  flex: none;
+  max-width: 20%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* Right-aligned so every description ends at the same edge and the titles
+   read as one left column. */
 .itemDescription {
   flex: 1;
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  text-align: right;
   color: var(--dsw-alias-label-tertiary);
 }
 

+ 33 - 8
packages/client/ui-input-trigger/src/client/MenuView.tsx

@@ -7,10 +7,12 @@
  * rows only while it has none; pointer picks route back through
  * the service (combobox pattern — focus never leaves the textarea, so rows
  * are mousedown-handled and the highlight is exposed via
- * aria-activedescendant on the listbox). A source publishing crumbs gets a
- * breadcrumb header pinned above the scrolling list.
+ * aria-activedescendant on the listbox). A row reads title, then the
+ * command-name alias when the title is not the name in another letter case
+ * (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb
+ * header pinned above the scrolling list.
  */
-import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
+import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
 import clsx from 'clsx'
 import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
@@ -21,8 +23,8 @@ import type { MenuKey } from './locales.ts'
 /** Full menu props: injected face + the locale seat. */
 export type MenuViewProps = MenuViewInjected & PropsLocale<'slash.menu'>
 
-/** Design cap on the list height (figma SLASH 39:26572 MenuDropdown). */
-const MAX_HEIGHT = 320
+/** Height cap that fits the two headings and eight built-in command rows. */
+const MAX_HEIGHT = 400
 
 /** DOM id of one option row (the aria-activedescendant target). */
 function optionId(source: string, index: number): string {
@@ -44,10 +46,20 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
     () => headers.getSnapshot(),
   )
   const listRef = useRef<HTMLDivElement>(null)
+  const viewportRef = useRef<HTMLDivElement>(null)
+  const [hasOverflowBelow, setHasOverflowBelow] = useState(false)
   // The list is bottom-anchored above the composer; clamp the design cap to
   // the space above it, re-measured on every store update (the anchor moves
   // when the composer grows).
   const maxHeight = useAnchoredMaxHeight(listRef, MAX_HEIGHT, state)
+  const updateOverflowHint = useCallback(() => {
+    const viewport = viewportRef.current
+    setHasOverflowBelow(viewport !== null
+      && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 1)
+  }, [])
+  useLayoutEffect(() => {
+    updateOverflowHint()
+  }, [state, maxHeight, updateOverflowHint])
   const highlight = state.open ? state.highlight : null
   // Focus stays in the textarea (combobox pattern), so the browser never
   // scrolls the active option into view on keyboard moves — do it here.
@@ -74,7 +86,13 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
   return (
     // The listbox role sits on the scrolling viewport, not this shell: a
     // breadcrumb header is not an option, and a listbox may not carry one.
-    <div ref={listRef} className={css.menu} style={{ maxHeight }} data-trigger-menu="">
+    <div
+      ref={listRef}
+      className={css.menu}
+      style={{ maxHeight }}
+      data-trigger-menu=""
+      data-overflow-below={hasOverflowBelow || undefined}
+    >
       {state.groups.map((group) => {
         const trail = crumbs.get(group.source)
         return trail === undefined ? null : (
@@ -101,10 +119,12 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
         )
       })}
       <div
+        ref={viewportRef}
         className={css.viewport}
         role="listbox"
         aria-label={t('suggestions.aria')}
         aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
+        onScroll={updateOverflowHint}
       >
         {state.groups.map(group => (group.status === 'ready' && group.items.length === 0)
           ? null
@@ -150,10 +170,15 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
                       >
                         {item.icon !== undefined && (
                           <span className={css.itemIcon} aria-hidden>
-                            <ReferenceIcon kind={item.icon} size={16} />
+                            {typeof item.icon === 'string'
+                              ? <ReferenceIcon kind={item.icon} size={16} />
+                              : <item.icon size={16} />}
                           </span>
                         )}
-                        <span className={css.itemName}>{item.name}</span>
+                        <span className={css.itemName}>{item.label ?? item.name}</span>
+                        {item.label !== undefined && item.label.toLowerCase() !== item.name.toLowerCase() && (
+                          <span className={css.itemAlias}>{item.name}</span>
+                        )}
                         {item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
                         {item.drill === true && (
                           <span className={css.trailing}>

+ 13 - 2
packages/client/ui-input-trigger/src/types.ts

@@ -7,9 +7,11 @@
  * never a Cordis context or the mutable Session. RPC and service access go
  * through the provider plugin's own root context captured at registration.
  */
+import type { ComponentType } from 'react'
 import type {
   PickOutcome, ReferenceInsert, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 export type {
@@ -40,14 +42,23 @@ export type PickVia = 'menu' | 'space' | 'enter'
 /** What a pick asks for: resolve the candidate, or drill into it in place. */
 export type PickAction = 'pick' | 'drill'
 
-/** Leading glyph token of one menu candidate, mapped to its SVG by the menu view. */
+/** Leading glyph token of one menu candidate, mapped to its reference SVG by the menu view. */
 export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session'
 
 /** One menu candidate. Pure display data — zero behavior declaration. */
 export interface InputTriggerCandidate {
+  /** Identity: the pick payload, the exact-match key, and the first search key. */
   readonly name: string
+  /**
+   * Display title; the name itself when absent. A label that is not the
+   * name in another letter case renders with the name as a trailing alias,
+   * and every label is the second search key (a localized title stays
+   * findable by its command name).
+   */
+  readonly label?: string
   readonly description?: string
-  readonly icon?: InputTriggerCandidateIcon
+  /** Reference glyph token, or an icon component from the shared icon set. */
+  readonly icon?: InputTriggerCandidateIcon | ComponentType<IconProps>
   readonly hint?: string
   /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */
   readonly section?: string

+ 37 - 2
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx

@@ -114,6 +114,26 @@ describe('MenuView', () => {
     expect(status.children).toHaveLength(2)
   })
 
+  it('renders a localized label as the title with the name as its alias, an icon component, and the description', () => {
+    const Glyph = ({ size = 16 }: { size?: number | undefined }) => <svg data-glyph="plan" width={size} height={size} />
+    mount(openState({
+      groups: [{
+        source: 'command',
+        status: 'ready',
+        items: [
+          { name: 'plan', label: '计划', description: '进入或退出计划模式', icon: Glyph, section: '添加' },
+          { name: 'file', label: 'File', section: '添加' },
+        ],
+      }],
+    }))
+    const options = screen.getAllByRole('option')
+    expect(options.map(o => o.textContent)).toEqual(['计划plan进入或退出计划模式', 'File'])
+    expect(options[0]?.querySelector('[data-glyph="plan"]')?.getAttribute('width')).toBe('16')
+    // A label that is the name in another letter case renders no alias.
+    expect(options[1]?.querySelectorAll('span')).toHaveLength(1)
+    expect(screen.getAllByText('添加')).toHaveLength(1)
+  })
+
   it('keeps an opted-out source title hidden while its candidates are pending', () => {
     mount(openState({
       groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }],
@@ -218,7 +238,7 @@ describe('MenuView', () => {
   it('caps the list height at the design maximum when the composer sits low enough', () => {
     vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
     mount(openState())
-    expect(menuShell().style.maxHeight).toBe('320px')
+    expect(menuShell().style.maxHeight).toBe('400px')
   })
 
   it('clamps the list height to the space above the composer minus the safe margin', () => {
@@ -231,12 +251,27 @@ describe('MenuView', () => {
     const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
     rect.mockReturnValue({ bottom: 800 } as DOMRect)
     mount(openState())
-    expect(menuShell().style.maxHeight).toBe('320px')
+    expect(menuShell().style.maxHeight).toBe('400px')
     rect.mockReturnValue({ bottom: 100 } as DOMRect)
     act(() => { window.dispatchEvent(new Event('resize')) })
     expect(menuShell().style.maxHeight).toBe('88px')
   })
 
+  it('shows the bottom overflow hint until the list reaches its final row', () => {
+    mount(openState())
+    const listbox = screen.getByRole('listbox')
+    Object.defineProperties(listbox, {
+      clientHeight: { configurable: true, value: 320 },
+      scrollHeight: { configurable: true, value: 392 },
+      scrollTop: { configurable: true, value: 0, writable: true },
+    })
+    fireEvent.scroll(listbox)
+    expect(menuShell().hasAttribute('data-overflow-below')).toBe(true)
+    listbox.scrollTop = 72
+    fireEvent.scroll(listbox)
+    expect(menuShell().hasAttribute('data-overflow-below')).toBe(false)
+  })
+
   it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
     const { onDismiss } = mount(openState())
     fireEvent.pointerDown(document.body)

+ 1 - 1
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -63,7 +63,7 @@ function readySource(
 }
 
 const claimOf = (token: string): CommandClaim =>
-  ({ token, submit: () => Promise.resolve({ kind: 'success' }) })
+  ({ name: token.slice(1).trim(), token, submit: () => Promise.resolve({ kind: 'success' }) })
 
 /** One microtask hop: lets settled candidate promises flow into the store. */
 const tick = () => Promise.resolve()

+ 3 - 0
packages/client/ui-model-selection/src/client/index.ts

@@ -22,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+import { IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ModelDirectoryState } from './directory.ts'
 import { ModelDirectoryResolver } from './service.ts'
 import type { ModelSelectInjected } from './slots.ts'
@@ -142,7 +143,9 @@ export function apply(ctx: ClientContext): void {
     const sessions = scope.sessions
     scope.effect(() => command.register({
       name: 'model',
+      label: () => t('command.label'),
       description: () => t('command.description'),
+      icon: IconDataOutline16,
       available: session => sessions.subagentAddress(session.sessionId) === undefined,
       ui: {
         kind: 'popupSelect',

+ 2 - 0
packages/client/ui-model-selection/src/client/locales.ts

@@ -10,6 +10,7 @@
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
+  'command.label': '模型',
   'command.description': '选择本会话使用的模型',
   'option.loadError': '目录加载失败:{message}',
   'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。',
@@ -37,6 +38,7 @@ export type ModelKey = keyof typeof zh
 
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
+  'command.label': 'Model',
   'command.description': 'Select the model for this conversation',
   'option.loadError': 'Catalog failed to load: {message}',
   'option.deepseekV4Flash.description': 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',

+ 57 - 3
packages/client/ui-primitives/src/icons/index.tsx

@@ -1,7 +1,8 @@
 /**
- * ic_ds_* icon set for the dsh web UI. All glyphs render fill="currentColor"
- * and take {size, className}. Batch A mirrors the deepsuite icon library
- * (same figma source); batch B glyphs are harness-only figma extracts.
+ * ic_ds_* icon set for the dsh web UI. All glyphs ride currentColor (filled
+ * or stroked) and take {size, className}. Batch A mirrors the deepsuite icon
+ * library (same figma source); batch B glyphs are harness-only figma
+ * extracts; the product glyphs at the end are hand-authored.
  */
 import type { IconProps } from './props.ts'
 
@@ -936,3 +937,56 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => (
     <path d="M12.7962 12.5661V11.0832H7.20548V12.5661L12.7962 12.5661Z" fill="currentColor" />
   </svg>
 )
+
+/**
+ * Plan row glyph of the composer menu (#3567): a document with three ruled
+ * lines and a pencil over its corner. Drawn on a 14 grid whose content spans
+ * 0.5..13.5, so at the menu's 16px render it fills the same extent as the
+ * 16-grid glyphs beside it.
+ */
+export const IconPlanOutline14 = ({ size = 14, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M9.56143 3.14672V4.24774H3.94716V3.14672H9.56143Z" fill="currentColor" />
+    <path d="M9.56143 5.44201V6.54304H3.94716V5.44201H9.56143Z" fill="currentColor" />
+    <path d="M7.97328 7.73731V8.83833H3.94716V7.73731H7.97328Z" fill="currentColor" />
+    <path
+      d="M8.02789 0.500001C8.82106 0.500001 9.46528 0.499108 9.97973 0.553797C10.5054 0.609708 10.9678 0.729732 11.3714 1.01734C11.6057 1.18436 11.8125 1.38729 11.9827 1.61716C12.2758 2.01317 12.3982 2.46699 12.4552 2.98269C12.5109 3.48743 12.51 4.11964 12.51 4.89782V5.48599L11.2928 6.72061V4.89782C11.2928 4.09333 11.2923 3.53731 11.2453 3.1118C11.1995 2.69759 11.1162 2.47711 10.9986 2.31831C10.9034 2.18976 10.7879 2.07638 10.6568 1.98298C10.495 1.86762 10.2704 1.7858 9.84814 1.7409C9.41445 1.69482 8.84789 1.69427 8.02789 1.69427H5.4821C4.66215 1.69427 4.09555 1.69485 3.66184 1.7409C3.23978 1.78576 3.01501 1.86773 2.85315 1.98298C2.72221 2.07632 2.60657 2.18986 2.51139 2.31831C2.39384 2.4771 2.31045 2.69763 2.26467 3.1118C2.21772 3.53731 2.21716 4.09333 2.21716 4.89782V8.91011C2.21716 9.71498 2.21771 10.2714 2.26467 10.697C2.3104 11.111 2.39397 11.3308 2.51139 11.4896C2.60659 11.6182 2.72214 11.7315 2.85315 11.825C3.01505 11.9404 3.23938 12.023 3.66184 12.0679C4.09555 12.114 4.66212 12.1146 5.4821 12.1146H5.97554L4.80224 13.3034C4.3108 13.3002 3.88905 13.2923 3.53026 13.2541C3.00445 13.1982 2.5423 13.0784 2.13857 12.7906C1.90424 12.6235 1.6975 12.4216 1.52725 12.1917C1.23396 11.7955 1.1118 11.3412 1.05483 10.8252C0.999149 10.3205 1 9.68831 1 8.91011V4.89782C1 4.11964 0.999094 3.48743 1.05483 2.98269C1.11182 2.46701 1.23416 2.01316 1.52725 1.61716C1.69745 1.3874 1.90437 1.1843 2.13857 1.01734C2.54218 0.729872 3.00468 0.609682 3.53026 0.553797C4.04472 0.499135 4.68896 0.500001 5.4821 0.500001H8.02789Z"
+      fill="currentColor"
+    />
+    <path d="M12.6413 13.2999H8.82536L10.0608 12.1056H12.6413V13.2999Z" fill="currentColor" />
+    <path
+      d="M7.22216 11.8899L6.53753 13.2335C6.4571 13.3913 6.62775 13.5587 6.78861 13.4798L8.15787 12.8081L13 8.08806L12.0643 7.16994L7.22216 11.8899Z"
+      fill="currentColor"
+    />
+  </svg>
+)
+
+/**
+ * Compact row glyph of the composer menu: the composer's context-usage ring
+ * (ContextMeter) frozen at its resting look — a quiet track with one filled
+ * quarter arc. A restyle of the live ring revisits this copy.
+ */
+export const IconCompactOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <circle cx="8" cy="8" r="6.4" stroke="currentColor" strokeWidth="1.6" opacity="0.35" />
+    <path d="M8 1.6A6.4 6.4 0 0 1 14.4 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
+  </svg>
+)
+
+/**
+ * The permission shield contour on the 16 grid (design set 1556), stroked at
+ * {@link SHIELD_OUTLINE_STROKE}. The composer's permission selector composes
+ * its mode marks (check, pencil, exclamation) over this same path inside one
+ * svg, so the geometry lives here once.
+ */
+export const SHIELD_OUTLINE_PATH = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
+
+/** Stroke width of {@link SHIELD_OUTLINE_PATH}. */
+export const SHIELD_OUTLINE_STROKE = '1.31831'
+
+/** Permission row glyph of the composer menu: the shield contour alone, without a mode mark. */
+export const IconShieldOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
+  </svg>
+)

+ 22 - 8
packages/client/ui-primitives/src/rank-by-name.ts

@@ -1,8 +1,9 @@
 /**
  * Shared ranking for `/` menu candidates: the query must be a
- * case-insensitive ordered subsequence of the candidate name. Prefix hits
- * rank first, then the strongest alignment score, then the source order of
- * the input. Decision record:
+ * case-insensitive ordered subsequence of the candidate name or, when the
+ * candidate carries one, of its display label (a localized title). Prefix
+ * hits rank first, then the strongest alignment score over either key, then
+ * the source order of the input. Decision record:
  * .agents/notes/archived/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
  */
 
@@ -59,19 +60,32 @@ function alignmentScore(name: string, query: string): number | undefined {
 
 /**
  * Rank named items by a menu query.
- * @param items - candidates in source order (a host catalog, then client contributions).
+ * @param items - candidates in source order (a host catalog, then client
+ * contributions); an item's optional `label` is a second search key beside
+ * its name.
  * @param rawQuery - the text typed after the trigger, matched case-insensitively.
  * @returns the matching items: prefix hits first, then by alignment score,
  * then in source order. The input list itself for an empty query.
  */
-export function rankByName<T extends { readonly name: string }>(items: readonly T[], rawQuery: string): readonly T[] {
+export function rankByName<T extends { readonly name: string; readonly label?: string }>(
+  items: readonly T[],
+  rawQuery: string,
+): readonly T[] {
   const query = rawQuery.toLowerCase()
   if (query === '') return items
   const ranked: Ranked<T>[] = []
   items.forEach((item, index) => {
-    const name = item.name.toLowerCase()
-    const score = alignmentScore(name, query)
-    if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score })
+    const keys = item.label === undefined ? [item.name] : [item.name, item.label]
+    let prefix = false
+    let score: number | undefined
+    for (const key of keys) {
+      const lower = key.toLowerCase()
+      const keyScore = alignmentScore(lower, query)
+      if (keyScore === undefined) continue
+      prefix ||= lower.startsWith(query)
+      score = score === undefined ? keyScore : Math.max(score, keyScore)
+    }
+    if (score !== undefined) ranked.push({ item, index, prefix, score })
   })
   ranked.sort((left, right) =>
     Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.