Kaynağa Gözat

feat(web): group, localize, and re-present the composer command menu

The + button and a typed / open one menu with an Add section (File, Goal,
Plan, Feedback) and a Commands section (Compact, Permission, Model, Export)
in usage order; rows carry a glyph, a localized title and description, and
the command name as an alias where the localized title differs. Built-in
Host rows get their face from the ui-commands dictionaries when the catalog
description is the canonical English text. A menu pick fills the locale's
claim token (/计划 ) while the submission executes the catalog name, and a
typed localized token resolves on Space and Enter in every locale. The File
row is a new action contribution kind that opens the composer's file picker
through the scoped slash/input-pick-files event; the paperclip button is
removed and the + button reads "Add files or run commands".
creatixchu 1 hafta önce
ebeveyn
işleme
5b1bb021cf
100 değiştirilmiş dosya ile 853 ekleme ve 323 silme
  1. 6 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml
  2. 37 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
  3. 37 0
      .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md
  4. 1 3
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  5. 1 3
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  6. 1 3
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  7. 1 3
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  8. 1 3
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  9. 1 3
      apps/web/tests/expected/markdown-images/ui.expected.md
  10. 1 3
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  11. 1 3
      apps/web/tests/expected/math-rendering/ui.expected.md
  12. 1 3
      apps/web/tests/expected/reference-composer/order.expected.md
  13. 1 3
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  14. 1 3
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  15. 1 3
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  16. 1 3
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  17. 1 3
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  18. 1 3
      apps/web/tests/expected/steer-all/settled.expected.md
  19. 1 1
      apps/web/tests/goal-command-presentation.e2e.ts
  20. 5 4
      apps/web/tests/lifecycle-chrome.e2e.ts
  21. 1 1
      apps/web/tests/shipped-composition.e2e.ts
  22. 1 3
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  23. 1 1
      packages/client/connection/src/client/fixture.ts
  24. 2 2
      packages/client/ui-commands/README.i18n.yaml
  25. 7 3
      packages/client/ui-commands/README.md
  26. 7 3
      packages/client/ui-commands/README.zh.md
  27. 31 6
      packages/client/ui-commands/src/client/contract.ts
  28. 28 4
      packages/client/ui-commands/src/client/index.ts
  29. 53 2
      packages/client/ui-commands/src/client/locales.ts
  30. 132 0
      packages/client/ui-commands/src/client/presentation.ts
  31. 73 38
      packages/client/ui-commands/src/client/service.ts
  32. 23 1
      packages/client/ui-commands/tests/browser-plugin.client.spec.ts
  33. 133 6
      packages/client/ui-commands/tests/service.client.spec.ts
  34. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  35. 1 1
      packages/client/ui-conversation/README.md
  36. 1 1
      packages/client/ui-conversation/README.zh.md
  37. 13 0
      packages/client/ui-conversation/src/client/contract/input.ts
  38. 24 0
      packages/client/ui-conversation/src/client/input/facade.ts
  39. 1 0
      packages/client/ui-conversation/src/client/input/hub.ts
  40. 2 4
      packages/client/ui-conversation/src/client/locales.ts
  41. 12 15
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  42. 18 10
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  43. 1 1
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  44. 2 2
      packages/client/ui-input-trigger/README.i18n.yaml
  45. 1 2
      packages/client/ui-input-trigger/README.md
  46. 1 2
      packages/client/ui-input-trigger/README.zh.md
  47. 14 0
      packages/client/ui-input-trigger/src/client/MenuView.module.css
  48. 11 4
      packages/client/ui-input-trigger/src/client/MenuView.tsx
  49. 13 2
      packages/client/ui-input-trigger/src/types.ts
  50. 20 0
      packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
  51. 5 4
      packages/client/ui-model-selection/src/client/index.ts
  52. 2 0
      packages/client/ui-model-selection/src/client/locales.ts
  53. 13 8
      packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
  54. 15 11
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  55. 22 8
      packages/client/ui-primitives/src/rank-by-name.ts
  56. 14 0
      packages/client/ui-primitives/tests/rank-by-name.client.spec.ts
  57. 1 1
      packages/feedback/command-feedback/src/index.ts
  58. 1 1
      packages/feedback/command-feedback/tests/command-feedback.spec.ts
  59. 1 1
      packages/goal/command-goal/src/index.ts
  60. 1 1
      packages/goal/command-goal/tests/command-goal.spec.ts
  61. 1 3
      snapshots/web/bash-abort-row/ui.expected.md
  62. 1 3
      snapshots/web/cordis-tool-round/ui.expected.md
  63. 1 3
      snapshots/web/feedback-command/ack-expanded.expected.md
  64. 1 3
      snapshots/web/feedback-command/ack.expected.md
  65. 1 3
      snapshots/web/feedback-release/ack-expanded.expected.md
  66. 1 3
      snapshots/web/feedback-release/ack.expected.md
  67. 1 3
      snapshots/web/file-upload-round/ui.expected.md
  68. 1 3
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  69. 1 3
      snapshots/web/fresh-round-trip/ui.expected.md
  70. 1 3
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  71. 1 3
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  72. 1 1
      snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md
  73. 9 7
      snapshots/web/lifecycle-chrome/command-menu.expected.md
  74. 1 3
      snapshots/web/lifecycle-chrome/hero.expected.md
  75. 1 3
      snapshots/web/lifecycle-chrome/plan-active.expected.md
  76. 1 3
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
  77. 1 3
      snapshots/web/lifecycle-chrome/reloaded.expected.md
  78. 1 3
      snapshots/web/live-interactions/cancel-expanded.expected.md
  79. 1 3
      snapshots/web/live-interactions/cancel.expected.md
  80. 1 3
      snapshots/web/live-interactions/error-auth.expected.md
  81. 1 3
      snapshots/web/live-interactions/loading.expected.md
  82. 1 3
      snapshots/web/live-interactions/retry-exhausted.expected.md
  83. 1 3
      snapshots/web/live-interactions/retry-expanded.expected.md
  84. 1 3
      snapshots/web/live-interactions/retry.expected.md
  85. 1 3
      snapshots/web/live-interactions/running-draft.expected.md
  86. 1 3
      snapshots/web/message-actions/ui.expected.md
  87. 1 3
      snapshots/web/minimal-preset/ui.expected.md
  88. 1 3
      snapshots/web/plan-review/approved-expanded.expected.md
  89. 1 3
      snapshots/web/plan-review/approved.expected.md
  90. 1 3
      snapshots/web/ptc-round/ui.expected.md
  91. 1 3
      snapshots/web/question-composer/answered-expanded.expected.md
  92. 1 3
      snapshots/web/question-composer/answered.expected.md
  93. 1 3
      snapshots/web/question-composer/cancelled.expected.md
  94. 1 3
      snapshots/web/queue-actions/collapsed.expected.md
  95. 1 3
      snapshots/web/queue-actions/editing.expected.md
  96. 1 3
      snapshots/web/queue-actions/failed.expected.md
  97. 1 3
      snapshots/web/queue-actions/layout.expected.md
  98. 1 3
      snapshots/web/queue-actions/preserved-expanded.expected.md
  99. 1 3
      snapshots/web/queue-actions/preserved.expected.md
  100. 1 3
      snapshots/web/queue-actions/sending.expected.md

