refactor(client): command decorations replace the hostBacked contribution mode

A popup on a host command is not a second command — it is what that
command's BARE invocation does on this client. CommandContribution loses
hostBacked (contributions are pure client commands again; a host-name
collision fails loud, unchanged for /model), and the contract gains
CommandDecoration + command.decorate(): key = the HOST command name, no
catalog row, no claim participation. Dispatch consults decorations only on
the bare paths (menu pick / bare enter) after the host row resolves; space
and argued enter never see them — the two edges hostBacked had to guard
explicitly hold by construction in the decoration model. A decorated name
with no host row in the session's directory never fires (a decoration
cannot manufacture a command).

ui-permission switches register→decorate with zero behavior change
(options still read the permissions projection; a pick still submits
'/permission <preset>'). Specs rewrite to the decoration semantics: no
catalog row, bare-enter popup vs argued-enter host claim, space host
claim, no-host-row miss, unavailable fall-through, duplicate fail-loud.
This commit is contained in:
imccyu
2026-07-29 12:01:36 +08:00
parent 79dbe4c6fb
commit 83c2115de8
12 changed files with 150 additions and 81 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md
README.md: 1782f89c0909ea80f8554bb9b271947a39ae7f8f
README.zh.md: 6c750329df5bfb25f738869eeb82ea26fa1b8634
README.md: 0cd8e7f878a151ffacd749eb625afcb20d44ad93
README.zh.md: 6bc299529c9795ef44cbe5429e78d6355c02a6ca

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Permission preset selection plugin, browser half: the `/permission` popupSelect contribution (registered through `ctx.command`). The contribution is `hostBacked` — the host's `/permission` command owns the slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; this entry supplies only the bare-invocation picker: one flat preset list with the current value marked active, where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The contribution is available exactly while the projection key is present; a permission-less composition shows no picker.
Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
The `/client` export surface is the plugin body (`apply`/`inject`).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
权限预设选择插件(浏览器半侧):`/permission` popupSelect contribution`ctx.command` 注册)。该 contribution 是 `hostBacked`宿主背书的——host 的 `/permission` 命令拥有斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;本入口只提供裸调用的选择框:一张扁平预设列表,当前值标记为 active选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。contribution 恰在投影 key 存在时可用;无权限组合不显示选择框。
权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.command.decorate`。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)
`/client` 导出面为插件本体(`apply`/`inject`)。

View File

@@ -1,14 +1,14 @@
/**
* Permission preset plugin, browser half — the `/permission` popupSelect
* (the bare-invocation picker the user asked for: one flat list of presets,
* current value marked active, a pick executes the switch). The contribution
* is hostBacked: the host's `/permission` command owns the catalog row, the
* argued path (`/permission <preset>` still switches directly), and the
* lifecycle logging — this entry only opens the picker on a bare pick/enter.
* Options and the active mark read the session's `permissions` projection
* (the same host-computed select the composer chip renders); a pick submits
* the `/permission <preset>` command line, so both surfaces write through
* one path and the pushed projection frame is the one confirmation.
* Permission preset plugin, browser half — a popupSelect DECORATION hung on
* the host `/permission` command: one flat list of presets, current value
* marked active, a pick executes the switch. The decoration owns only the
* bare invocation; the host command keeps its catalog row, the argued path
* (`/permission <preset>` still switches directly), and the lifecycle
* logging. Options and the active mark read the session's `permissions`
* projection (the same host-computed select the composer chip renders); a
* pick submits the `/permission <preset>` command line, so both surfaces
* write through one path and the pushed projection frame is the one
* confirmation.
*/
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
@@ -45,10 +45,8 @@ export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
sessions.binding(session.sessionId)?.session
ctx.effect(() => command.register({
ctx.effect(() => command.decorate({
name: 'permission',
description: 'Switch the permission preset (sandbox mode + approval policy)',
hostBacked: true,
// The picker exists exactly while the projection does: a permission-less
// host serves no key and the bare invocation falls through to the host
// command (which is absent too — the line simply misses).
@@ -68,5 +66,5 @@ export function apply(ctx: ClientContext): void {
if (!result.value.matched) throw new Error('the host offers no /permission command')
},
},
}), 'ui-permission: /permission contribution')
}), 'ui-permission: /permission decoration')
}

View File

@@ -1,7 +1,7 @@
/**
* ui-permission browser half on a real cordis Context with fake command/
* sessions faces: the plugin registers the hostBacked /permission popup
* contribution; options flatten the session's permissions projection with
* sessions faces: the plugin hangs the /permission popup decoration on the
* host command; options flatten the session's permissions projection with
* the current value active and `custom` excluded; availability follows the
* projection key's presence; a pick submits the /permission line through
* Session.command and surfaces rejection/unmatched as thrown errors; fiber
@@ -10,7 +10,7 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandContribution } from '@deepseek-ai/dsh-client-ui-command/client'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import { apply, inject } from '../src/client/index.ts'
@@ -27,11 +27,11 @@ const SELECT: PermissionSelect = {
async function bench() {
const ctx = new Context()
let contribution: CommandContribution | undefined
let decoration: CommandDecoration | undefined
ctx.provide('command', {
register(c: CommandContribution) {
contribution = c
return () => { contribution = undefined }
decorate(c: CommandDecoration) {
decoration = c
return () => { decoration = undefined }
},
})
const values = new Map<SessionId, PermissionSelect>()
@@ -59,22 +59,21 @@ async function bench() {
return {
ctx, fiber, values, commands,
setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
contribution: () => contribution,
decoration: () => decoration,
}
}
describe('ui-permission browser plugin', () => {
it('registers the hostBacked /permission popup contribution', async () => {
it('hangs the /permission popup decoration on the host command', async () => {
const b = await bench()
const c = b.contribution()!
const c = b.decoration()!
expect(c.name).toBe('permission')
expect(c.hostBacked).toBe(true)
expect(c.ui.kind).toBe('popupSelect')
})
it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
const b = await bench()
const c = b.contribution()!
const c = b.decoration()!
const proj = { sessionId: sid('s1') }
expect(c.available(proj)).toBe(false)
b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
@@ -93,7 +92,7 @@ describe('ui-permission browser plugin', () => {
it('a pick submits the /permission line; rejection and unmatched throw', async () => {
const b = await bench()
const c = b.contribution()!
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)
@@ -107,10 +106,10 @@ describe('ui-permission browser plugin', () => {
.rejects.toThrow(/not materialized/)
})
it('disposal removes the contribution (HMR safety)', async () => {
it('disposal removes the decoration (HMR safety)', async () => {
const b = await bench()
expect(b.contribution()).toBeDefined()
expect(b.decoration()).toBeDefined()
await b.fiber.dispose()
expect(b.contribution()).toBeUndefined()
expect(b.decoration()).toBeUndefined()
})
})