Bladeren bron

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 4 weken geleden
bovenliggende
commit
5b1bb021cf
100 gewijzigde bestanden met toevoegingen van 853 en 323 verwijderingen
  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":
 - button "Back to bottom":
   - img
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Read Only"': Read Only
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Read Only"': Read Only
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
 - 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
   - img
   - text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
   - 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"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

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

@@ -16,9 +16,7 @@
   - img
   - img
   - text: Session recall Research notes
   - text: Session recall Research notes
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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":
 - button "Back to bottom":
   - img
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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":
 - button "Copy":
   - img
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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()
     const input = page.locator('[data-composer-input]').first()
     await input.fill('/go')
     await input.fill('/go')
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
     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 })
       .waitFor({ timeout: 10_000 })
     await input.press('Tab')
     await input.press('Tab')
     await expect.poll(() => input.textContent()).toBe('/goal ')
     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 () => {
   it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
     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()
     await launcher.click()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
     await menu.waitFor({ timeout: 10_000 })
     await menu.waitFor({ timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
+    expect(snapshot).toContain('text: Add')
     expect(snapshot).toContain('text: Commands')
     expect(snapshot).toContain('text: Commands')
     expect(snapshot).not.toContain('text: Skills')
     expect(snapshot).not.toContain('text: Skills')
     expect(snapshot).not.toContain('text: Subagents')
     expect(snapshot).not.toContain('text: Subagents')
@@ -95,7 +96,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     )).toBeLessThan(1)
     )).toBeLessThan(1)
     await writeComposerDraft(page, input, '/cpt')
     await writeComposerDraft(page, input, '/cpt')
     await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
     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)
     const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
     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 activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
       await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
       const input = activePage.locator('[data-composer-input]').first()
       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' })
       const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
       await menu.waitFor({ timeout: 10_000 })
       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 expect.poll(() => input.textContent()).toBe('/plan ')
       await input.press('Enter')
       await input.press('Enter')
       const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
       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 {
   try {
     expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
     expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
       name: 'feedback',
       name: 'feedback',
-      description: 'record feedback about this session',
+      description: 'Record feedback about this session',
       input: { hint: '<text>' },
       input: { hint: '<text>' },
     })
     })
   } finally {
   } 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"
 - code: "const first: number = 1 const second = \"two\" let tail"
 - status: Deep diving...
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current streaming-fence-highlight-test/streaming-fence":
 - 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: [
         value: [
           { name: 'compact', description: 'fixture:压缩当前会话上下文' },
           { name: 'compact', description: 'fixture:压缩当前会话上下文' },
           { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
           { 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: '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: '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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
 #   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
 ## 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
 ## Table of Contents
 
 
@@ -29,7 +29,11 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
 
 
 ### Kinds and decorations
 ### 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
 ### Attachment-carrying submissions
 
 
@@ -43,7 +47,7 @@ When the composer submits with images or generic files, only a host command decl
 <details>
 <details>
 <summary>Implementation internals — click to expand</summary>
 <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>
 </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>
 <details>
 <summary>实现细节——点击展开</summary>
 <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>
 </details>
 
 

+ 31 - 6
packages/client/ui-commands/src/client/contract.ts

@@ -3,8 +3,10 @@
  * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages
  * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages
  * consume `register` alone.
  * consume `register` alone.
  */
  */
+import type { ComponentType } from 'react'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 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. */
 /** Copy for an option that must be acknowledged before onSelect can run. */
 export interface SelectConfirmation {
 export interface SelectConfirmation {
@@ -31,26 +33,49 @@ export interface SelectOption {
  * The shell component is owned by ui-commands; business never sees it. Both
  * The shell component is owned by ui-commands; business never sees it. Both
  * callbacks receive the ClientSessionContext captured at popup open.
  * callbacks receive the ClientSessionContext captured at popup open.
  */
  */
-export type CommandUiSpec = {
+export interface PopupSelectSpec {
   readonly kind: 'popupSelect'
   readonly kind: 'popupSelect'
   options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
   options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
   onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
   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
  * One client-owned command contribution: a slash-menu entry whose behavior
  * lives entirely on the client (no host descriptor). Merged with the host
  * 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
  * 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 {
 export interface CommandContribution {
   /** Command name without the leading slash (unique across contributions). */
   /** Command name without the leading slash (unique across contributions). */
   readonly name: string
   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. */
   /** Capability filter, called with a fresh projection per candidate pass. */
   available(session: ClientSessionContext): boolean
   available(session: ClientSessionContext): boolean
-  /** The command's UI behavior (this phase: popupSelect only). */
+  /** The command's UI behavior. */
   readonly ui: CommandUiSpec
   readonly ui: CommandUiSpec
 }
 }
 
 
@@ -68,7 +93,7 @@ export interface CommandDecoration {
   readonly name: string
   readonly name: string
   /** Capability filter, called with a fresh projection per bare invocation. */
   /** Capability filter, called with a fresh projection per bare invocation. */
   available(session: ClientSessionContext): boolean
   available(session: ClientSessionContext): boolean
-  /** The bare-invocation UI (this phase: popupSelect only). */
+  /** The bare-invocation UI. */
   readonly ui: CommandUiSpec
   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-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/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 { CommandUiRuntime } from './service.ts'
 import type { PopupSelectInjected } from './PopupSelectView.tsx'
 import type { PopupSelectInjected } from './PopupSelectView.tsx'
 import { PopupSelectView } 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 { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
 export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
 export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
 export type {
 export type {
-  CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption,
+  ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec,
+  SelectConfirmation, SelectOption,
 } from './contract.ts'
 } from './contract.ts'
 export type { CommandKey } from './locales.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' {
 declare module '@deepseek-ai/cordis' {
   interface Context {
   interface Context {
@@ -39,7 +43,7 @@ declare module '@deepseek-ai/cordis' {
 
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
   interface LocaleNamespaceMap {
-    /** The popupSelect shell's copy. */
+    /** The menu rows' and the popupSelect shell's copy. */
     command: CommandKey
     command: CommandKey
   }
   }
 }
 }
@@ -51,13 +55,33 @@ const NS = 'command'
 export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale']
 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.
  * @param ctx - client root context.
  */
  */
 export function apply(ctx: ClientContext): void {
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries')
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries')
   ctx.plugin(CommandUiRuntime)
   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) => {
   ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => {
     const command = scope.commandUi
     const command = scope.commandUi
     const sessions = scope.get('sessions') as ISessions
     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). */
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
 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.placeholder': '搜索…',
   'search.aria': '筛选选项',
   'search.aria': '筛选选项',
   'status.loading': '正在加载选项…',
   'status.loading': '正在加载选项…',
@@ -15,8 +41,33 @@ export const zh = {
 /** The command namespace key union. */
 /** The command namespace key union. */
 export type CommandKey = keyof typeof zh
 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 = {
 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.placeholder': 'Search…',
   'search.aria': 'Filter options',
   'search.aria': 'Filter options',
   'status.loading': 'Loading 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
  * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the
  * session-keyed directory, the client-contribution registry, and the
  * session-keyed directory, the client-contribution registry, and the
  * per-session popupSelect controllers. Candidate synthesis merges the host
  * 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.
  * agent-backed.
  */
  */
 import { Service } from '@deepseek-ai/cordis'
 import { Service } from '@deepseek-ai/cordis'
@@ -27,6 +29,7 @@ import type { CommandContribution, CommandDecoration, CommandUiContract } from '
 import type { CommandDescriptor } from './directory.ts'
 import type { CommandDescriptor } from './directory.ts'
 import { CommandDirectory } from './directory.ts'
 import { CommandDirectory } from './directory.ts'
 import { PopupSelectController } from './popup.ts'
 import { PopupSelectController } from './popup.ts'
+import { builtinRowFace, claimToken, resolveCommandName, sectionRows } from './presentation.ts'
 import type { TokenSegment } from './popup.ts'
 import type { TokenSegment } from './popup.ts'
 
 
 declare module '@deepseek-ai/cordis' {
 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[]> {
   private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]> {
     const list = await this.directory.ensureReady(session.sessionId, req.signal)
     const list = await this.directory.ensureReady(session.sessionId, req.signal)
     const rows: InputTriggerCandidate[] = []
     const rows: InputTriggerCandidate[] = []
     const seen = new Set<string>()
     const seen = new Set<string>()
     for (const c of list) {
     for (const c of list) {
       seen.add(c.name)
       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()) {
     for (const contribution of this.live.contributions.values()) {
       if (!contribution.available(session)) continue
       if (!contribution.available(session)) continue
       if (seen.has(contribution.name)) {
       if (seen.has(contribution.name)) {
         throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`)
         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 {
   private dispatch(pick: InputTriggerPick): PickOutcome {
     const name = pick.candidate.name
     const name = pick.candidate.name
     const contribution = this.live.contributions.get(name)
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(pick.session)) {
     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'
       return 'handled'
     }
     }
     const desc = this.directory.resolve(pick.session.sessionId, name)
     const desc = this.directory.resolve(pick.session.sessionId, name)
     if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
     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)
     const decoration = this.live.decorations.get(name)
     if (decoration !== undefined && decoration.available(pick.session)) {
     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'
       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
     // Menu-pick execute consumes the trigger span before the detached run
     // (scoped event; the input owns the CAS guard).
     // (scoped event; the input owns the CAS guard).
     this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
     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. */
   /** Decision table, space column: hot-key sync check; only host leadingInput claims. */
   private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
   private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
     if (!token.startsWith('/')) return undefined
     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)
     const desc = this.directory.resolve(session.sessionId, name)
     if (desc === undefined || desc.input === undefined) return undefined
     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.
    * args-tolerant.
    *
    *
    * Envelope policy: an enter submission carrying attachments resolves only
    * 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(
   private async matchEnter(
     session: ClientSessionContext,
     session: ClientSessionContext,
@@ -266,16 +284,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     const ws = trimmed.search(/\s/)
     const ws = trimmed.search(/\s/)
     const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
     const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
     const bare = ws === -1
     const bare = ws === -1
-    const name = token.slice(1)
+    const name = resolveCommandName(token.slice(1))
     if (name === '') return undefined
     if (name === '') return undefined
+    // The Host line always carries the catalog name.
+    const canonical = `/${name}${trimmed.slice(token.length)}`
     const refuseAttachments = (): never => {
     const refuseAttachments = (): never => {
       throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
       throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
     }
     }
     const contribution = this.live.contributions.get(name)
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(session)) {
     if (contribution !== undefined && contribution.available(session)) {
       if (!bare) return undefined
       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'
       return 'handled'
     }
     }
     await this.directory.ensureReady(session.sessionId, signal)
     await this.directory.ensureReady(session.sessionId, signal)
@@ -286,42 +306,57 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     if (bare) {
     if (bare) {
       const decoration = this.live.decorations.get(name)
       const decoration = this.live.decorations.get(name)
       if (decoration !== undefined && decoration.available(session)) {
       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'
         return 'handled'
       }
       }
     }
     }
     if (desc.input !== undefined) {
     if (desc.input !== undefined) {
       if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments()
       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 (!bare) return undefined
     if (envelope.attachments > 0) refuseAttachments()
     if (envelope.attachments > 0) refuseAttachments()
     this.consumeVia(session.sessionId, { via: 'enter', token })
     this.consumeVia(session.sessionId, { via: 'enter', token })
-    this.runDetached(desc, session, trimmed)
+    this.runDetached(desc, session, canonical)
     return 'handled'
     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,
     name: string,
     ui: CommandContribution['ui'],
     ui: CommandContribution['ui'],
     session: ClientSessionContext,
     session: ClientSessionContext,
     segment: TokenSegment,
     segment: TokenSegment,
   ): void {
   ): void {
+    if (ui.kind === 'action') {
+      this.consumeVia(session.sessionId, segment)
+      ui.run(session)
+      return
+    }
     const actx = this.scopeFor(session.sessionId)
     const actx = this.scopeFor(session.sessionId)
     if (actx === undefined) return
     if (actx === undefined) return
     this.popupFor(actx).open(name, ui, session, segment)
     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 {
     return {
       token,
       token,
       ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
       ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
       ...(desc.input?.attachments === true ? { attachments: true } : {}),
       ...(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', {
   ctx.provide('sessions', {
     scope: (id: SessionId) => scopes.get(id),
     scope: (id: SessionId) => scopes.get(id),
     scopeOf: (c: Context) => scopeOf(c),
     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 service subscribes its cache-invalidation events on construction, so
   // the Remote face needs `$on` even where this spec dispatches none.
   // the Remote face needs `$on` even where this spec dispatches none.
   ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
   ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
@@ -73,6 +76,25 @@ describe('apply', () => {
     expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
     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 () => {
   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 { ctx, slots, mint } = await bench()
     const command = ctx.get('commandUi') as CommandUiRuntime
     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 { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 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 { 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 type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandUiRuntime } from '../src/client/service.ts'
 import { CommandUiRuntime } from '../src/client/service.ts'
 
 
@@ -155,7 +156,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi
   return source.onPick(pick)
   return source.onPick(pick)
 }
 }
 
 
-const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
+const themeUi = (over: Partial<PopupSelectSpec> = {}): PopupSelectSpec => ({
   kind: 'popupSelect',
   kind: 'popupSelect',
   options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
   options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
   onSelect: () => undefined,
   onSelect: () => undefined,
@@ -164,7 +165,7 @@ const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
 
 
 const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
 const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
   name: 'theme',
   name: 'theme',
-  description: 'client popup kind',
+  description: () => 'client popup kind',
   available: () => true,
   available: () => true,
   ui: themeUi(),
   ui: themeUi(),
   ...over,
   ...over,
@@ -224,7 +225,7 @@ describe('candidates', () => {
     const { source, listCalls } = await bench()
     const { source, listCalls } = await bench()
     const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
     const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
     expect(listCalls).toEqual([{ sessionId: sid('s2') }])
     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 () => {
   it('hides leadingInput commands at inline position', async () => {
@@ -238,7 +239,7 @@ describe('candidates', () => {
     const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
     const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
     command.register(themeContribution({ available }))
     command.register(themeContribution({ available }))
     const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
     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'))
     expect(available).toHaveBeenLastCalledWith(proj('s1'))
     const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
     const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
     expect(s2Names).not.toContain('theme')
     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')
     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)', () => {
 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()
     const { command, source } = await bench()
     command.decorate(goalDecoration())
     command.decorate(goalDecoration())
     const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
     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 () => {
   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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
 #   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.
 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>
 <a id="temporary-composer-entries"></a>
 ## Temporary composer entries
 ## Temporary composer entries

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

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

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

@@ -167,6 +167,12 @@ declare module '@deepseek-ai/cordis' {
      * @mode bail
      * @mode bail
      */
      */
     'slash/input-insert-text'(request: InsertTextRequest): true | undefined
     '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
   space(): boolean
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   dismissPopup(): void
   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. */
 /** 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
   private disposed = false
   /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
   /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
   private mirrorFn: ((text: string) => void) | undefined
   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. */
   /** Live lexicon subscription disposer; undefined until the controller resolves. */
   private lexiconOff: (() => void) | undefined
   private lexiconOff: (() => void) | undefined
   /** Default sends retained until admission settles or scope disposal releases their attachments. */
   /** 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 ----
   // ---- effect executor ----
 
 
   /** The claim token the decoration transform styles; null while unclaimed. */
   /** 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),
           shell.consumeToken(req.guard) ? true : undefined),
         actx.on('slash/input-insert-text', req =>
         actx.on('slash/input-insert-text', req =>
           shell.insertText(req.text, req.span, req.continue === true) ? true : undefined),
           shell.insertText(req.text, req.span, req.continue === true) ? true : undefined),
+        actx.on('slash/input-pick-files', () => shell.pickFiles() ? true : undefined),
       ]
       ]
       return () => {
       return () => {
         for (const off of offs) off()
         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.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话',
   'placeholder.workspace': '选择一个工作区开始',
   'placeholder.workspace': '选择一个工作区开始',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
-  'input.commands': '指令',
+  'input.commands': '添加文件或调用指令',
   'input.stop': '停止生成',
   'input.stop': '停止生成',
   'input.send': '发送消息',
   'input.send': '发送消息',
   'input.send.queue': '排队发送',
   'input.send.queue': '排队发送',
@@ -49,7 +49,6 @@ export const zh = {
   'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
   'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
   'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
   'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
   'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
   'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
-  'file.attach': '添加附件',
   'file.pending': '待发送文件',
   'file.pending': '待发送文件',
   'file.remove': '移除文件 {name}',
   'file.remove': '移除文件 {name}',
   'file.uploading': '上传中…',
   'file.uploading': '上传中…',
@@ -185,7 +184,7 @@ export const en = {
   'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions',
   'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions',
   'placeholder.workspace': 'Choose a workspace to start',
   'placeholder.workspace': 'Choose a workspace to start',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
   'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
-  'input.commands': 'Commands',
+  'input.commands': 'Add files or run commands',
   'input.stop': 'Stop generating',
   'input.stop': 'Stop generating',
   'input.send': 'Send message',
   'input.send': 'Send message',
   'input.send.queue': 'Queue 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.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.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',
   'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
-  'file.attach': 'Add attachment',
   'file.pending': 'Pending files',
   'file.pending': 'Pending files',
   'file.remove': 'Remove file {name}',
   'file.remove': 'Remove file {name}',
   'file.uploading': 'Uploading…',
   '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 type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
 import {
 import {
-  IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
+  IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
 // Type-only: the `plan` projection key merge (the TodoDock posture — the
 // Type-only: the `plan` projection key merge (the TodoDock posture — the
 // composer reads a host-computed value; the domain owns the key).
 // 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.
   // registration survives re-renders without re-arming per keystroke.
   const gate = useRef({
   const gate = useRef({
     locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
     locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
-    intakeFiles, uploadsPending, showToast, t,
+    intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
   })
   })
   gate.current = {
   gate.current = {
     locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
     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(() => {
   useEffect(() => {
     if (editor === null || keyboard === undefined) return
     if (editor === null || keyboard === undefined) return
     return registerComposerKeymap(editor, {
     return registerComposerKeymap(editor, {
@@ -491,18 +500,6 @@ export const InputBar = memo(function InputBar({
                 <IconPlusOutline16 size={14} />
                 <IconPlusOutline16 size={14} />
               </button>
               </button>
             </Tooltip>
             </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
             <input
               ref={fileInputRef}
               ref={fileInputRef}
               type="file"
               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', () => {
   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,
       running: true,
       draft: '后续消息',
       draft: '后续消息',
       subagent: {
       subagent: {
@@ -888,9 +888,13 @@ describe('running and lock semantics', () => {
     expect(button.getAttribute('aria-label')).toBe('排队发送')
     expect(button.getAttribute('aria-label')).toBe('排队发送')
     expect(interruptButton).not.toBeNull()
     expect(interruptButton).not.toBeNull()
     expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
     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(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
     expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
     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)
     fireEvent.click(button)
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     fireEvent.click(interruptButton!)
     fireEvent.click(interruptButton!)
@@ -923,10 +927,14 @@ describe('running and lock semantics', () => {
     ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
     ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
   ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
   ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
     const added = vi.fn(() => null)
     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(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(false)
     expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
     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', () => {
   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(textarea.getAttribute('aria-disabled')).toBe('true')
     expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
     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.getAttribute('aria-label')).toBe('发送消息')
     expect(button.disabled).toBe(true)
     expect(button.disabled).toBe(true)
     expect(interruptButton?.disabled).toBe(false)
     expect(interruptButton?.disabled).toBe(false)
@@ -996,7 +1004,7 @@ describe('running and lock semantics', () => {
     const { textarea, view } = bench({ disabled: true })
     const { textarea, view } = bench({ disabled: true })
     expect(textarea.getAttribute('aria-disabled')).toBe('true')
     expect(textarea.getAttribute('aria-disabled')).toBe('true')
     expect(placeholderOf(view.container)).toBe('会话不可用')
     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', () => {
   it('idle primary sends and disables on empty draft', () => {
@@ -1129,7 +1137,7 @@ describe('running and lock semantics', () => {
     expect(editableOf(textarea)).toBe(false)
     expect(editableOf(textarea)).toBe(false)
     expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea.getAttribute('aria-expanded')).toBe('false')
     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.click(textarea)
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -1432,7 +1440,7 @@ describe('strips and variants', () => {
 describe('command launcher chrome and control seats', () => {
 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', () => {
   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()
     const { view, slotCalls } = bench()
-    expect(view.getByLabelText('指令')).toBeTruthy()
+    expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy()
     // Capability absent (no projection value): the chip renders nothing.
     // Capability absent (no projection value): the chip renders nothing.
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
     // Every seat dispatched, nothing rendered (render passes may repeat; the
     // 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 toggleCommandMenu = vi.fn()
     const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
     const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
     act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
     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')
     expect(launcher.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(launcher)
     fireEvent.click(launcher)
     expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
     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)', () => {
   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 permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
     const { view } = bench({ disabled: true, permissions })
     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)
     expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
     cleanup()
     cleanup()
     const live = bench({ running: true, permissions })
     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', () => {
   it('disables the textarea and chrome; the machine currency is untouched', () => {
     const { view, textarea, shell } = bench({ disabled: true })
     const { view, textarea, shell } = bench({ disabled: true })
     expect(textarea.getAttribute('aria-disabled')).toBe('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')
     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;
 # 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:
 # 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
 #   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>
 <a id="use-this-package"></a>
 ## Use this package
 ## 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
 ### 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.
 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.
 - **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.
 - **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>
 <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>
 <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 需求。
 - **只有全局 source 层**——会话 scope 的 source 注册(逐会话遮蔽)已有设计但未启用;台账记录着触发条件,即真实的逐会话 source 需求。
-- **`InputTriggerCandidate.icon` 以文本渲染**——`MenuView` 把该字符串原样放进图标位;与设计系统图标枚举的接入将在该枚举交付后完成。
 - **overlay 的 SlotMap 合并归属与槽位所有权分离**——唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
 - **overlay 的 SlotMap 合并归属与槽位所有权分离**——唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
 
 
 <a id="dev-note"></a>
 <a id="dev-note"></a>

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

@@ -86,12 +86,26 @@
   white-space: nowrap;
   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 {
 .itemDescription {
   flex: 1;
   flex: 1;
   min-width: 0;
   min-width: 0;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
+  text-align: right;
   color: var(--dsw-alias-label-tertiary);
   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
  * rows only while it has none; pointer picks route back through
  * the service (combobox pattern — focus never leaves the textarea, so rows
  * the service (combobox pattern — focus never leaves the textarea, so rows
  * are mousedown-handled and the highlight is exposed via
  * are mousedown-handled and the highlight is exposed via
- * aria-activedescendant on the listbox). A source publishing crumbs gets a
- * breadcrumb header pinned above the scrolling list.
+ * aria-activedescendant on the listbox). A row reads title, then the
+ * command-name alias when the title is not the name in another letter case
+ * (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb
+ * header pinned above the scrolling list.
  */
  */
 import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
 import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
@@ -150,10 +152,15 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
                       >
                       >
                         {item.icon !== undefined && (
                         {item.icon !== undefined && (
                           <span className={css.itemIcon} aria-hidden>
                           <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>
                         )}
                         )}
-                        <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.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
                         {item.drill === true && (
                         {item.drill === true && (
                           <span className={css.trailing}>
                           <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
  * never a Cordis context or the mutable Session. RPC and service access go
  * through the provider plugin's own root context captured at registration.
  * through the provider plugin's own root context captured at registration.
  */
  */
+import type { ComponentType } from 'react'
 import type {
 import type {
   PickOutcome, TokenSpan,
   PickOutcome, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 } 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'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 
 export type {
 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. */
 /** What a pick asks for: resolve the candidate, or drill into it in place. */
 export type PickAction = 'pick' | 'drill'
 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'
 export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session'
 
 
 /** One menu candidate. Pure display data — zero behavior declaration. */
 /** One menu candidate. Pure display data — zero behavior declaration. */
 export interface InputTriggerCandidate {
 export interface InputTriggerCandidate {
+  /** Identity: the pick payload, the exact-match key, and the first search key. */
   readonly name: string
   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 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
   readonly hint?: string
   /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */
   /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */
   readonly section?: string
   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)
     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', () => {
   it('keeps an opted-out source title hidden while its candidates are pending', () => {
     mount(openState({
     mount(openState({
       groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }],
       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-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 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 type { ModelDirectoryState } from './directory.ts'
 import { ModelDirectoryResolver } from './service.ts'
 import { ModelDirectoryResolver } from './service.ts'
 import type { ModelSelectInjected } from './slots.ts'
 import type { ModelSelectInjected } from './slots.ts'
@@ -120,16 +121,16 @@ export function apply(ctx: ClientContext): void {
   // a locale change reaches the next publish.
   // a locale change reaches the next publish.
   ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') })
   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) => {
   ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => {
     const command = scope.get('commandUi') as CommandUiContract
     const command = scope.get('commandUi') as CommandUiContract
     const models = scope.modelDirectories
     const models = scope.modelDirectories
     const sessions = scope.sessions
     const sessions = scope.sessions
     scope.effect(() => command.register({
     scope.effect(() => command.register({
       name: 'model',
       name: 'model',
-      description: t('command.description'),
+      label: () => t('command.label'),
+      description: () => t('command.description'),
+      icon: IconDataOutline16,
       available: session => sessions.subagentAddress(session.sessionId) === undefined,
       available: session => sessions.subagentAddress(session.sessionId) === undefined,
       ui: {
       ui: {
         kind: 'popupSelect',
         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). */
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
 export const zh = {
+  'command.label': '模型',
   'command.description': '选择本会话使用的模型',
   'command.description': '选择本会话使用的模型',
   'option.loadError': '目录加载失败:{message}',
   'option.loadError': '目录加载失败:{message}',
   'trigger.fallback': '选择模型',
   'trigger.fallback': '选择模型',
@@ -35,6 +36,7 @@ export type ModelKey = keyof typeof zh
 
 
 /** English dictionary, checked complete against the zh key set. */
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
 export const en = {
+  'command.label': 'Model',
   'command.description': 'Select the model for this conversation',
   'command.description': 'Select the model for this conversation',
   'option.loadError': 'Catalog failed to load: {message}',
   'option.loadError': 'Catalog failed to load: {message}',
   'trigger.fallback': 'Select model',
   '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 { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
 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 type { ModelSelectInjected } from '../src/client/slots.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { zh } from '../src/client/locales.ts'
 import { zh } from '../src/client/locales.ts'
@@ -157,6 +157,11 @@ async function bench() {
   return {
   return {
     ctx, fiber, mint, calls, remote,
     ctx, fiber, mint, calls, remote,
     contribution: () => contribution!,
     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')!,
     seat: () => seats.get('conversation.input.model')!,
     hostCurrent: () => selected,
     hostCurrent: () => selected,
     setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
     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 () => {
   it('popup options mark the host current active with the provider group in the detail', async () => {
     const b = await bench()
     const b = await bench()
     b.mint('s1')
     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.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
     expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
     expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
     expect(options[1]?.active).toBeUndefined()
     expect(options[1]?.active).toBeUndefined()
@@ -209,7 +214,7 @@ describe('ui-model-selection dual entry', () => {
       reasoningEffort: 'max',
       reasoningEffort: 'max',
     })
     })
     // The POPUP's next options pass reflects it without a seat-side reload.
     // 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 })
     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()
     const b = await bench()
     b.mint('s1')
     b.mint('s1')
     const seatFace = b.seat().inject!(sid('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')!
     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({
     expect(seatFace.directory.getSnapshot().current).toEqual({
       provider: 'deepseek-official',
       provider: 'deepseek-official',
       model: 'deepseek-v4-pro',
       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.
     // The service face resolves the same instance the seat inject handed out.
     expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
     expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
     await Promise.all([
     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)
     expect(b.calls.models).toBe(1)
   })
   })
@@ -369,7 +374,7 @@ describe('ui-model-selection dual entry', () => {
     b.address(sid('child'))
     b.address(sid('child'))
 
 
     expect(b.contribution().available(projection('child'))).toBe(false)
     expect(b.contribution().available(projection('child'))).toBe(false)
-    await expect(b.contribution().ui.options(
+    await expect(b.popup().options(
       projection('child'),
       projection('child'),
       new AbortController().signal,
       new AbortController().signal,
     )).rejects.toThrow(/unavailable for addressed subagent/)
     )).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 { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
 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 { 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 type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
 import {
 import {
   PermissionRow, type PermissionRowInjected,
   PermissionRow, type PermissionRowInjected,
@@ -82,6 +82,11 @@ async function bench() {
     ctx, fiber, locale, values, commands, remote,
     ctx, fiber, locale, values, commands, remote,
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     decoration: () => decoration,
     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')
     permissionRow: () => ctx.slots.entries('settings.general.item')
       .find(entry => entry.component === PermissionRow),
       .find(entry => entry.component === PermissionRow),
   }
   }
@@ -110,11 +115,11 @@ describe('ui-permission browser plugin', () => {
     expect(c.available(proj)).toBe(false)
     expect(c.available(proj)).toBe(false)
     b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
     b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
     expect(c.available(proj)).toBe(true)
     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.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
     expect(options.every(option => option.active !== true)).toBe(true)
     expect(options.every(option => option.active !== true)).toBe(true)
     b.values.set(sid('s1'), SELECT)
     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 === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
     // English built-ins use product labels; other kebab-case names title-case.
     // 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',
       confirmLabel: 'Enable Full access',
     })
     })
     b.locale.setLocale('zh')
     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.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限'])
     expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
     expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
       title: '确认启用完全权限?',
       title: '确认启用完全权限?',
@@ -143,28 +148,27 @@ describe('ui-permission browser plugin', () => {
       { value: '__proto__', name: '__proto__' },
       { value: '__proto__', name: '__proto__' },
       { value: 'plain', name: 'Ask Every Time' },
       { 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([
     expect(passthrough.map(option => option.label)).toEqual([
       'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
       'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
     ])
     ])
     // A projection that vanished between availability and open throws.
     // 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/)
       .toThrow(/not available on this host/)
   })
   })
 
 
   it('a pick submits the /permission line; rejection and unmatched throw', async () => {
   it('a pick submits the /permission line; rejection and unmatched throw', async () => {
     const b = await bench()
     const b = await bench()
-    const c = b.decoration()!
     const proj = { sessionId: sid('s1') }
     const proj = { sessionId: sid('s1') }
     b.values.set(sid('s1'), SELECT)
     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'])
     expect(b.commands).toEqual(['/permission danger-full-access'])
     b.setResult({ ok: false })
     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 })
     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.
     // 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/)
       .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
  * 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
  * .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.
  * 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.
  * @param rawQuery - the text typed after the trigger, matched case-insensitively.
  * @returns the matching items: prefix hits first, then by alignment score,
  * @returns the matching items: prefix hits first, then by alignment score,
  * then in source order. The input list itself for an empty query.
  * 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()
   const query = rawQuery.toLowerCase()
   if (query === '') return items
   if (query === '') return items
   const ranked: Ranked<T>[] = []
   const ranked: Ranked<T>[] = []
   items.forEach((item, index) => {
   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) =>
   ranked.sort((left, right) =>
     Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
     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'])
     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', () => {
   it('returns the ranked items with their payload intact', () => {
     const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }]
     const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }]
     expect(rankByName(items, 'pl')).toEqual([{ 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 {
 export function apply(ctx: Context): void {
   ctx.commands.register({
   ctx.commands.register({
     name: 'feedback',
     name: 'feedback',
-    description: 'record feedback about this session',
+    description: 'Record feedback about this session',
     input: { hint: '<text>' },
     input: { hint: '<text>' },
     recordInput: false,
     recordInput: false,
     handler: executeFeedbackCommand,
     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({
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
       name: 'feedback',
       name: 'feedback',
-      description: 'record feedback about this session',
+      description: 'Record feedback about this session',
       input: { hint: '<text>' },
       input: { hint: '<text>' },
     })
     })
     expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
     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 {
 export function apply(ctx: Context): void {
   ctx.commands.register({
   ctx.commands.register({
     name: 'goal',
     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 },
     input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
     handler: invocation => executeGoalCommand(ctx, invocation),
     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({
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
       name: 'goal',
       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 },
       input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
     })
     })
     expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
     expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()

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

@@ -29,9 +29,7 @@
   - img
   - img
   - text: "Failed Bash Error: tool call aborted before dispatch"
   - text: "Failed Bash Error: tool call aborted before dispatch"
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Full access"': Full access
 - 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/feedback-command/ack-expanded.expected.md

@@ -47,9 +47,7 @@
   - img
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/feedback-command/ack.expected.md

@@ -39,9 +39,7 @@
   - img
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/feedback-release/ack-expanded.expected.md

@@ -47,9 +47,7 @@
   - img
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/feedback-release/ack.expected.md

@@ -39,9 +39,7 @@
   - img
   - img
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
   - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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":
 - button "Back to bottom":
   - img
   - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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":
 - listbox "Trigger suggestions":
   - text: Commands
   - 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":
 - 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
   - 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
   - img
 - textbox "Describe what you want to build, / commands, @ files or sessions":
 - textbox "Describe what you want to build, / commands, @ files or sessions":
   - paragraph
   - paragraph
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

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

@@ -30,9 +30,7 @@
   - text: Standard mode
   - text: Standard mode
   - img
   - img
 - textbox "Describe what you want to build, / commands, @ files or sessions"
 - textbox "Describe what you want to build, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Plan mode on, press to turn off": Plan
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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
   - text: This turn failedAPI key is invalid
   - code: AUTH
   - code: AUTH
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

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

@@ -25,9 +25,7 @@
 - paragraph: partial
 - paragraph: partial
 - status: Deep diving...
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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
   - text: This turn failedupstream 503
   - code: SERVER
   - code: SERVER
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/live-interactions/running-draft.expected.md

@@ -26,9 +26,7 @@
 - status: Deep diving...
 - status: Deep diving...
 - textbox "Message or run a task, / commands, @ files or sessions":
 - textbox "Message or run a task, / commands, @ files or sessions":
   - paragraph: Queue this follow-up while the current turn is running.
   - paragraph: Queue this follow-up while the current turn is running.
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: 7/25 {{clock}}
 - text: 7/25 {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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: Ran for {{duration}}
 - text: {{clock}}
 - text: {{clock}}
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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?
   - listitem: Which color do you prefer?
 - button "Inspect"
 - button "Inspect"
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/queue-actions/collapsed.expected.md

@@ -26,9 +26,7 @@
 - status: Deep diving...
 - status: Deep diving...
 - button "2 queued messages"
 - button "2 queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/queue-actions/editing.expected.md

@@ -42,9 +42,7 @@
     - button "Cancel editing":
     - button "Cancel editing":
       - img
       - img
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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)
 - alert: Queue submission failed (session/agent-busy)
 - textbox "Message or run a task, / commands, @ files or sessions":
 - textbox "Message or run a task, / commands, @ files or sessions":
   - paragraph: Queue submission to retry
   - paragraph: Queue submission to retry
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

+ 1 - 3
snapshots/web/queue-actions/layout.expected.md

@@ -42,9 +42,7 @@
   - img
   - img
 - button "2 queued messages"
 - button "2 queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
 - textbox "Cmd/Ctrl+Enter steers all queued messages"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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]:
     - button "Steer queued message" [disabled]:
       - img
       - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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]:
     - button "Steer queued message" [disabled]:
       - img
       - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - 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]:
     - button "Steer queued message" [disabled]:
       - img
       - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - textbox "Message or run a task, / commands, @ files or sessions"
-- button "Commands":
-  - img
-- button "Add attachment":
+- button "Add files or run commands":
   - img
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
 - button "Select model, current DeepSeek-V4-Flash":

Some files were not shown because too many files changed in this diff