+ 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: c8fc2226a15828de14f623fc582473e72e3ec1f1
+2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 5905c872ab583294aabf15a1b69f2dfe1312e4ba

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

@@ -0,0 +1,37 @@
+# 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 a client face from the `command` dictionaries — title, description, glyph, and claim token — when the catalog description equals the canonical English text; a scoped override or third-party command of the same name keeps its own description and only its section position follows the name. The English dictionary is byte-equal to the Host descriptors, and the `feedback` and `goal` descriptors are capitalized to match the other four. 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. `resolveCommandName` maps every localized token of every dictionary back to its command name, so `/计划` typed under any locale reaches the `plan` descriptor on Space and Enter, and a bare `/压缩` executes as `/compact`.
+
+The File row is an `action` contribution, a new `CommandUiSpec` kind whose bare invocation consumes the trigger token and runs a client callback without submitting anything; it therefore never refuses an attachment-carrying draft. The row raises the scoped `slash/input-pick-files` event, which `ui-conversation` routes to the composer's hidden file input through `ComposerKeyboard.bindFilePicker` under the same gate as drop intake; the paperclip button is removed. 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.
+
+## Alternatives considered
+
+**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; the client already owns product copy for every other surface (the locale-owned client UI copy decision), and the equality guard keeps a scoped or third-party descriptor verbatim.
+
+**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, the Host catalog, and the localized face are three separate facts joined by name and by the canonical English description; adding a built-in Host command means adding its `label.*`, `description.*`, and `token.*` keys, its glyph in `presentation.ts`, and its section position. The web e2e goldens and every expected ARIA snapshot that showed the paperclip or the `Commands` button changed in the same PR. A third-party command named like a built-in one is positioned by name but keeps its own description and gets no glyph, which is the intended reading of "the same name, another command".

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

@@ -0,0 +1,37 @@
+# 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`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。
+
+六个内置宿主命令在目录说明与规范英文文案相同时,从 `command` 词典取得客户端展示面:标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字。英文词典与宿主描述符逐字节相同,`feedback` 与 `goal` 的描述符改为首字母大写,与另外四个一致。贡献项携带 `label()`、`description()` 与 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。
+
+菜单选中填入当前语言的声明 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输的 token 保留手输的写法作为声明,因为 composer 从它持有的 token 之后读取参数。`resolveCommandName` 把每本词典里的每个本地化 token 映射回命令名,因此在任何语言下键入的 `/计划` 在空格与回车裁决中都到达 `plan` 描述符,裸 `/压缩` 执行为 `/compact`。
+
+「文件」行是一个 `action` 贡献项,这是 `CommandUiSpec` 新增的种类:裸调用消费掉触发 token 后运行一个客户端回调,不提交任何内容,因此绝不会拒绝带附件的草稿。该行触发会话作用域的 `slash/input-pick-files` 事件,`ui-conversation` 经 `ComposerKeyboard.bindFilePicker` 把它路由到 composer 隐藏的文件输入框,门槛与拖放入口相同;回形针按钮移除。`+` 按钮的无障碍名称与提示改为「添加文件或调用指令」。
+
+`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
+
+## 考虑过的替代方案
+
+**在宿主侧本地化说明。** 宿主没有语言设置,目录又被所有客户端共享;客户端已经拥有其他所有界面的产品文案(客户端界面文案归语言所有的决策),相等性判断还能让作用域覆盖或第三方描述符原样保留。
+
+**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。
+
+**在菜单旁保留回形针。** 设计稿与 issue 的验收条件都要求把入口整合进菜单且旧图标不再重复显示;隐藏的文件输入框与拖放门槛保持原位。
+
+**由贡献项拥有内置宿主行的文案。** 宿主包没有客户端一半可供注册,文案只能放在客户端;在已经拥有 `/` source 的包里用一张按名字索引的表,能把设计决策集中在一处。
+
+**只在当前语言下解析 `token` 别名。** 遍历所有词典解析没有成本,还能让在一种语言下持久化的草稿在另一种语言下提交。
+
+## 后果
+
+菜单、宿主目录与本地化展示面是三份独立事实,靠名字与规范英文说明连接;新增一个内置宿主命令意味着补上它的 `label.*`、`description.*`、`token.*` 键、`presentation.ts` 里的图标与小节位置。Web e2e 基准与所有曾显示回形针或 `Commands` 按钮的 ARIA 快照在同一 PR 内更新。与内置命令同名的第三方命令按名字定位,但保留自己的说明且没有图标,这正是「同名的另一个命令」应有的解读。

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

@@ -193,9 +193,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

@@ -51,9 +51,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

@@ -43,9 +43,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

@@ -17,9 +17,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

@@ -51,9 +51,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

@@ -30,9 +30,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

@@ -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: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
apps/web/tests/expected/math-rendering/ui.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/reference-composer/order.expected.md

@@ -16,9 +16,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

@@ -43,9 +43,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

@@ -35,9 +35,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

@@ -462,9 +462,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

@@ -34,9 +34,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

@@ -53,9 +53,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

@@ -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 - 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 ')

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

@@ -71,12 +71,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
 
   it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
-    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.waitFor({ 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')
@@ -95,7 +96,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)
@@ -112,10 +113,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/shipped-composition.e2e.ts

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

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

@@ -27,9 +27,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":

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

@@ -2220,7 +2220,7 @@ 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: '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 } },
         ],

+ 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: e1264d10bd8ff807d8f348339e0c055607579407
-README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea
+README.md: 31fd9925c08393735c56de891bafe985cc00b683
+README.zh.md: 76e4955014365f1c9890c98600c7cf1c347afbb0

+ 7 - 3
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`, registering a popupSelect spec (`/model`, `/permission`) or decorating an existing host command with a picker 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 `popupSelect`, and everything else is `execute`.
+Typing a `/` command in the composer opens the matching surface — a registered popup, a client action, 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`, registering a popupSelect spec (`/model`, `/permission`) or an action, or decorating an existing host command with a picker 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 `popupSelect` or `action`, and everything else is `execute`. 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.
 
 ## Table of Contents
 
@@ -29,7 +29,11 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
 
 ### Kinds and decorations
 
-A contribution is a client-owned command — a host-name collision fails loud. 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 loud. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, which submits nothing and therefore never refuses an attachment-carrying draft. The menu's File row is this package's own action; it opens the composer's file picker through the scoped `slash/input-pick-files` event wherever the composer accepts files, never on a subagent. A decoration adds a bare-invocation popup or action 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 and titles; prefixes rank first, and a typed query lists the matches flat without the section headings.
+
+### Built-in row faces
+
+Host descriptors carry English text only, so `src/client/presentation.ts` owns the client face of the six built-in Host commands: a catalog row whose description equals the canonical English text gets its localized title, description, glyph, and claim token from the `command` dictionaries, while a scoped override or third-party command of the same name keeps its own description and only its section position follows the name; rows outside both section lists close the Commands section in catalog order. Under Chinese, picking Plan fills the composer with `/计划 ` and the submission still executes `/plan `; a typed `/计划` or `/目标` resolves the same way on Space and Enter in every locale, so a draft written under one locale submits under another. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass, so a locale change reaches the next menu open without re-registration.
 
 ### 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` 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. `presentation.ts` holds the section lists, the built-in row faces, and the localized-token resolution; `candidates` sections an empty query and ranks a typed one through the shared `rankByName`.
 
 </details>
 

