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".
This commit is contained in:
creatixchu committed 2026-09-08 12:28:40 +08:00
1 parent 2c9eda5b6f
commit 5b1bb021cf
119 files changed
+872 -380

No files matched your search

@@ -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
@@ -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".
@@ -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 内更新。与内置命令同名的第三方命令按名字定位,但保留自己的说明且没有图标,这正是「同名的另一个命令」应有的解读。
@@ -193,9 +193,7 @@
- button "Back to bottom":
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -51,9 +51,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current github-webhook-review-test/reply":
@@ -43,9 +43,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current github-webhook-review-test/reply":
@@ -17,9 +17,7 @@
- img
- text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -51,9 +51,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -30,9 +30,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -42,9 +42,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -46,9 +46,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -16,9 +16,7 @@
- img
- text: Session recall Research notes
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -43,9 +43,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -35,9 +35,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -462,9 +462,7 @@
- button "Back to bottom":
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -34,9 +34,7 @@
- button "Copy":
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -53,9 +53,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -41,9 +41,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -51,7 +51,7 @@ describe('web e2e: /goal human transcript presentation', () => {
const input = page.locator('[data-composer-input]').first()
await input.fill('/go')
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' })
await menu.getByRole('option', { name: 'Goal Set or view the goal for a long-running task' })
.waitFor({ timeout: 10_000 })
await input.press('Tab')
await expect.poll(() => input.textContent()).toBe('/goal ')
+5 -4
View File
@@ -71,12 +71,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
const launcher = page.getByRole('button', { name: 'Commands' })
const launcher = page.getByRole('button', { name: 'Add files or run commands' })
await launcher.click()
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('text: Add')
expect(snapshot).toContain('text: Commands')
expect(snapshot).not.toContain('text: Skills')
expect(snapshot).not.toContain('text: Subagents')
@@ -95,7 +96,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
)).toBeLessThan(1)
await writeComposerDraft(page, input, '/cpt')
await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
'compactCompact older conversation history',
'CompactCompact older conversation history',
])
const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
@@ -112,10 +113,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
const input = activePage.locator('[data-composer-input]').first()
await activePage.getByRole('button', { name: 'Commands' }).click()
await activePage.getByRole('button', { name: 'Add files or run commands' }).click()
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
await menu.getByRole('option', { name: 'Plan Enter or leave plan mode' }).click()
await expect.poll(() => input.textContent()).toBe('/plan ')
await input.press('Enter')
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
+1 -1
View File
@@ -183,7 +183,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
try {
expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
description: 'Record feedback about this session',
input: { hint: '<text>' },
})
} finally {
@@ -27,9 +27,7 @@
- code: "const first: number = 1 const second = \"two\" let tail"
- status: Deep diving...
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current streaming-fence-highlight-test/streaming-fence":
@@ -2220,7 +2220,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
value: [
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
{ name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
],
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
README.md: e1264d10bd8ff807d8f348339e0c055607579407
README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea
README.md: 31fd9925c08393735c56de891bafe985cc00b683
README.zh.md: 76e4955014365f1c9890c98600c7cf1c347afbb0
+7 -3
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`.
Typing a `/` command in the composer opens the matching surface — a registered popup, a client action, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or an action, or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect` or `action`, and everything else is `execute`. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
## Table of Contents
@@ -29,7 +29,11 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
### Kinds and decorations
A contribution is a client-owned command — a host-name collision fails loud. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first.
A contribution is a client-owned command — a host-name collision fails loud. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, which submits nothing and therefore never refuses an attachment-carrying draft. The menu's File row is this package's own action; it opens the composer's file picker through the scoped `slash/input-pick-files` event wherever the composer accepts files, never on a subagent. A decoration adds a bare-invocation popup or action to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles; prefixes rank first, and a typed query lists the matches flat without the section headings.
### Built-in row faces
Host descriptors carry English text only, so `src/client/presentation.ts` owns the client face of the six built-in Host commands: a catalog row whose description equals the canonical English text gets its localized title, description, glyph, and claim token from the `command` dictionaries, while a scoped override or third-party command of the same name keeps its own description and only its section position follows the name; rows outside both section lists close the Commands section in catalog order. Under Chinese, picking Plan fills the composer with `/计划 ` and the submission still executes `/plan `; a typed `/计划` or `/目标` resolves the same way on Space and Enter in every locale, so a draft written under one locale submits under another. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass, so a locale change reaches the next menu open without re-registration.
### Attachment-carrying submissions
@@ -43,7 +47,7 @@ When the composer submits with images or generic files, only a host command decl
<details>
<summary>Implementation internals — click to expand</summary>
`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution.
`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. `presentation.ts` holds the section lists, the built-in row faces, and the localized-token resolution; `candidates` sections an empty query and ranks a typed one through the shared `rankByName`.
</details>
+7 -3
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`),或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。
在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、客户端动作、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`)或动作,或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect` 或 `action`,其余全部是 `execute`。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
## 目录
@@ -29,7 +29,11 @@ kind: "package-reference"
### 种类与装饰
贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。
贡献项是客户端自有命令——与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用在消费掉触发 token 之后运行的一个回调,它不提交任何内容,因此绝不会拒绝带附件的草稿。菜单里的「文件」行就是本包自己的动作;只要 composer 接受文件,它就经会话作用域的 `slash/input-pick-files` 事件打开 composer 的文件选择器,子智能体上不提供。装饰为**已存在的**宿主命令添加裸调用弹窗或动作:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名与标题的子序列;前缀排名最高,输入查询后匹配项平铺列出,不再带小节标题。
### 内置行的展示面
宿主描述符只携带英文文案,因此 `src/client/presentation.ts` 拥有六个内置宿主命令在客户端的展示面:目录行的说明与规范英文文案相同时,它从 `command` 词典取得本地化的标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字;不在两个小节清单里的行按目录顺序排在「指令」小节末尾。中文下选中「计划」会在 composer 填入 `/计划 `,提交仍执行 `/plan `;键入的 `/计划` 或 `/目标` 在任何语言下的空格与回车裁决中都同样解析,因此在一种语言下写的草稿在另一种语言下照样能提交。贡献项自带 `label`、`description` 与 `icon`,每次生成候选项时读取,语言切换后下一次打开菜单即生效,无需重新注册。
### 带附件提交
@@ -43,7 +47,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
<details>
<summary>实现细节——点击展开</summary>
`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。
`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。`presentation.ts` 持有小节清单、内置行的展示面与本地化 token 的解析;`candidates` 对空查询分小节、对输入的查询经共享的 `rankByName` 排序。
</details>
@@ -3,8 +3,10 @@
* CommandUiRuntime (`ctx.commandUi`) implements this face; business packages
* consume `register` alone.
*/
import type { ComponentType } from 'react'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/** Copy for an option that must be acknowledged before onSelect can run. */
export interface SelectConfirmation {
@@ -31,26 +33,49 @@ export interface SelectOption {
* The shell component is owned by ui-commands; business never sees it. Both
* callbacks receive the ClientSessionContext captured at popup open.
*/
export type CommandUiSpec = {
export interface PopupSelectSpec {
readonly kind: 'popupSelect'
options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
}
/**
* Business registration for the action command kind: a bare invocation
* consumes the trigger token and runs one client-side callback (the menu's
* File row opens the composer's file picker). It submits nothing, so an
* attachment-carrying draft never refuses it.
*/
export interface ActionSpec {
readonly kind: 'action'
/**
* Run the action for one session.
* @param session - the ClientSessionContext captured at invocation.
*/
run(session: ClientSessionContext): void
}
/** The UI behavior of a contribution or decoration. */
export type CommandUiSpec = PopupSelectSpec | ActionSpec
/**
* One client-owned command contribution: a slash-menu entry whose behavior
* lives entirely on the client (no host descriptor). Merged with the host
* catalog by name — a collision with a host command fails loud at candidate
* synthesis, never shadows.
* synthesis, never shadows. Row copy is read on every candidate pass, so a
* locale change reaches the next menu open without re-registration.
*/
export interface CommandContribution {
/** Command name without the leading slash (unique across contributions). */
readonly name: string
/** Menu row description. */
readonly description: string
/** Localized menu row title; the name itself when absent. */
label?(): string
/** Localized menu row description; the row shows none when absent. */
description?(): string
/** Menu row glyph from the shared icon set. */
readonly icon?: ComponentType<IconProps>
/** Capability filter, called with a fresh projection per candidate pass. */
available(session: ClientSessionContext): boolean
/** The command's UI behavior (this phase: popupSelect only). */
/** The command's UI behavior. */
readonly ui: CommandUiSpec
}
@@ -68,7 +93,7 @@ export interface CommandDecoration {
readonly name: string
/** Capability filter, called with a fresh projection per bare invocation. */
available(session: ClientSessionContext): boolean
/** The bare-invocation UI (this phase: popupSelect only). */
/** The bare-invocation UI. */
readonly ui: CommandUiSpec
}
@@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CommandUiRuntime } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
@@ -27,9 +28,12 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption,
ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec,
SelectConfirmation, SelectOption,
} from './contract.ts'
export type { CommandKey } from './locales.ts'
export { SECTION_ROWS, resolveCommandName } from './presentation.ts'
export type { MenuSection } from './presentation.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -39,7 +43,7 @@ declare module '@deepseek-ai/cordis' {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The popupSelect shell's copy. */
/** The menu rows' and the popupSelect shell's copy. */
command: CommandKey
}
}
@@ -51,13 +55,33 @@ const NS = 'command'
export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale']
/**
* Client plugin body: mount the service, then register the popupSelect shell
* into the input overlay once its declarer is up.
* Client plugin body: mount the service, register the menu's File row (an
* action opening the composer's file picker wherever the composer accepts
* files — never on a subagent), then register the popupSelect shell into the
* input overlay once its declarer is up.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries')
ctx.plugin(CommandUiRuntime)
const t = ctx.locale.bind(NS)
ctx.inject(['commandUi', 'sessions'], (scope: ClientContext) => {
const command = scope.commandUi
const sessions = scope.get('sessions') as ISessions
scope.effect(() => command.register({
name: 'file',
label: () => t('label.file'),
icon: IconPaperclipOutline16,
available: session => sessions.subagentAddress(session.sessionId) === undefined,
ui: {
kind: 'action',
run: (session) => {
const actx = sessions.scope(session.sessionId)
if (actx !== undefined) actx.bail(actx, 'slash/input-pick-files')
},
},
}), 'ui-commands: File row')
})
ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => {
const command = scope.commandUi
const sessions = scope.get('sessions') as ISessions
@@ -1,7 +1,33 @@
/** `command` namespace dictionaries (the popupSelect shell's copy). */
/**
* `command` namespace dictionaries: the composer menu's section headings,
* the client face (title, description, claim token) of the built-in Host
* commands whose catalog descriptors carry English text only, the File row's
* title, and the popupSelect shell's copy.
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'section.add': '添加',
'section.commands': '指令',
'label.file': '文件',
'label.goal': '目标',
'label.plan': '计划',
'label.feedback': '反馈',
'label.compact': '压缩',
'label.permission': '权限',
'label.export': '下载日志',
'description.goal': '设置或查看长期任务的目标',
'description.plan': '进入或退出计划模式',
'description.feedback': '记录关于本会话的反馈',
'description.compact': '压缩较早的对话历史',
'description.permission': '切换权限预设(沙箱模式与审批策略)',
'description.export': '将本会话日志下载为 ZIP 压缩包',
'token.goal': '目标',
'token.plan': '计划',
'token.feedback': '反馈',
'token.compact': '压缩',
'token.permission': '权限',
'token.export': '导出',
'search.placeholder': '搜索…',
'search.aria': '筛选选项',
'status.loading': '正在加载选项…',
@@ -15,8 +41,33 @@ export const zh = {
/** The command namespace key union. */
export type CommandKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
/**
* English dictionary, checked complete against the zh key set. Each
* `description.*` entry is byte-equal to the Host descriptor it localizes:
* that equality is how a catalog row is recognized as the built-in command.
*/
export const en = {
'section.add': 'Add',
'section.commands': 'Commands',
'label.file': 'File',
'label.goal': 'Goal',
'label.plan': 'Plan',
'label.feedback': 'Feedback',
'label.compact': 'Compact',
'label.permission': 'Permission',
'label.export': 'Export',
'description.goal': 'Set or view the goal for a long-running task',
'description.plan': 'Enter or leave plan mode',
'description.feedback': 'Record feedback about this session',
'description.compact': 'Compact older conversation history',
'description.permission': 'Switch the permission preset (sandbox mode + approval policy)',
'description.export': 'Download this Session log as a ZIP archive',
'token.goal': 'goal',
'token.plan': 'plan',
'token.feedback': 'feedback',
'token.compact': 'compact',
'token.permission': 'permission',
'token.export': 'export',
'search.placeholder': 'Search…',
'search.aria': 'Filter options',
'status.loading': 'Loading options…',
@@ -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]
}
@@ -2,10 +2,12 @@
* CommandUiRuntime (`ctx.commandUi`): the '/' command source over the
* session-keyed directory, the client-contribution registry, and the
* per-session popupSelect controllers. Candidate synthesis merges the host
* catalog with contributions by availability, then position filtering and
* the `/` menu's shared name ranking (ui-primitives `rankByName`); a
* host/contribution name collision fails loud. Every execute
* addresses the session's agent by sessionId — sessions are always
* catalog with contributions by availability, gives built-in Host rows their
* localized face (presentation.ts), then position-filters; an empty query
* lists the Add and Commands sections in usage order, a typed query ranks
* every row by the `/` menu's shared name-and-label ranking (ui-primitives
* `rankByName`). A host/contribution name collision fails loud. Every
* execute addresses the session's agent by sessionId — sessions are always
* agent-backed.
*/
import { Service } from '@deepseek-ai/cordis'
@@ -27,6 +29,7 @@ import type { CommandContribution, CommandDecoration, CommandUiContract } from '
import type { CommandDescriptor } from './directory.ts'
import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
import { builtinRowFace, claimToken, resolveCommandName, sectionRows } from './presentation.ts'
import type { TokenSegment } from './popup.ts'
declare module '@deepseek-ai/cordis' {
@@ -185,47 +188,58 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
}
}
/** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */
/**
* Menu candidates: host catalog + contribution availability, built-in rows
* localized, then position filtering; sections for an empty query, the
* shared name-and-label ranking for a typed one.
*/
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]> {
const list = await this.directory.ensureReady(session.sessionId, req.signal)
const rows: InputTriggerCandidate[] = []
const seen = new Set<string>()
for (const c of list) {
seen.add(c.name)
rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) })
rows.push({
name: c.name,
...(builtinRowFace(c.name, c.description, this.t) ?? { description: c.description }),
...(c.input !== undefined ? { hint: c.input.hint } : {}),
})
}
for (const contribution of this.live.contributions.values()) {
if (!contribution.available(session)) continue
if (seen.has(contribution.name)) {
throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`)
}
rows.push({ name: contribution.name, description: contribution.description })
rows.push({
name: contribution.name,
...(contribution.label === undefined ? {} : { label: contribution.label() }),
...(contribution.description === undefined ? {} : { description: contribution.description() }),
...(contribution.icon === undefined ? {} : { icon: contribution.icon }),
})
}
return rankByName(
rows.filter(c => req.position === 'leading' || c.hint === undefined),
req.query,
)
const visible = rows.filter(c => req.position === 'leading' || c.hint === undefined)
return req.query === '' ? sectionRows(visible, this.t) : rankByName(visible, req.query)
}
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
/** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */
private dispatch(pick: InputTriggerPick): PickOutcome {
const name = pick.candidate.name
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(pick.session)) {
this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
this.invoke(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
return 'handled'
}
const desc = this.directory.resolve(pick.session.sessionId, name)
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
// A decoration replaces the HOST row's bare invocation with its popup;
// it decorates only a resolvable host command (checked above), never
// manufactures one, and never touches the argument claim below.
// A decoration replaces the HOST row's bare invocation with its popup or
// action; it decorates only a resolvable host command (checked above),
// never manufactures one, and never touches the argument claim below.
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(pick.session)) {
this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
return 'handled'
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc.name, desc.description, this.t)) }
// Menu-pick execute consumes the trigger span before the detached run
// (scoped event; the input owns the CAS guard).
this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
@@ -236,11 +250,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
/** Decision table, space column: hot-key sync check; only host leadingInput claims. */
private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
if (!token.startsWith('/')) return undefined
const name = token.slice(1)
if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
const name = resolveCommandName(token.slice(1))
if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space
const desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined || desc.input === undefined) return undefined
return { claim: this.leadingClaim(desc, session) }
return { claim: this.leadingClaim(desc, session, token.slice(1)) }
}
/**
@@ -250,10 +264,14 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
* args-tolerant.
*
* Envelope policy: an enter submission carrying attachments resolves only
* through a command declaring attachment acceptance. Every other command route —
* popup, non-accepting claim, bare detached execute — throws the refusal
* so the machine surfaces one composer notice and the draft and attachments
* stay in place; nothing executes and nothing is dropped.
* through a command declaring attachment acceptance. Every other submitting
* route — popup, non-accepting claim, bare detached execute — throws the
* refusal so the machine surfaces one composer notice and the draft and
* attachments stay in place; nothing executes and nothing is dropped. An
* action submits nothing and runs regardless.
*
* A typed token is resolved through the localized claim tokens, so a line
* written as `/计划` reaches the `plan` descriptor and executes as `/plan`.
*/
private async matchEnter(
session: ClientSessionContext,
@@ -266,16 +284,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
const ws = trimmed.search(/\s/)
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
const bare = ws === -1
const name = token.slice(1)
const name = resolveCommandName(token.slice(1))
if (name === '') return undefined
// The Host line always carries the catalog name.
const canonical = `/${name}${trimmed.slice(token.length)}`
const refuseAttachments = (): never => {
throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
}
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(session)) {
if (!bare) return undefined
if (envelope.attachments > 0) refuseAttachments()
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments()
this.invoke(name, contribution.ui, session, { via: 'enter', token })
return 'handled'
}
await this.directory.ensureReady(session.sessionId, signal)
@@ -286,42 +306,57 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
if (bare) {
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(session)) {
if (envelope.attachments > 0) refuseAttachments()
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments()
this.invoke(name, decoration.ui, session, { via: 'enter', token })
return 'handled'
}
}
if (desc.input !== undefined) {
if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments()
return { claim: this.leadingClaim(desc, session) }
return { claim: this.leadingClaim(desc, session, token.slice(1)) }
}
if (!bare) return undefined
if (envelope.attachments > 0) refuseAttachments()
this.consumeVia(session.sessionId, { via: 'enter', token })
this.runDetached(desc, session, trimmed)
this.runDetached(desc, session, canonical)
return 'handled'
}
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
private openPopup(
/**
* Invoke one contribution or decoration (menu pick / bare enter): open the
* session's popup, or consume the token and run the action.
*/
private invoke(
name: string,
ui: CommandContribution['ui'],
session: ClientSessionContext,
segment: TokenSegment,
): void {
if (ui.kind === 'action') {
this.consumeVia(session.sessionId, segment)
ui.run(session)
return
}
const actx = this.scopeFor(session.sessionId)
if (actx === undefined) return
this.popupFor(actx).open(name, ui, session, segment)
}
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
const token = `/${desc.name} `
/**
* Build the leadingInput claim. The composer keeps the claimed token in
* the draft and reads the arguments after it, so the token is the spelling
* the draft will carry: the locale's token for a menu pick, the typed
* spelling for Space and Enter. The command.execute submit transaction
* always sends the catalog name.
*/
private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext, shown: string): CommandClaim {
const token = `/${shown} `
const line = `/${desc.name} `
return {
token,
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
...(desc.input?.attachments === true ? { attachments: true } : {}),
submit: (args, _actx, attachments) => this.execute(session, token + args, attachments),
submit: (args, _actx, attachments) => this.execute(session, line + args, attachments),
}
}
@@ -32,8 +32,11 @@ async function bench() {
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id),
scopeOf: (c: Context) => scopeOf(c),
subagentAddress: (id: SessionId) => id === sid('child')
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
const commandsRemote = { list: () => Promise.resolve([]) }
const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) }
// The service subscribes its cache-invalidation events on construction, so
// the Remote face needs `$on` even where this spec dispatches none.
ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
@@ -73,6 +76,25 @@ describe('apply', () => {
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
})
it('registers the File row: an action that consumes the token and opens the scoped file picker; absent on a subagent', async () => {
const { sources, mint } = await bench()
const source = sources.get('/ command')!
const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal }
const rows = await source.candidates({ sessionId: sid('s1') }, req)
expect(rows).toEqual([{ name: 'file', label: 'File', icon: expect.any(Function) as never, section: 'Add' }])
expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([])
const scope = mint('s1')
const picks: string[] = []
scope.ctx.on('slash/input-consume-token', () => { picks.push('consume'); return true })
scope.ctx.on('slash/input-pick-files', () => { picks.push('pick-files'); return true })
const span = { start: 0, end: 5, draftRev: 1 }
expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('s1') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled')
expect(picks).toEqual(['consume', 'pick-files'])
// A session without a live scope runs nothing and throws nothing.
expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('gone') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled')
expect(picks).toHaveLength(2)
})
it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
const { ctx, slots, mint } = await bench()
const command = ctx.get('commandUi') as CommandUiRuntime
@@ -13,8 +13,9 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { IconGoalOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
import type { CommandDescriptor } from '../src/client/directory.ts'
import { CommandUiRuntime } from '../src/client/service.ts'
@@ -155,7 +156,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi
return source.onPick(pick)
}
const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
const themeUi = (over: Partial<PopupSelectSpec> = {}): PopupSelectSpec => ({
kind: 'popupSelect',
options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
onSelect: () => undefined,
@@ -164,7 +165,7 @@ const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
name: 'theme',
description: 'client popup kind',
description: () => 'client popup kind',
available: () => true,
ui: themeUi(),
...over,
@@ -224,7 +225,7 @@ describe('candidates', () => {
const { source, listCalls } = await bench()
const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
expect(listCalls).toEqual([{ sessionId: sid('s2') }])
expect(names).toEqual(['plan', 'goal', 'attach'])
expect(names).toEqual(['goal', 'plan', 'attach'])
})
it('hides leadingInput commands at inline position', async () => {
@@ -238,7 +239,7 @@ describe('candidates', () => {
const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
command.register(themeContribution({ available }))
const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
expect(s1Names).toEqual(['plan', 'goal', 'theme'])
expect(s1Names).toEqual(['goal', 'plan', 'theme'])
expect(available).toHaveBeenLastCalledWith(proj('s1'))
const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
expect(s2Names).not.toContain('theme')
@@ -257,6 +258,132 @@ describe('candidates', () => {
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
})
describe('menu presentation (design doc for #3567)', () => {
/** The shipped catalog in Host registration order, with the canonical English descriptions. */
const SHIPPED: CommandDescriptor[] = [
{ name: 'compact', description: 'Compact older conversation history' },
{ name: 'export', description: 'Download this Session log as a ZIP archive' },
{ name: 'feedback', description: 'Record feedback about this session', input: { hint: '<text>' } },
{ name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
{ name: 'deploy', description: 'third-party command' },
]
const Glyph = () => null
const fileContribution = (run = vi.fn()): CommandContribution => ({
name: 'file',
label: () => 'command:label.file',
icon: Glyph,
available: () => true,
ui: { kind: 'action', run },
})
const modelContribution = (): CommandContribution => ({
name: 'model',
label: () => '模型',
description: () => '选择本会话使用的模型',
icon: Glyph,
available: () => true,
ui: themeUi(),
})
it('an empty query lists Add then Commands in usage order; built-in rows carry their localized face, other rows their own text', async () => {
const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
command.register(modelContribution())
command.register(fileContribution())
const rows = await source.candidates(proj('s1'), req(''))
expect(rows.map(row => row.name)).toEqual([
'file', 'goal', 'plan', 'feedback', 'compact', 'permission', 'model', 'export', 'deploy',
])
expect(rows.map(row => row.section)).toEqual([
...Array<string>(4).fill('command:section.add'),
...Array<string>(5).fill('command:section.commands'),
])
expect(rows[1]).toEqual({
name: 'goal',
label: 'command:label.goal',
description: 'command:description.goal',
icon: IconGoalOutline16,
hint: '<objective>',
section: 'command:section.add',
})
expect(rows[0]).toEqual({ name: 'file', label: 'command:label.file', icon: Glyph, section: 'command:section.add' })
expect(rows[6]).toMatchObject({ name: 'model', label: '模型', description: '选择本会话使用的模型', icon: Glyph })
// A third-party command keeps its catalog text and gets no glyph.
expect(rows[8]).toEqual({ name: 'deploy', description: 'third-party command', section: 'command:section.commands' })
})
it('a Host row of a built-in name with another description is not the built-in command', async () => {
const commands: CommandDescriptor[] = [{ name: 'goal', description: 'scoped goal', input: { hint: 'x' } }]
const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
const [row] = await source.candidates(proj('s1'), req(''))
expect(row).toEqual({ name: 'goal', description: 'scoped goal', hint: 'x', section: 'command:section.add' })
})
it('a typed query ranks rows flat by name or label and drops the section headings', async () => {
const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
command.register(modelContribution())
const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
await expect(names('模型')).resolves.toEqual(['model'])
await expect(names('label.goal')).resolves.toEqual(['goal'])
await expect(names('ex')).resolves.toEqual(['export'])
// Prefix hits lead; the empty-query section order no longer applies.
await expect(names('pl')).resolves.toEqual(['plan', 'deploy'])
const rows = await source.candidates(proj('s1'), req('pl'))
expect(rows.every(row => row.section === undefined)).toBe(true)
})
it('an action contribution: the menu pick consumes the span and runs it; bare enter runs it even with attachments; an argued line misses', async () => {
const run = vi.fn()
const { command, source, mint, warm } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
command.register(fileContribution(run))
const scope = mint('s1')
const consumes: ConsumeTokenRequest[] = []
scope.ctx.on('slash/input-consume-token', (r) => { consumes.push(r); return true })
await warm(proj('s1'))
expect(menuPick(source, 'file', proj('s1'))).toBe('handled')
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
expect(run).toHaveBeenCalledExactlyOnceWith(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/file', new AbortController().signal, { attachments: 2 })).toBe('handled')
expect(consumes[1]).toEqual({ guard: { kind: 'bare-token', token: '/file' } })
expect(run).toHaveBeenCalledTimes(2)
expect(await source.matchEnter!(proj('s1'), '/file now', new AbortController().signal, { attachments: 0 })).toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/file')).toBeUndefined()
})
it('a built-in claim shows the localized token and submits the catalog name', async () => {
const { source, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
await warm(proj('s1'))
const outcome = menuPick(source, 'plan', proj('s1'))
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the claim')
expect(outcome.claim.token).toBe('/command:token.plan ')
await outcome.claim.submit('do x', new Context(), [])
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan do x', images: [] }])
})
it('a typed localized token resolves to the built-in command on space and enter; the Host line carries the catalog name', async () => {
const { source, mint, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
mint('s1')
await warm(proj('s1'))
const space = source.matchSpace!(proj('s1'), '/计划')
if (space === undefined || space === 'handled' || !('claim' in space)) throw new Error('expected the plan claim')
expect(space.claim.hint).toBe('[off|message]')
// The claim keeps the typed spelling (the draft carries it and the
// arguments are read after it); the submission sends the catalog name.
expect(space.claim.token).toBe('/计划 ')
const enter = await source.matchEnter!(proj('s1'), '/目标 ship it', new AbortController().signal, { attachments: 0 })
if (enter === undefined || enter === 'handled' || !('claim' in enter)) throw new Error('expected the goal claim')
expect(enter.claim.attachments).toBe(true)
expect(enter.claim.token).toBe('/目标 ')
await enter.claim.submit('ship it', new Context(), [])
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }])
const typed = await source.matchEnter!(proj('s1'), '/plan now', new AbortController().signal, { attachments: 0 })
if (typed === undefined || typed === 'handled' || !('claim' in typed)) throw new Error('expected the plan claim')
expect(typed.claim.token).toBe('/plan ')
executeCalls.length = 0
expect(await source.matchEnter!(proj('s1'), '/压缩', new AbortController().signal, { attachments: 0 })).toBe('handled')
await vi.waitFor(() => { expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/compact', images: [] }]) })
})
})
})
describe('decorations (bare-invocation UI on host commands)', () => {
@@ -271,7 +398,7 @@ describe('decorations (bare-invocation UI on host commands)', () => {
const { command, source } = await bench()
command.decorate(goalDecoration())
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
expect(names).toEqual(['plan', 'goal'])
expect(names).toEqual(['goal', 'plan'])
})
it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: e06270a339a23fa17fb9d51642317fcf2aec6c0f
README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024
README.md: cefb6f543151b4d52a817ef1539063c4b3207661
README.zh.md: 385547e06bc90d9a46a2a0b416054efd35acded7
+1 -1
View File
@@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no File row, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)).
<a id="temporary-composer-entries"></a>
## Temporary composer entries
+1 -1
View File
@@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供「文件」菜单项、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。
<a id="temporary-composer-entries"></a>
## 临时 composer entry
@@ -167,6 +167,12 @@ declare module '@deepseek-ai/cordis' {
* @mode bail
*/
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
/**
* Open the composer's file picker for the scoped session (the menu's
* File row); true when a mounted composer accepting files opened it.
* @mode bail
*/
'slash/input-pick-files'(): true | undefined
}
}
@@ -282,6 +288,13 @@ export interface ComposerKeyboard {
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/**
* Bind the composer's file-picker opener for the scoped pick-files event;
* the composer decides inside `open` whether intake is currently allowed.
* @param open - opens the native file dialog.
* @returns the unbind disposer.
*/
bindFilePicker(open: () => void): () => void
}
/** One independently addressable row projected from the transient queue snapshot. */
@@ -154,6 +154,8 @@ export class SessionInputShell implements SessionInput {
private disposed = false
/** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
private mirrorFn: ((text: string) => void) | undefined
/** The mounted composer's file-picker opener (scoped pick-files event target). */
private filePickerFn: (() => void) | undefined
/** Live lexicon subscription disposer; undefined until the controller resolves. */
private lexiconOff: (() => void) | undefined
/** Default sends retained until admission settles or scope disposal releases their attachments. */
@@ -607,6 +609,28 @@ export class SessionInputShell implements SessionInput {
}
}
/**
* Bind the composer's file-picker opener (ComposerKeyboard face; unbind on unmount).
* @param open - opens the native file dialog.
* @returns the unbind disposer.
*/
bindFilePicker(open: () => void): () => void {
this.filePickerFn = open
return () => {
if (this.filePickerFn === open) this.filePickerFn = undefined
}
}
/**
* Open the bound file picker (scoped pick-files event listener body).
* @returns whether a composer was bound to open it.
*/
pickFiles(): boolean {
if (this.filePickerFn === undefined) return false
this.filePickerFn()
return true
}
// ---- effect executor ----
/** The claim token the decoration transform styles; null while unclaimed. */
@@ -121,6 +121,7 @@ export class InputHub implements SessionInputResolver {
shell.consumeToken(req.guard) ? true : undefined),
actx.on('slash/input-insert-text', req =>
shell.insertText(req.text, req.span, req.continue === true) ? true : undefined),
actx.on('slash/input-pick-files', () => shell.pickFiles() ? true : undefined),
]
return () => {
for (const off of offs) off()
@@ -19,7 +19,7 @@ export const zh = {
'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话',
'placeholder.workspace': '选择一个工作区开始',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.commands': '指令',
'input.commands': '添加文件或调用指令',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.send.queue': '排队发送',
@@ -49,7 +49,6 @@ export const zh = {
'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
'file.attach': '添加附件',
'file.pending': '待发送文件',
'file.remove': '移除文件 {name}',
'file.uploading': '上传中…',
@@ -185,7 +184,7 @@ export const en = {
'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions',
'placeholder.workspace': 'Choose a workspace to start',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.commands': 'Commands',
'input.commands': 'Add files or run commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.send.queue': 'Queue message',
@@ -215,7 +214,6 @@ export const en = {
'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again',
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
'file.attach': 'Add attachment',
'file.pending': 'Pending files',
'file.remove': 'Remove file {name}',
'file.uploading': 'Uploading…',
@@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
@@ -266,13 +266,22 @@ export const InputBar = memo(function InputBar({
// registration survives re-renders without re-arming per keystroke.
const gate = useRef({
locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
intakeFiles, uploadsPending, showToast, t,
intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
})
gate.current = {
locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
intakeFiles, uploadsPending, showToast, t,
intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
}
// The menu's File row opens the same hidden input the drop path feeds,
// under the drop gate the row itself cannot see.
useEffect(() => {
if (keyboard === undefined) return
return keyboard.bindFilePicker(() => {
if (gate.current.canAcceptDrop) fileInputRef.current?.click()
})
}, [keyboard])
useEffect(() => {
if (editor === null || keyboard === undefined) return
return registerComposerKeymap(editor, {
@@ -491,18 +500,6 @@ export const InputBar = memo(function InputBar({
<IconPlusOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('file.attach')} side="top" delayMs={500}>
<button
type="button"
className={css.add}
aria-label={t('file.attach')}
disabled={subagent !== null || locked || machineBusy || addFiles === undefined}
onMouseDown={keepFocus}
onClick={() => { fileInputRef.current?.click() }}
>
<IconPaperclipOutline16 size={14} />
</button>
</Tooltip>
<input
ref={fileInputRef}
type="file"
@@ -873,7 +873,7 @@ describe('running and lock semantics', () => {
})
it('running continuable subagent keeps Send beside an independent Stop', () => {
const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({
const { button, interruptButton, textarea, sink, stop, view, slotCalls, shell } = bench({
running: true,
draft: '后续消息',
subagent: {
@@ -888,9 +888,13 @@ describe('running and lock semantics', () => {
expect(button.getAttribute('aria-label')).toBe('排队发送')
expect(interruptButton).not.toBeNull()
expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
// The menu's File row reaches the same gate: a bound picker stays shut.
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
expect(shell.pickFiles()).toBe(true)
expect(click).not.toHaveBeenCalled()
click.mockRestore()
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
fireEvent.click(interruptButton!)
@@ -923,10 +927,14 @@ describe('running and lock semantics', () => {
['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
const added = vi.fn(() => null)
const { view, slotCalls } = bench({ ...projection, addFiles: added })
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false)
const { view, slotCalls, shell } = bench({ ...projection, addFiles: added })
expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(false)
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
// The menu's File row opens the hidden input through the bound picker.
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
expect(shell.pickFiles()).toBe(true)
expect(click).toHaveBeenCalledOnce()
click.mockRestore()
})
it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
@@ -944,7 +952,7 @@ describe('running and lock semantics', () => {
})
expect(textarea.getAttribute('aria-disabled')).toBe('true')
expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(button.disabled).toBe(true)
expect(interruptButton?.disabled).toBe(false)
@@ -996,7 +1004,7 @@ describe('running and lock semantics', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.getAttribute('aria-disabled')).toBe('true')
expect(placeholderOf(view.container)).toBe('会话不可用')
expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
@@ -1129,7 +1137,7 @@ describe('running and lock semantics', () => {
expect(editableOf(textarea)).toBe(false)
expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
expect(textarea.getAttribute('aria-expanded')).toBe('false')
expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -1432,7 +1440,7 @@ describe('strips and variants', () => {
describe('command launcher chrome and control seats', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('指令')).toBeTruthy()
expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Every seat dispatched, nothing rendered (render passes may repeat; the
@@ -1451,7 +1459,7 @@ describe('command launcher chrome and control seats', () => {
const toggleCommandMenu = vi.fn()
const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
const launcher = view.getByLabelText('指令')
const launcher = view.getByLabelText('添加文件或调用指令')
expect(launcher.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(launcher)
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
@@ -1621,7 +1629,7 @@ describe('command launcher chrome and control seats', () => {
it('disabled locks the Access chip and command launcher (running does not)', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })
@@ -317,7 +317,7 @@ describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect(textarea.getAttribute('aria-disabled')).toBe('true')
expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
README.md: 9ad568027e4be8f2e9cc641270cbf6b2f870c59c
README.zh.md: 2926eb87d4738387cf6dc56d38a02f579ed267c4
README.md: 60c96df61d12b9b3051faafb1a38ec18e5280dbe
README.zh.md: 3f9b35d02367f386497facec1319e4fc12a81921
+1 -2
View File
@@ -25,7 +25,7 @@ This package powers the input trigger pipeline of the Web GUI: it detects `/` an
<a id="use-this-package"></a>
## Use this package
Mount this plugin alongside `ui-conversation`; the menu then appears in the input overlay when the user types a trigger under the caret. Grouped candidates render under title rows; a pick routes to the source, and the consuming surface applies the result — a slash command opens its popup or executes, a reference inserts its inline token.
Mount this plugin alongside `ui-conversation`; the menu then appears in the input overlay when the user types a trigger under the caret. Grouped candidates render under title rows, or under the section headings a source attaches to its own rows; a pick routes to the source, and the consuming surface applies the result — a slash command opens its popup or executes, a reference inserts its inline token. A row shows its icon, its title (the candidate `label`, or the `name` when no label is given), the `name` as a trailing alias when the label is not the name in another letter case, and the description right-aligned; a query matches either the name or the label.
### Keyboard and mouse
@@ -74,7 +74,6 @@ None; this package neither assembles nor sends a provider request.
These limits define the current trigger pipeline. They are current package constraints, not a general menu comparison or a task backlog.
- **Global source layer only** — session-scope source registration (per-session shadowing) is designed but not enabled; the ledger tracks the trigger condition, a real per-session source need.
- **`InputTriggerCandidate.icon` renders as text** — `MenuView` drops the string into the icon slot verbatim; wiring to the design-system icon enum lands when that enum ships.
- **Overlay SlotMap merge home is split from slot ownership** — the sole `conversation.input.overlay` merge lives here, while ui-conversation owns its anchor, children declaration, and lifecycle because the dependency direction is ui-conversation → ui-input-trigger.
<a id="dev-note"></a>
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
与 `ui-conversation` 一起挂载本插件;用户在光标处键入触发器时,菜单随即出现在输入浮层中。分组候选项渲染在标题行之下;pick 路由到 source,消费方表面应用其结果——斜杠命令打开其弹窗或执行,引用插入其行内 token。
与 `ui-conversation` 一起挂载本插件;用户在光标处键入触发器时,菜单随即出现在输入浮层中。分组候选项渲染在标题行之下,或渲染在 source 附加在自己各行上的小节标题之下;pick 路由到 source,消费方表面应用其结果——斜杠命令打开其弹窗或执行,引用插入其行内 token。每一行显示图标、标题(候选项的 `label`,没有 label 时显示 `name`)、当标题不是 `name` 的另一种大小写写法时跟在标题后的 `name` 别名,以及右对齐的说明;查询同时匹配 name 与 label。
### 键盘与鼠标
@@ -74,7 +74,6 @@ kind: "package-reference"
这些限制界定了当前触发流水线。它们是当前包约束,不是通用菜单对比或任务积压。
- **只有全局 source 层**——会话 scope 的 source 注册(逐会话遮蔽)已有设计但未启用;台账记录着触发条件,即真实的逐会话 source 需求。
- **`InputTriggerCandidate.icon` 以文本渲染**——`MenuView` 把该字符串原样放进图标位;与设计系统图标枚举的接入将在该枚举交付后完成。
- **overlay 的 SlotMap 合并归属与槽位所有权分离**——唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。
<a id="dev-note"></a>
@@ -86,12 +86,26 @@
white-space: nowrap;
}
/* The command name beside a localized title, in the description's ink so
the row reads title first. */
.itemAlias {
flex: none;
max-width: 20%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
/* Right-aligned so every description ends at the same edge and the titles
read as one left column. */
.itemDescription {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
color: var(--dsw-alias-label-tertiary);
}
@@ -7,8 +7,10 @@
* rows only while it has none; pointer picks route back through
* the service (combobox pattern — focus never leaves the textarea, so rows
* are mousedown-handled and the highlight is exposed via
* aria-activedescendant on the listbox). A source publishing crumbs gets a
* breadcrumb header pinned above the scrolling list.
* aria-activedescendant on the listbox). A row reads title, then the
* command-name alias when the title is not the name in another letter case
* (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb
* header pinned above the scrolling list.
*/
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
import clsx from 'clsx'
@@ -150,10 +152,15 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
>
{item.icon !== undefined && (
<span className={css.itemIcon} aria-hidden>
<ReferenceIcon kind={item.icon} size={16} />
{typeof item.icon === 'string'
? <ReferenceIcon kind={item.icon} size={16} />
: <item.icon size={16} />}
</span>
)}
<span className={css.itemName}>{item.name}</span>
<span className={css.itemName}>{item.label ?? item.name}</span>
{item.label !== undefined && item.label.toLowerCase() !== item.name.toLowerCase() && (
<span className={css.itemAlias}>{item.name}</span>
)}
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
{item.drill === true && (
<span className={css.trailing}>
+13 -2
View File
@@ -7,9 +7,11 @@
* never a Cordis context or the mutable Session. RPC and service access go
* through the provider plugin's own root context captured at registration.
*/
import type { ComponentType } from 'react'
import type {
PickOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
export type {
@@ -40,14 +42,23 @@ export type PickVia = 'menu' | 'space' | 'enter'
/** What a pick asks for: resolve the candidate, or drill into it in place. */
export type PickAction = 'pick' | 'drill'
/** Leading glyph token of one menu candidate, mapped to its SVG by the menu view. */
/** Leading glyph token of one menu candidate, mapped to its reference SVG by the menu view. */
export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session'
/** One menu candidate. Pure display data — zero behavior declaration. */
export interface InputTriggerCandidate {
/** Identity: the pick payload, the exact-match key, and the first search key. */
readonly name: string
/**
* Display title; the name itself when absent. A label that is not the
* name in another letter case renders with the name as a trailing alias,
* and every label is the second search key (a localized title stays
* findable by its command name).
*/
readonly label?: string
readonly description?: string
readonly icon?: InputTriggerCandidateIcon
/** Reference glyph token, or an icon component from the shared icon set. */
readonly icon?: InputTriggerCandidateIcon | ComponentType<IconProps>
readonly hint?: string
/** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */
readonly section?: string
@@ -114,6 +114,26 @@ describe('MenuView', () => {
expect(status.children).toHaveLength(2)
})
it('renders a localized label as the title with the name as its alias, an icon component, and the description', () => {
const Glyph = ({ size = 16 }: { size?: number | undefined }) => <svg data-glyph="plan" width={size} height={size} />
mount(openState({
groups: [{
source: 'command',
status: 'ready',
items: [
{ name: 'plan', label: '计划', description: '进入或退出计划模式', icon: Glyph, section: '添加' },
{ name: 'file', label: 'File', section: '添加' },
],
}],
}))
const options = screen.getAllByRole('option')
expect(options.map(o => o.textContent)).toEqual(['计划plan进入或退出计划模式', 'File'])
expect(options[0]?.querySelector('[data-glyph="plan"]')?.getAttribute('width')).toBe('16')
// A label that is the name in another letter case renders no alias.
expect(options[1]?.querySelectorAll('span')).toHaveLength(1)
expect(screen.getAllByText('添加')).toHaveLength(1)
})
it('keeps an opted-out source title hidden while its candidates are pending', () => {
mount(openState({
groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }],
@@ -22,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelDirectoryState } from './directory.ts'
import { ModelDirectoryResolver } from './service.ts'
import type { ModelSelectInjected } from './slots.ts'
@@ -120,16 +121,16 @@ export function apply(ctx: ClientContext): void {
// a locale change reaches the next publish.
ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') })
// Entry 1: the /model popupSelect over the shared directory. The command
// description is registry-held text: it reads t() once at registration and
// refreshes only on re-registration, not on locale change.
// Entry 1: the /model popupSelect over the shared directory.
ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => {
const command = scope.get('commandUi') as CommandUiContract
const models = scope.modelDirectories
const sessions = scope.sessions
scope.effect(() => command.register({
name: 'model',
description: t('command.description'),
label: () => t('command.label'),
description: () => t('command.description'),
icon: IconDataOutline16,
available: session => sessions.subagentAddress(session.sessionId) === undefined,
ui: {
kind: 'popupSelect',
@@ -10,6 +10,7 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command.label': '模型',
'command.description': '选择本会话使用的模型',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
@@ -35,6 +36,7 @@ export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'command.label': 'Model',
'command.description': 'Select the model for this conversation',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
@@ -16,7 +16,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
import { zh } from '../src/client/locales.ts'
@@ -157,6 +157,11 @@ async function bench() {
return {
ctx, fiber, mint, calls, remote,
contribution: () => contribution!,
popup: (): PopupSelectSpec => {
const ui = contribution!.ui
if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind')
return ui
},
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => selected,
setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
@@ -182,7 +187,7 @@ describe('ui-model-selection dual entry', () => {
it('popup options mark the host current active with the provider group in the detail', async () => {
const b = await bench()
b.mint('s1')
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
const options = await b.popup().options(projection('s1'), new AbortController().signal)
expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
expect(options[1]?.active).toBeUndefined()
@@ -209,7 +214,7 @@ describe('ui-model-selection dual entry', () => {
reasoningEffort: 'max',
})
// The POPUP's next options pass reflects it without a seat-side reload.
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
const options = await b.popup().options(projection('s1'), new AbortController().signal)
expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
})
@@ -217,9 +222,9 @@ describe('ui-model-selection dual entry', () => {
const b = await bench()
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
const options = await b.popup().options(projection('s1'), new AbortController().signal)
const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
await b.contribution().ui.onSelect(pro, projection('s1'))
await b.popup().onSelect(pro, projection('s1'))
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
@@ -239,8 +244,8 @@ describe('ui-model-selection dual entry', () => {
// The service face resolves the same instance the seat inject handed out.
expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
await Promise.all([
b.contribution().ui.options(projection('a'), new AbortController().signal),
b.contribution().ui.options(projection('b'), new AbortController().signal),
b.popup().options(projection('a'), new AbortController().signal),
b.popup().options(projection('b'), new AbortController().signal),
])
expect(b.calls.models).toBe(1)
})
@@ -369,7 +374,7 @@ describe('ui-model-selection dual entry', () => {
b.address(sid('child'))
expect(b.contribution().available(projection('child'))).toBe(false)
await expect(b.contribution().ui.options(
await expect(b.popup().options(
projection('child'),
new AbortController().signal,
)).rejects.toThrow(/unavailable for addressed subagent/)
@@ -15,7 +15,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
import {
PermissionRow, type PermissionRowInjected,
@@ -82,6 +82,11 @@ async function bench() {
ctx, fiber, locale, values, commands, remote,
setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
decoration: () => decoration,
popup: (): PopupSelectSpec => {
const ui = decoration!.ui
if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind')
return ui
},
permissionRow: () => ctx.slots.entries('settings.general.item')
.find(entry => entry.component === PermissionRow),
}
@@ -110,11 +115,11 @@ describe('ui-permission browser plugin', () => {
expect(c.available(proj)).toBe(false)
b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
expect(c.available(proj)).toBe(true)
const options = await c.ui.options(proj, new AbortController().signal)
const options = await b.popup().options(proj, new AbortController().signal)
expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
expect(options.every(option => option.active !== true)).toBe(true)
b.values.set(sid('s1'), SELECT)
const again = await c.ui.options(proj, new AbortController().signal)
const again = await b.popup().options(proj, new AbortController().signal)
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
// English built-ins use product labels; other kebab-case names title-case.
@@ -127,7 +132,7 @@ describe('ui-permission browser plugin', () => {
confirmLabel: 'Enable Full access',
})
b.locale.setLocale('zh')
const localized = await c.ui.options(proj, new AbortController().signal)
const localized = await b.popup().options(proj, new AbortController().signal)
expect(localized.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限'])
expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
title: '确认启用完全权限?',
@@ -143,28 +148,27 @@ describe('ui-permission browser plugin', () => {
{ value: '__proto__', name: '__proto__' },
{ value: 'plain', name: 'Ask Every Time' },
] })
const passthrough = await c.ui.options(proj, new AbortController().signal)
const passthrough = await b.popup().options(proj, new AbortController().signal)
expect(passthrough.map(option => option.label)).toEqual([
'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
])
// A projection that vanished between availability and open throws.
expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
expect(() => b.popup().options({ sessionId: sid('ghost') }, new AbortController().signal))
.toThrow(/not available on this host/)
})
it('a pick submits the /permission line; rejection and unmatched throw', async () => {
const b = await bench()
const c = b.decoration()!
const proj = { sessionId: sid('s1') }
b.values.set(sid('s1'), SELECT)
await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
await b.popup().onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
expect(b.commands).toEqual(['/permission danger-full-access'])
b.setResult({ ok: false })
await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
b.setResult({ ok: true, matched: false })
await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
// An unmaterialized session throws before any submit.
await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
.rejects.toThrow(/not materialized/)
})
@@ -1,8 +1,9 @@
/**
* Shared ranking for `/` menu candidates: the query must be a
* case-insensitive ordered subsequence of the candidate name. Prefix hits
* rank first, then the strongest alignment score, then the source order of
* the input. Decision record:
* case-insensitive ordered subsequence of the candidate name or, when the
* candidate carries one, of its display label (a localized title). Prefix
* hits rank first, then the strongest alignment score over either key, then
* the source order of the input. Decision record:
* .agents/notes/archived/feature/2026-08-04-web-slash-command-fuzzy-discovery.md
*/
@@ -59,19 +60,32 @@ function alignmentScore(name: string, query: string): number | undefined {
/**
* Rank named items by a menu query.
* @param items - candidates in source order (a host catalog, then client contributions).
* @param items - candidates in source order (a host catalog, then client
* contributions); an item's optional `label` is a second search key beside
* its name.
* @param rawQuery - the text typed after the trigger, matched case-insensitively.
* @returns the matching items: prefix hits first, then by alignment score,
* then in source order. The input list itself for an empty query.
*/
export function rankByName<T extends { readonly name: string }>(items: readonly T[], rawQuery: string): readonly T[] {
export function rankByName<T extends { readonly name: string; readonly label?: string }>(
items: readonly T[],
rawQuery: string,
): readonly T[] {
const query = rawQuery.toLowerCase()
if (query === '') return items
const ranked: Ranked<T>[] = []
items.forEach((item, index) => {
const name = item.name.toLowerCase()
const score = alignmentScore(name, query)
if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score })
const keys = item.label === undefined ? [item.name] : [item.name, item.label]
let prefix = false
let score: number | undefined
for (const key of keys) {
const lower = key.toLowerCase()
const keyScore = alignmentScore(lower, query)
if (keyScore === undefined) continue
prefix ||= lower.startsWith(query)
score = score === undefined ? keyScore : Math.max(score, keyScore)
}
if (score !== undefined) ranked.push({ item, index, prefix, score })
})
ranked.sort((left, right) =>
Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
@@ -34,6 +34,20 @@ describe('rankByName', () => {
expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab'])
})
it('matches the display label as a second key and keeps the stronger alignment', () => {
const items = [
{ name: 'goal', label: '目标' },
{ name: 'plan', label: '计划' },
{ name: 'permission', label: '权限' },
]
expect(names(rankByName(items, '目标'))).toEqual(['goal'])
expect(names(rankByName(items, 'goal'))).toEqual(['goal'])
expect(names(rankByName(items, 'p'))).toEqual(['plan', 'permission'])
expect(names(rankByName(items, '划'))).toEqual(['plan'])
// A label prefix hit ranks like a name prefix hit.
expect(names(rankByName([{ name: 'xplan', label: 'plan' }, { name: 'plant' }], 'pla'))).toEqual(['xplan', 'plant'])
})
it('returns the ranked items with their payload intact', () => {
const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }]
expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }])
@@ -60,7 +60,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
export function apply(ctx: Context): void {
ctx.commands.register({
name: 'feedback',
description: 'record feedback about this session',
description: 'Record feedback about this session',
input: { hint: '<text>' },
recordInput: false,
handler: executeFeedbackCommand,
@@ -89,7 +89,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => {
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
description: 'Record feedback about this session',
input: { hint: '<text>' },
})
expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
+1 -1
View File
@@ -189,7 +189,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
export function apply(ctx: Context): void {
ctx.commands.register({
name: 'goal',
description: 'set or view the goal for a long-running task',
description: 'Set or view the goal for a long-running task',
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
handler: invocation => executeGoalCommand(ctx, invocation),
})
@@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => {
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'goal',
description: 'set or view the goal for a long-running task',
description: 'Set or view the goal for a long-running task',
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
})
expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
+1 -3
View File
@@ -29,9 +29,7 @@
- img
- text: "Failed Bash Error: tool call aborted before dispatch"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
@@ -117,9 +117,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -47,9 +47,7 @@
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -39,9 +39,7 @@
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -47,9 +47,7 @@
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -39,9 +39,7 @@
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -41,9 +41,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high":
@@ -51,9 +51,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -35,9 +35,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -229,9 +229,7 @@
- button "Back to bottom":
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -117,9 +117,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -1,3 +1,3 @@
- listbox "Trigger suggestions":
- text: Commands
- option "compact Compact older conversation history" [selected]
- option "Compact Compact older conversation history" [selected]
@@ -1,9 +1,11 @@
- listbox "Trigger suggestions":
- text: Add
- option "File" [selected]
- option "Goal Set or view the goal for a long-running task"
- option "Plan Enter or leave plan mode"
- option "Feedback Record feedback about this session"
- text: Commands
- option "compact Compact older conversation history" [selected]
- option "export Download this Session log as a ZIP archive"
- option "feedback record feedback about this session"
- option "goal set or view the goal for a long-running task"
- option "permission Switch the permission preset (sandbox mode + approval policy)"
- option "plan Enter or leave plan mode"
- option "model Select the model for this conversation"
- option "Compact Compact older conversation history"
- option "Permission Switch the permission preset (sandbox mode + approval policy)"
- option "Model Select the model for this conversation"
- option "Export Download this Session log as a ZIP archive"
@@ -31,9 +31,7 @@
- img
- textbox "Describe what you want to build, / commands, @ files or sessions":
- paragraph
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -30,9 +30,7 @@
- text: Standard mode
- img
- textbox "Describe what you want to build, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode on, press to turn off": Plan
@@ -43,9 +43,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -35,9 +35,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -40,9 +40,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -36,9 +36,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -26,9 +26,7 @@
- text: This turn failedAPI key is invalid
- code: AUTH
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -25,9 +25,7 @@
- paragraph: partial
- status: Deep diving...
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -28,9 +28,7 @@
- text: This turn failedupstream 503
- code: SERVER
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -45,9 +45,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -35,9 +35,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -26,9 +26,7 @@
- status: Deep diving...
- textbox "Message or run a task, / commands, @ files or sessions":
- paragraph: Queue this follow-up while the current turn is running.
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
+1 -3
View File
@@ -65,9 +65,7 @@
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
+1 -3
View File
@@ -40,9 +40,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -61,9 +61,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -41,9 +41,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
+1 -3
View File
@@ -62,9 +62,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -53,9 +53,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -35,9 +35,7 @@
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -32,9 +32,7 @@
- listitem: Which color do you prefer?
- button "Inspect"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -26,9 +26,7 @@
- status: Deep diving...
- button "2 queued messages"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -42,9 +42,7 @@
- button "Cancel editing":
- img
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -1,9 +1,7 @@
- alert: Queue submission failed (session/agent-busy)
- textbox "Message or run a task, / commands, @ files or sessions":
- paragraph: Queue submission to retry
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -42,9 +42,7 @@
- img
- button "2 queued messages"
- textbox "Cmd/Ctrl+Enter steers all queued messages"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -59,9 +59,7 @@
- button "Steer queued message" [disabled]:
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -55,9 +55,7 @@
- button "Steer queued message" [disabled]:
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
@@ -35,9 +35,7 @@
- button "Steer queued message" [disabled]:
- img
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more