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:
1 parent
2c9eda5b6f
commit
5b1bb021cf
119 files changed
+872
-380
No files matched your search
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md
|
||||
2026-09-08-composer-menu-sections-and-localized-rows.md: c8fc2226a15828de14f623fc582473e72e3ec1f1
|
||||
2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 5905c872ab583294aabf15a1b69f2dfe1312e4ba
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Composer menu sections, localized rows, and the File row
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-composer-menu-sections-and-localized-rows.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The composer's `+` button and a typed `/` listed every command in Host registration order as `name description`, all lowercase, with no glyphs and no grouping, beside a separate paperclip button for files. Under Chinese the rows stayed English because Host descriptors carry English text only, and a user who knew a command by its Chinese title could neither find it by that title nor see what to type. Issue #3567 and the design doc for it ask for two sections in usage order, a glyph and a left-aligned title per row with the description right-aligned, capitalized English titles, Chinese titles and descriptions that stay searchable in both languages and show the English command name, a Chinese fill for the Plan and Goal claims, and a File entry inside the menu.
|
||||
|
||||
## Decision
|
||||
|
||||
`ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first.
|
||||
|
||||
The six built-in Host commands get a client face from the `command` dictionaries — title, description, glyph, and claim token — when the catalog description equals the canonical English text; a scoped override or third-party command of the same name keeps its own description and only its section position follows the name. The English dictionary is byte-equal to the Host descriptors, and the `feedback` and `goal` descriptors are capitalized to match the other four. Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration.
|
||||
|
||||
A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. `resolveCommandName` maps every localized token of every dictionary back to its command name, so `/计划` typed under any locale reaches the `plan` descriptor on Space and Enter, and a bare `/压缩` executes as `/compact`.
|
||||
|
||||
The File row is an `action` contribution, a new `CommandUiSpec` kind whose bare invocation consumes the trigger token and runs a client callback without submitting anything; it therefore never refuses an attachment-carrying draft. The row raises the scoped `slash/input-pick-files` event, which `ui-conversation` routes to the composer's hidden file input through `ComposerKeyboard.bindFilePicker` under the same gate as drop intake; the paperclip button is removed. The `+` button's accessible name and tooltip read "Add files or run commands".
|
||||
|
||||
`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; the client already owns product copy for every other surface (the locale-owned client UI copy decision), and the equality guard keeps a scoped or third-party descriptor verbatim.
|
||||
|
||||
**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete.
|
||||
|
||||
**Keep the paperclip beside the menu.** The design doc and the issue's acceptance criteria integrate the entry into the menu and forbid the old icon from showing twice; the hidden file input and the drop gate stay where they were.
|
||||
|
||||
**A contribution-owned copy for the built-in Host rows.** Host packages have no client half to register from, so the copy has to live on the client; one table keyed by name in the package that already owns the `/` source keeps the design decision in one place.
|
||||
|
||||
**A `token` alias only under the active locale.** Resolving through every dictionary costs nothing and lets a draft persisted under one locale submit under another.
|
||||
|
||||
## Consequences
|
||||
|
||||
The menu, the Host catalog, and the localized face are three separate facts joined by name and by the canonical English description; adding a built-in Host command means adding its `label.*`, `description.*`, and `token.*` keys, its glyph in `presentation.ts`, and its section position. The web e2e goldens and every expected ARIA snapshot that showed the paperclip or the `Commands` button changed in the same PR. A third-party command named like a built-in one is positioned by name but keeps its own description and gets no glyph, which is the intended reading of "the same name, another command".
|
||||
+37
@@ -0,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 ')
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
Reference in new issue
Block a user