+ 7 - 3
packages/client/ui-commands/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`),或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。
+在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、客户端动作、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`)或动作,或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect` 或 `action`,其余全部是 `execute`。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
 
 ## 目录
 
@@ -29,7 +29,11 @@ kind: "package-reference"
 
 ### 种类与装饰
 
-贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。
+贡献项是客户端自有命令——与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用在消费掉触发 token 之后运行的一个回调,它不提交任何内容,因此绝不会拒绝带附件的草稿。菜单里的「文件」行就是本包自己的动作;只要 composer 接受文件,它就经会话作用域的 `slash/input-pick-files` 事件打开 composer 的文件选择器,子智能体上不提供。装饰为**已存在的**宿主命令添加裸调用弹窗或动作:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名与标题的子序列;前缀排名最高,输入查询后匹配项平铺列出,不再带小节标题。
+
+### 内置行的展示面
+
+宿主描述符只携带英文文案,因此 `src/client/presentation.ts` 拥有六个内置宿主命令在客户端的展示面:目录行的说明与规范英文文案相同时,它从 `command` 词典取得本地化的标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字;不在两个小节清单里的行按目录顺序排在「指令」小节末尾。中文下选中「计划」会在 composer 填入 `/计划 `,提交仍执行 `/plan `;键入的 `/计划` 或 `/目标` 在任何语言下的空格与回车裁决中都同样解析,因此在一种语言下写的草稿在另一种语言下照样能提交。贡献项自带 `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` 是固定的业务约定:`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`,按会话解析。`presentation.ts` 持有小节清单、内置行的展示面与本地化 token 的解析;`candidates` 对空查询分小节、对输入的查询经共享的 `rankByName` 排序。
 
 </details>
 

+ 31 - 6
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 {
@@ -31,26 +33,49 @@ export interface SelectOption {
  * The shell component is owned by ui-commands; business never sees it. Both
  * callbacks receive the ClientSessionContext captured at popup open.
  */
-export type CommandUiSpec = {
+export interface PopupSelectSpec {
   readonly kind: 'popupSelect'
   options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
   onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
 }
 
+/**
+ * Business registration for the action command kind: a bare invocation
+ * consumes the trigger token and runs one client-side callback (the menu's
+ * File row opens the composer's file picker). It submits nothing, so an
+ * attachment-carrying draft never refuses it.
+ */
+export interface ActionSpec {
+  readonly kind: 'action'
+  /**
+   * Run the action for one session.
+   * @param session - the ClientSessionContext captured at invocation.
+   */
+  run(session: ClientSessionContext): void
+}
+
+/** The UI behavior of a contribution or decoration. */
+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
-  /** Menu row description. */
-  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 (this phase: popupSelect only). */
+  /** The command's UI behavior. */
   readonly ui: CommandUiSpec
 }
 
@@ -68,7 +93,7 @@ export interface CommandDecoration {
   readonly name: string
   /** Capability filter, called with a fresh projection per bare invocation. */
   available(session: ClientSessionContext): boolean
-  /** The bare-invocation UI (this phase: popupSelect only). */
+  /** The bare-invocation UI. */
   readonly ui: CommandUiSpec
 }
 

+ 28 - 4
packages/client/ui-commands/src/client/index.ts

@@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 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 { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { CommandUiRuntime } from './service.ts'
 import type { PopupSelectInjected } from './PopupSelectView.tsx'
 import { PopupSelectView } from './PopupSelectView.tsx'
@@ -27,9 +28,12 @@ export { filterOptions, PopupSelectController } from './popup.ts'
 export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
 export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
 export type {
-  CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption,
+  ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec,
+  SelectConfirmation, SelectOption,
 } from './contract.ts'
 export type { CommandKey } from './locales.ts'
+export { SECTION_ROWS, resolveCommandName } from './presentation.ts'
+export type { MenuSection } from './presentation.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -39,7 +43,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
   }
 }
@@ -51,13 +55,33 @@ 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.
+ * Client plugin body: mount the service, register the menu's File row (an
+ * action opening the composer's file picker wherever the composer accepts
+ * files — never on a subagent), then register the popupSelect shell into the
+ * input overlay once its declarer is up.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries')
   ctx.plugin(CommandUiRuntime)
+  const t = ctx.locale.bind(NS)
+  ctx.inject(['commandUi', 'sessions'], (scope: ClientContext) => {
+    const command = scope.commandUi
+    const sessions = scope.get('sessions') as ISessions
+    scope.effect(() => command.register({
+      name: 'file',
+      label: () => t('label.file'),
+      icon: IconPaperclipOutline16,
+      available: session => sessions.subagentAddress(session.sessionId) === undefined,
+      ui: {
+        kind: 'action',
+        run: (session) => {
+          const actx = sessions.scope(session.sessionId)
+          if (actx !== undefined) actx.bail(actx, 'slash/input-pick-files')
+        },
+      },
+    }), 'ui-commands: File row')
+  })
   ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => {
     const command = scope.commandUi
     const sessions = scope.get('sessions') as ISessions

+ 53 - 2
packages/client/ui-commands/src/client/locales.ts

@@ -1,7 +1,33 @@
-/** `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, the File row's
+ * title, and the popupSelect shell's copy.
+ */
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
+  'section.add': '添加',
+  'section.commands': '指令',
+  'label.file': '文件',
+  'label.goal': '目标',
+  'label.plan': '计划',
+  'label.feedback': '反馈',
+  'label.compact': '压缩',
+  'label.permission': '权限',
+  'label.export': '下载日志',
+  'description.goal': '设置或查看长期任务的目标',
+  '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': '正在加载选项…',
@@ -15,8 +41,33 @@ export const zh = {
 /** The command namespace key union. */
 export type CommandKey = keyof typeof zh
 
-/** English dictionary, checked complete against the zh key set. */
+/**
+ * English dictionary, checked complete against the zh key set. Each
+ * `description.*` entry is byte-equal to the Host descriptor it localizes:
+ * that equality is how a catalog row is recognized as the built-in command.
+ */
 export const en = {
+  'section.add': 'Add',
+  'section.commands': 'Commands',
+  'label.file': 'File',
+  '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.permission': 'Switch the permission preset (sandbox mode + approval policy)',
+  '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…',

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

@@ -0,0 +1,132 @@
+/**
+ * Design-owned presentation of the composer menu (design doc for #3567):
+ * the two sections with their usage order, and the client face of the
+ * built-in Host commands — localized title, description, claim token, and
+ * icon — whose catalog descriptors carry English text only. A catalog row is
+ * the built-in command when its description equals the canonical English
+ * text; a scoped override or third-party command of the same name keeps its
+ * own description and title, and only its section position follows the name.
+ */
+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 { en, zh } from './locales.ts'
+import type { CommandKey } from './locales.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. */
+export 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 token: CommandKey
+  readonly icon: ComponentType<IconProps>
+}
+
+/** One built-in Host command's face, keyed by its dictionary entries. */
+function hostFace(name: string, icon: ComponentType<IconProps>): readonly [string, HostFace] {
+  return [name, {
+    label: `label.${name}` as CommandKey,
+    description: `description.${name}` as CommandKey,
+    token: `token.${name}` as CommandKey,
+    icon,
+  }]
+}
+
+/** Built-in Host commands whose client face this package owns. */
+const HOST_FACES: ReadonlyMap<string, HostFace> = new Map([
+  hostFace('goal', IconGoalOutline16),
+  hostFace('plan', IconPlanOutline14),
+  hostFace('feedback', IconSendOutline16),
+  hostFace('compact', IconCompactOutline16),
+  hostFace('permission', IconShieldOutline16),
+  hostFace('export', IconDownloadOutline16),
+])
+
+/** The face of the built-in command a catalog row is, or undefined when the description is not the canonical English text. */
+function builtinFace(name: string, description: string): HostFace | undefined {
+  const face = HOST_FACES.get(name)
+  return face !== undefined && en[face.description] === description ? face : undefined
+}
+
+/**
+ * The localized menu face of a catalog row.
+ * @param name - catalog command name.
+ * @param description - the catalog description.
+ * @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(
+  name: string,
+  description: string,
+  t: TranslateNS<'command'>,
+): Pick<InputTriggerCandidate, 'label' | 'description' | 'icon'> | undefined {
+  const face = builtinFace(name, description)
+  return face === undefined ? undefined : { label: t(face.label), description: t(face.description), icon: face.icon }
+}
+
+/**
+ * The claim token of a catalog row in the current locale (the text the
+ * composer shows after a pick): the localized token of a built-in command,
+ * the name itself for any other row.
+ * @param name - catalog command name.
+ * @param description - the catalog description.
+ * @param t - the `command` namespace translator.
+ * @returns the token without its leading slash.
+ */
+export function claimToken(name: string, description: string, t: TranslateNS<'command'>): string {
+  const face = builtinFace(name, description)
+  return face === undefined ? name : t(face.token)
+}
+
+/** Every localized claim token of every dictionary → the command name it stands for. */
+const TOKEN_ALIASES: ReadonlyMap<string, string> = new Map(
+  [...HOST_FACES].flatMap(([name, face]) =>
+    [zh[face.token], en[face.token]].filter(token => token !== name).map(token => [token, name] as const)),
+)
+
+/**
+ * The command name a typed token stands for: a localized claim token of a
+ * built-in command in any dictionary resolves to that command, so a draft
+ * written under one locale still submits under another.
+ * @param token - the typed name without its leading slash.
+ * @returns the catalog command name.
+ */
+export function resolveCommandName(token: string): string {
+  return TOKEN_ALIASES.get(token) ?? token
+}
+
+/**
+ * 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]
+}

+ 73 - 38
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'
@@ -27,6 +29,7 @@ import type { CommandContribution, CommandDecoration, CommandUiContract } from '
 import type { CommandDescriptor } from './directory.ts'
 import { CommandDirectory } from './directory.ts'
 import { PopupSelectController } from './popup.ts'
+import { builtinRowFace, claimToken, resolveCommandName, sectionRows } from './presentation.ts'
 import type { TokenSegment } from './popup.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -185,47 +188,58 @@ 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[] = []
     const seen = new Set<string>()
     for (const c of list) {
       seen.add(c.name)
-      rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) })
+      rows.push({
+        name: c.name,
+        ...(builtinRowFace(c.name, c.description, this.t) ?? { description: c.description }),
+        ...(c.input !== undefined ? { hint: c.input.hint } : {}),
+      })
     }
     for (const contribution of this.live.contributions.values()) {
       if (!contribution.available(session)) continue
       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,
-    )
+    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; host input → claim; host bare → detached execute. */
+  /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */
   private dispatch(pick: InputTriggerPick): PickOutcome {
     const name = pick.candidate.name
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(pick.session)) {
-      this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
+      this.invoke(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
     const desc = this.directory.resolve(pick.session.sessionId, name)
     if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
-    // A decoration replaces the HOST row's bare invocation with its popup;
-    // it decorates only a resolvable host command (checked above), never
-    // manufactures one, and never touches the argument claim below.
+    // A decoration replaces the HOST row's bare invocation with its popup or
+    // action; it decorates only a resolvable host command (checked above),
+    // never manufactures one, and never touches the argument claim below.
     const decoration = this.live.decorations.get(name)
     if (decoration !== undefined && decoration.available(pick.session)) {
-      this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
+      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.name, desc.description, 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 })
@@ -236,11 +250,11 @@ 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 kinds never claim on space
+    const name = resolveCommandName(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 (desc === undefined || desc.input === undefined) return undefined
-    return { claim: this.leadingClaim(desc, session) }
+    return { claim: this.leadingClaim(desc, session, token.slice(1)) }
   }
 
   /**
@@ -250,10 +264,14 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * args-tolerant.
    *
    * Envelope policy: an enter submission carrying attachments resolves only
-   * through a command declaring attachment acceptance. Every other command route —
-   * popup, non-accepting claim, bare detached execute — throws the refusal
-   * so the machine surfaces one composer notice and the draft and attachments
-   * stay in place; nothing executes and nothing is dropped.
+   * through a command declaring attachment acceptance. Every other submitting
+   * route — popup, non-accepting claim, bare detached execute — throws the
+   * 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,
@@ -266,16 +284,18 @@ 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)
+    const name = resolveCommandName(token.slice(1))
     if (name === '') return undefined
+    // The Host line always carries the catalog name.
+    const canonical = `/${name}${trimmed.slice(token.length)}`
     const refuseAttachments = (): never => {
       throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
     }
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(session)) {
       if (!bare) return undefined
-      if (envelope.attachments > 0) refuseAttachments()
-      this.openPopup(name, contribution.ui, session, { via: 'enter', token })
+      if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments()
+      this.invoke(name, contribution.ui, session, { via: 'enter', token })
       return 'handled'
     }
     await this.directory.ensureReady(session.sessionId, signal)
@@ -286,42 +306,57 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     if (bare) {
       const decoration = this.live.decorations.get(name)
       if (decoration !== undefined && decoration.available(session)) {
-        if (envelope.attachments > 0) refuseAttachments()
-        this.openPopup(name, decoration.ui, session, { via: 'enter', token })
+        if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments()
+        this.invoke(name, decoration.ui, session, { via: 'enter', token })
         return 'handled'
       }
     }
     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'
   }
 
-  /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
-  private openPopup(
+  /**
+   * Invoke one contribution or decoration (menu pick / bare enter): open the
+   * session's popup, or consume the token and run the action.
+   */
+  private invoke(
     name: string,
     ui: CommandContribution['ui'],
     session: ClientSessionContext,
     segment: TokenSegment,
   ): void {
+    if (ui.kind === 'action') {
+      this.consumeVia(session.sessionId, segment)
+      ui.run(session)
+      return
+    }
     const actx = this.scopeFor(session.sessionId)
     if (actx === undefined) return
     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 {
       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),
     }
   }
 

+ 23 - 1
packages/client/ui-commands/tests/browser-plugin.client.spec.ts

@@ -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,25 @@ describe('apply', () => {
     expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
   })
 
+  it('registers the File row: an action that consumes the token and opens the scoped file picker; absent on a subagent', async () => {
+    const { sources, mint } = await bench()
+    const source = sources.get('/ command')!
+    const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal }
+    const rows = await source.candidates({ sessionId: sid('s1') }, req)
+    expect(rows).toEqual([{ name: 'file', label: 'File', icon: expect.any(Function) as never, section: 'Add' }])
+    expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([])
+    const scope = mint('s1')
+    const picks: string[] = []
+    scope.ctx.on('slash/input-consume-token', () => { picks.push('consume'); return true })
+    scope.ctx.on('slash/input-pick-files', () => { picks.push('pick-files'); return true })
+    const span = { start: 0, end: 5, draftRev: 1 }
+    expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('s1') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled')
+    expect(picks).toEqual(['consume', 'pick-files'])
+    // A session without a live scope runs nothing and throws nothing.
+    expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('gone') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled')
+    expect(picks).toHaveLength(2)
+  })
+
   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

