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
@@ -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()
})
})