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,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')
}