+ 133 - 6
packages/client/ui-commands/tests/service.client.spec.ts

@@ -13,8 +13,9 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
 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, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
+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'
 
@@ -155,7 +156,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi
   return source.onPick(pick)
 }
 
-const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
+const themeUi = (over: Partial<PopupSelectSpec> = {}): PopupSelectSpec => ({
   kind: 'popupSelect',
   options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
   onSelect: () => undefined,
@@ -164,7 +165,7 @@ const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
 
 const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
   name: 'theme',
-  description: 'client popup kind',
+  description: () => 'client popup kind',
   available: () => true,
   ui: themeUi(),
   ...over,
@@ -224,7 +225,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 () => {
@@ -238,7 +239,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')
@@ -257,6 +258,132 @@ describe('candidates', () => {
     await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
   })
 
+  describe('menu presentation (design doc for #3567)', () => {
+    /** The shipped catalog in Host registration order, with the canonical English descriptions. */
+    const SHIPPED: CommandDescriptor[] = [
+      { name: 'compact', description: 'Compact older conversation history' },
+      { name: 'export', description: 'Download this Session log as a ZIP archive' },
+      { name: 'feedback', description: 'Record feedback about this session', input: { hint: '<text>' } },
+      { 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 } },
+      { 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 Host row of a built-in name with another description is not the built-in command', async () => {
+      const commands: CommandDescriptor[] = [{ name: 'goal', description: 'scoped 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: 'scoped goal', hint: 'x', section: 'command:section.add' })
+    })
+
+    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('/计划 ')
+      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)', () => {
@@ -271,7 +398,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: e06270a339a23fa17fb9d51642317fcf2aec6c0f
-README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024
+README.md: cefb6f543151b4d52a817ef1539063c4b3207661
+README.zh.md: 385547e06bc90d9a46a2a0b416054efd35acded7

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

@@ -48,7 +48,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.
 
-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)).
+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)).
 
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries

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

@@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
 
-普通 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))。
+普通 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))。
 
 <a id="temporary-composer-entries"></a>
 ## 临时 composer entry

+ 13 - 0
packages/client/ui-conversation/src/client/contract/input.ts

@@ -167,6 +167,12 @@ declare module '@deepseek-ai/cordis' {
      * @mode bail
      */
     'slash/input-insert-text'(request: InsertTextRequest): true | undefined
+    /**
+     * Open the composer's file picker for the scoped session (the menu's
+     * File row); true when a mounted composer accepting files opened it.
+     * @mode bail
+     */
+    'slash/input-pick-files'(): true | undefined
   }
 }
 
@@ -282,6 +288,13 @@ export interface ComposerKeyboard {
   space(): boolean
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   dismissPopup(): void
+  /**
+   * Bind the composer's file-picker opener for the scoped pick-files event;
+   * the composer decides inside `open` whether intake is currently allowed.
+   * @param open - opens the native file dialog.
+   * @returns the unbind disposer.
+   */
+  bindFilePicker(open: () => void): () => void
 }
 
 /** One independently addressable row projected from the transient queue snapshot. */

+ 24 - 0
packages/client/ui-conversation/src/client/input/facade.ts

@@ -154,6 +154,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 filePickerFn: (() => void) | 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. */
@@ -607,6 +609,28 @@ export class SessionInputShell implements SessionInput {
     }
   }
 
+  /**
+   * Bind the composer's file-picker opener (ComposerKeyboard face; unbind on unmount).
+   * @param open - opens the native file dialog.
+   * @returns the unbind disposer.
+   */
+  bindFilePicker(open: () => void): () => void {
+    this.filePickerFn = open
+    return () => {
+      if (this.filePickerFn === open) this.filePickerFn = undefined
+    }
+  }
+
+  /**
+   * Open the bound file picker (scoped pick-files event listener body).
+   * @returns whether a composer was bound to open it.
+   */
+  pickFiles(): boolean {
+    if (this.filePickerFn === undefined) return false
+    this.filePickerFn()
+    return true
+  }
+
   // ---- effect executor ----
 
   /** The claim token the decoration transform styles; null while unclaimed. */

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

@@ -121,6 +121,7 @@ export class InputHub implements SessionInputResolver {
           shell.consumeToken(req.guard) ? true : undefined),
         actx.on('slash/input-insert-text', req =>
           shell.insertText(req.text, req.span, req.continue === true) ? true : undefined),
+        actx.on('slash/input-pick-files', () => shell.pickFiles() ? true : undefined),
       ]
       return () => {
         for (const off of offs) off()

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

@@ -19,7 +19,7 @@ export const zh = {
   'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话',
   'placeholder.workspace': '选择一个工作区开始',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
-  'input.commands': '指令',
+  'input.commands': '添加文件或调用指令',
   'input.stop': '停止生成',
   'input.send': '发送消息',
   'input.send.queue': '排队发送',
@@ -49,7 +49,6 @@ export const zh = {
   'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
   'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
   'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
-  'file.attach': '添加附件',
   'file.pending': '待发送文件',
   'file.remove': '移除文件 {name}',
   'file.uploading': '上传中…',
@@ -185,7 +184,7 @@ 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.stop': 'Stop generating',
   'input.send': 'Send message',
   'input.send.queue': 'Queue message',
@@ -215,7 +214,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…',

+ 12 - 15
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,22 @@ 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,
   }
 
+  // The menu's File row opens the same hidden input the drop path feeds,
+  // under the drop gate the row itself cannot see.
+  useEffect(() => {
+    if (keyboard === undefined) return
+    return keyboard.bindFilePicker(() => {
+      if (gate.current.canAcceptDrop) fileInputRef.current?.click()
+    })
+  }, [keyboard])
+
   useEffect(() => {
     if (editor === null || keyboard === undefined) return
     return registerComposerKeymap(editor, {
@@ -491,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"

+ 18 - 10
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -873,7 +873,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: {
@@ -888,9 +888,13 @@ 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)
+    // The menu's File row reaches the same gate: a bound picker stays shut.
+    const click = vi.spyOn(HTMLInputElement.prototype, 'click')
+    expect(shell.pickFiles()).toBe(true)
+    expect(click).not.toHaveBeenCalled()
+    click.mockRestore()
     fireEvent.click(button)
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     fireEvent.click(interruptButton!)
@@ -923,10 +927,14 @@ 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')
+    expect(shell.pickFiles()).toBe(true)
+    expect(click).toHaveBeenCalledOnce()
+    click.mockRestore()
   })
 
   it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
@@ -944,7 +952,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)
@@ -996,7 +1004,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', () => {
@@ -1129,7 +1137,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' })
@@ -1432,7 +1440,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
@@ -1451,7 +1459,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 })
@@ -1621,7 +1629,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 })

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

@@ -317,7 +317,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')
   })
 

+ 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: 9ad568027e4be8f2e9cc641270cbf6b2f870c59c
-README.zh.md: 2926eb87d4738387cf6dc56d38a02f579ed267c4
+README.md: 60c96df61d12b9b3051faafb1a38ec18e5280dbe
+README.zh.md: 3f9b35d02367f386497facec1319e4fc12a81921

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

@@ -25,7 +25,7 @@ This package powers the input trigger pipeline of the Web GUI: it detects `/` an
 <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
 
@@ -74,7 +74,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>

+ 1 - 2
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。
 
 ### 键盘与鼠标
 
@@ -74,7 +74,6 @@ kind: "package-reference"
 这些限制界定了当前触发流水线。它们是当前包约束,不是通用菜单对比或任务积压。
 
 - **只有全局 source 层**——会话 scope 的 source 注册(逐会话遮蔽)已有设计但未启用;台账记录着触发条件,即真实的逐会话 source 需求。
-- **`InputTriggerCandidate.icon` 以文本渲染**——`MenuView` 把该字符串原样放进图标位;与设计系统图标枚举的接入将在该枚举交付后完成。
 - **overlay 的 SlotMap 合并归属与槽位所有权分离**——唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
 
 <a id="dev-note"></a>

+ 14 - 0
packages/client/ui-input-trigger/src/client/MenuView.module.css

@@ -86,12 +86,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);
 }
 

+ 11 - 4
packages/client/ui-input-trigger/src/client/MenuView.tsx

@@ -7,8 +7,10 @@
  * 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 clsx from 'clsx'
@@ -150,10 +152,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, 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

+ 20 - 0
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: [] }],

+ 5 - 4
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'
@@ -120,16 +121,16 @@ export function apply(ctx: ClientContext): void {
   // a locale change reaches the next publish.
   ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') })
 
-  // Entry 1: the /model popupSelect over the shared directory. The command
-  // description is registry-held text: it reads t() once at registration and
-  // refreshes only on re-registration, not on locale change.
+  // Entry 1: the /model popupSelect over the shared directory.
   ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => {
     const command = scope.get('commandUi') as CommandUiContract
     const models = scope.modelDirectories
     const sessions = scope.sessions
     scope.effect(() => command.register({
       name: 'model',
-      description: t('command.description'),
+      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}',
   'trigger.fallback': '选择模型',
@@ -35,6 +36,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}',
   'trigger.fallback': 'Select model',

+ 13 - 8
packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts

@@ -16,7 +16,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
-import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
+import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { ModelSelectInjected } from '../src/client/slots.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { zh } from '../src/client/locales.ts'
@@ -157,6 +157,11 @@ async function bench() {
   return {
     ctx, fiber, mint, calls, remote,
     contribution: () => contribution!,
+    popup: (): PopupSelectSpec => {
+      const ui = contribution!.ui
+      if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind')
+      return ui
+    },
     seat: () => seats.get('conversation.input.model')!,
     hostCurrent: () => selected,
     setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
@@ -182,7 +187,7 @@ describe('ui-model-selection dual entry', () => {
   it('popup options mark the host current active with the provider group in the detail', async () => {
     const b = await bench()
     b.mint('s1')
-    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
     expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
     expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
     expect(options[1]?.active).toBeUndefined()
@@ -209,7 +214,7 @@ describe('ui-model-selection dual entry', () => {
       reasoningEffort: 'max',
     })
     // The POPUP's next options pass reflects it without a seat-side reload.
-    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
     expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
   })
 
@@ -217,9 +222,9 @@ describe('ui-model-selection dual entry', () => {
     const b = await bench()
     b.mint('s1')
     const seatFace = b.seat().inject!(sid('s1'))
-    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
     const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
-    await b.contribution().ui.onSelect(pro, projection('s1'))
+    await b.popup().onSelect(pro, projection('s1'))
     expect(seatFace.directory.getSnapshot().current).toEqual({
       provider: 'deepseek-official',
       model: 'deepseek-v4-pro',
@@ -239,8 +244,8 @@ describe('ui-model-selection dual entry', () => {
     // The service face resolves the same instance the seat inject handed out.
     expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
     await Promise.all([
-      b.contribution().ui.options(projection('a'), new AbortController().signal),
-      b.contribution().ui.options(projection('b'), new AbortController().signal),
+      b.popup().options(projection('a'), new AbortController().signal),
+      b.popup().options(projection('b'), new AbortController().signal),
     ])
     expect(b.calls.models).toBe(1)
   })
@@ -369,7 +374,7 @@ describe('ui-model-selection dual entry', () => {
     b.address(sid('child'))
 
     expect(b.contribution().available(projection('child'))).toBe(false)
-    await expect(b.contribution().ui.options(
+    await expect(b.popup().options(
       projection('child'),
       new AbortController().signal,
     )).rejects.toThrow(/unavailable for addressed subagent/)

+ 15 - 11
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -15,7 +15,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
+import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
 import {
   PermissionRow, type PermissionRowInjected,
@@ -82,6 +82,11 @@ async function bench() {
     ctx, fiber, locale, values, commands, remote,
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     decoration: () => decoration,
+    popup: (): PopupSelectSpec => {
+      const ui = decoration!.ui
+      if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind')
+      return ui
+    },
     permissionRow: () => ctx.slots.entries('settings.general.item')
       .find(entry => entry.component === PermissionRow),
   }
@@ -110,11 +115,11 @@ describe('ui-permission browser plugin', () => {
     expect(c.available(proj)).toBe(false)
     b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
     expect(c.available(proj)).toBe(true)
-    const options = await c.ui.options(proj, new AbortController().signal)
+    const options = await b.popup().options(proj, new AbortController().signal)
     expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
     expect(options.every(option => option.active !== true)).toBe(true)
     b.values.set(sid('s1'), SELECT)
-    const again = await c.ui.options(proj, new AbortController().signal)
+    const again = await b.popup().options(proj, new AbortController().signal)
     expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
     // English built-ins use product labels; other kebab-case names title-case.
@@ -127,7 +132,7 @@ describe('ui-permission browser plugin', () => {
       confirmLabel: 'Enable Full access',
     })
     b.locale.setLocale('zh')
-    const localized = await c.ui.options(proj, new AbortController().signal)
+    const localized = await b.popup().options(proj, new AbortController().signal)
     expect(localized.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限'])
     expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
       title: '确认启用完全权限?',
@@ -143,28 +148,27 @@ describe('ui-permission browser plugin', () => {
       { value: '__proto__', name: '__proto__' },
       { value: 'plain', name: 'Ask Every Time' },
     ] })
-    const passthrough = await c.ui.options(proj, new AbortController().signal)
+    const passthrough = await b.popup().options(proj, new AbortController().signal)
     expect(passthrough.map(option => option.label)).toEqual([
       'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
     ])
     // A projection that vanished between availability and open throws.
-    expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
+    expect(() => b.popup().options({ sessionId: sid('ghost') }, new AbortController().signal))
       .toThrow(/not available on this host/)
   })
 
   it('a pick submits the /permission line; rejection and unmatched throw', async () => {
     const b = await bench()
-    const c = b.decoration()!
     const proj = { sessionId: sid('s1') }
     b.values.set(sid('s1'), SELECT)
-    await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
+    await b.popup().onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
     expect(b.commands).toEqual(['/permission danger-full-access'])
     b.setResult({ ok: false })
-    await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
+    await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
     b.setResult({ ok: true, matched: false })
-    await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
+    await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
     // An unmaterialized session throws before any submit.
-    await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
+    await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
       .rejects.toThrow(/not materialized/)
   })
 

+ 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)

+ 14 - 0
packages/client/ui-primitives/tests/rank-by-name.client.spec.ts

@@ -34,6 +34,20 @@ describe('rankByName', () => {
     expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab'])
   })
 
+  it('matches the display label as a second key and keeps the stronger alignment', () => {
+    const items = [
+      { name: 'goal', label: '目标' },
+      { name: 'plan', label: '计划' },
+      { name: 'permission', label: '权限' },
+    ]
+    expect(names(rankByName(items, '目标'))).toEqual(['goal'])
+    expect(names(rankByName(items, 'goal'))).toEqual(['goal'])
+    expect(names(rankByName(items, 'p'))).toEqual(['plan', 'permission'])
+    expect(names(rankByName(items, '划'))).toEqual(['plan'])
+    // A label prefix hit ranks like a name prefix hit.
+    expect(names(rankByName([{ name: 'xplan', label: 'plan' }, { name: 'plant' }], 'pla'))).toEqual(['xplan', 'plant'])
+  })
+
   it('returns the ranked items with their payload intact', () => {
     const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }]
     expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }])

+ 1 - 1
packages/feedback/command-feedback/src/index.ts

@@ -60,7 +60,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
 export function apply(ctx: Context): void {
   ctx.commands.register({
     name: 'feedback',
-    description: 'record feedback about this session',
+    description: 'Record feedback about this session',
     input: { hint: '<text>' },
     recordInput: false,
     handler: executeFeedbackCommand,

+ 1 - 1
packages/feedback/command-feedback/tests/command-feedback.spec.ts

@@ -89,7 +89,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => {
 
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
       name: 'feedback',
-      description: 'record feedback about this session',
+      description: 'Record feedback about this session',
       input: { hint: '<text>' },
     })
     expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })

+ 1 - 1
packages/goal/command-goal/src/index.ts

@@ -189,7 +189,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
 export function apply(ctx: Context): void {
   ctx.commands.register({
     name: 'goal',
-    description: 'set or view the goal for a long-running task',
+    description: 'Set or view the goal for a long-running task',
     input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
     handler: invocation => executeGoalCommand(ctx, invocation),
   })

+ 1 - 1
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => {
 
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
       name: 'goal',
-      description: 'set or view the goal for a long-running task',
+      description: 'Set or view the goal for a long-running task',
       input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
     })
     expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()

+ 1 - 3
snapshots/web/bash-abort-row/ui.expected.md

@@ -29,9 +29,7 @@
   - img
   - text: "Failed Bash Error: tool call aborted before dispatch"
 - 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: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/cordis-tool-round/ui.expected.md

@@ -117,9 +117,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
snapshots/web/feedback-command/ack-expanded.expected.md

@@ -47,9 +47,7 @@
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - 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
snapshots/web/feedback-command/ack.expected.md

@@ -39,9 +39,7 @@
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - 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
snapshots/web/feedback-release/ack-expanded.expected.md

@@ -47,9 +47,7 @@
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - 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
snapshots/web/feedback-release/ack.expected.md

@@ -39,9 +39,7 @@
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - 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
snapshots/web/file-upload-round/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-Vision-Exp, reasoning effort high":

+ 1 - 3
snapshots/web/fresh-round-trip/ui-expanded.expected.md

@@ -51,9 +51,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
snapshots/web/fresh-round-trip/ui.expected.md

@@ -35,9 +35,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
snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md

@@ -229,9 +229,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
snapshots/web/goal-multi-turn-actions/ui.expected.md

@@ -117,9 +117,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
snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md

@@ -1,3 +1,3 @@
 - listbox "Trigger suggestions":
   - text: Commands
-  - option "compact Compact older conversation history" [selected]
+  - option "Compact Compact older conversation history" [selected]

+ 9 - 7
snapshots/web/lifecycle-chrome/command-menu.expected.md

@@ -1,9 +1,11 @@
 - listbox "Trigger suggestions":
+  - text: Add
+  - option "File" [selected]
+  - option "Goal Set or view the goal for a long-running task"
+  - option "Plan Enter or leave plan mode"
+  - option "Feedback Record feedback about this session"
   - text: Commands
-  - option "compact Compact older conversation history" [selected]
-  - option "export Download this Session log as a ZIP archive"
-  - option "feedback record feedback about this session"
-  - option "goal set or view the goal for a long-running task"
-  - option "permission Switch the permission preset (sandbox mode + approval policy)"
-  - option "plan Enter or leave plan mode"
-  - option "model Select the model for this conversation"
+  - option "Compact Compact older conversation history"
+  - option "Permission Switch the permission preset (sandbox mode + approval policy)"
+  - option "Model Select the model for this conversation"
+  - option "Export Download this Session log as a ZIP archive"

+ 1 - 3
snapshots/web/lifecycle-chrome/hero.expected.md

@@ -31,9 +31,7 @@
   - img
 - textbox "Describe what you want to build, / commands, @ files or sessions":
   - paragraph
-- 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
snapshots/web/lifecycle-chrome/plan-active.expected.md

@@ -30,9 +30,7 @@
   - text: Standard mode
   - img
 - textbox "Describe what you want to build, / 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 "Plan mode on, press to turn off": Plan

+ 1 - 3
snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md

@@ -43,9 +43,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
snapshots/web/lifecycle-chrome/reloaded.expected.md

@@ -35,9 +35,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
snapshots/web/live-interactions/cancel-expanded.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 - 3
snapshots/web/live-interactions/cancel.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
snapshots/web/live-interactions/error-auth.expected.md

@@ -26,9 +26,7 @@
   - text: This turn failedAPI key is invalid
   - code: AUTH
 - 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
snapshots/web/live-interactions/loading.expected.md

@@ -25,9 +25,7 @@
 - paragraph: partial
 - 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 DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/live-interactions/retry-exhausted.expected.md

@@ -28,9 +28,7 @@
   - text: This turn failedupstream 503
   - code: SERVER
 - 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
snapshots/web/live-interactions/retry-expanded.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
snapshots/web/live-interactions/retry.expected.md

@@ -35,9 +35,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
snapshots/web/live-interactions/running-draft.expected.md

@@ -26,9 +26,7 @@
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions":
   - paragraph: Queue this follow-up while the current turn is running.
-- 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
snapshots/web/message-actions/ui.expected.md

@@ -65,9 +65,7 @@
   - text: Ran for {{duration}}
 - text: 7/25 {{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
snapshots/web/minimal-preset/ui.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 - 3
snapshots/web/plan-review/approved-expanded.expected.md

@@ -61,9 +61,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
snapshots/web/plan-review/approved.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
snapshots/web/ptc-round/ui.expected.md

@@ -62,9 +62,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
snapshots/web/question-composer/answered-expanded.expected.md

@@ -53,9 +53,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
snapshots/web/question-composer/answered.expected.md

@@ -35,9 +35,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
snapshots/web/question-composer/cancelled.expected.md

@@ -32,9 +32,7 @@
   - listitem: Which color do you prefer?
 - button "Inspect"
 - 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
snapshots/web/queue-actions/collapsed.expected.md

@@ -26,9 +26,7 @@
 - status: Deep diving...
 - button "2 queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
-- 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
snapshots/web/queue-actions/editing.expected.md

@@ -42,9 +42,7 @@
     - button "Cancel editing":
       - img
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
-- 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
snapshots/web/queue-actions/failed.expected.md

@@ -1,9 +1,7 @@
 - alert: Queue submission failed (session/agent-busy)
 - textbox "Message or run a task, / commands, @ files or sessions":
   - paragraph: Queue submission to retry
-- 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
snapshots/web/queue-actions/layout.expected.md

@@ -42,9 +42,7 @@
   - img
 - button "2 queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
-- 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
snapshots/web/queue-actions/preserved-expanded.expected.md

@@ -59,9 +59,7 @@
     - button "Steer queued message" [disabled]:
       - 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
snapshots/web/queue-actions/preserved.expected.md

@@ -55,9 +55,7 @@
     - button "Steer queued message" [disabled]:
       - 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
snapshots/web/queue-actions/sending.expected.md

@@ -35,9 +35,7 @@
     - button "Steer queued message" [disabled]:
       - 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":

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor