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

@@ -1,6 +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
README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
README.zh.md: 1291556409b993aa893e102386f75c45bb195adf
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, 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 — a `/` line is never silently downgraded to a plain prompt.

View File

@@ -4,7 +4,7 @@
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter与生命周期记账被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key每个会话恒为 agent-backed因此 `command.list({sessionId})` 是唯一的寻址形状source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。

View File

@@ -30,29 +30,37 @@ export type CommandUiSpec = {
* 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 — UNLESS the contribution declares `hostBacked`:
* then the same-named host command owns execution and the contribution only
* supplies the bare-invocation picker (menu row stays the host's; a bare
* pick/enter opens the popup; a line with arguments falls through to the
* host command's own path).
* synthesis, never shadows.
*/
export interface CommandContribution {
/** Command name without the leading slash (unique across contributions). */
readonly name: string
/** Menu row description. */
readonly description: string
/**
* Cooperate with the same-named host command instead of colliding: the
* popup is the bare-invocation UI, the host command is the executor (its
* catalog row, argument claim, and lifecycle logging stand unchanged).
*/
readonly hostBacked?: true
/** Capability filter, called with a fresh projection per candidate pass. */
available(session: ClientSessionContext): boolean
/** The command's UI behavior (this phase: popupSelect only). */
readonly ui: CommandUiSpec
}
/**
* A UI decoration hung on one HOST command: what its BARE invocation does on
* this client. Not a second command — the host command keeps its catalog
* row, its argument claim (space / argued enter), and its lifecycle logging;
* the decoration replaces only the bare menu-pick/enter with a popup whose
* onSelect typically submits a completed line back through command.execute.
* A decoration never manufactures a row: a name with no host catalog entry
* in the session's directory simply never reaches the decoration.
*/
export interface CommandDecoration {
/** The HOST command name this decorates (without the leading slash). */
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). */
readonly ui: CommandUiSpec
}
/** The `ctx.command` service face visible to business packages. */
export interface CommandServiceContract {
/**
@@ -60,6 +68,11 @@ export interface CommandServiceContract {
* names throw at registration.
*/
register(contribution: CommandContribution): () => void
/**
* Hang a bare-invocation decoration on one host command; effect disposer.
* Duplicate names throw at registration.
*/
decorate(decoration: CommandDecoration): () => void
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
popupFor(actx: ClientContext): unknown
}

View File

@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
} from './contract.ts'
declare module 'cordis' {

View File

@@ -14,7 +14,7 @@ import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SubmitOutcome,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandServiceContract } from './contract.ts'
import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
import type { CommandDescriptor } from './directory.ts'
import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
@@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts'
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
readonly contributions: Map<string, CommandContribution>
readonly decorations: Map<string, CommandDecoration>
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
}
@@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
/**
* @param ctx - owning root context (plugin fiber; the service registers
@@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract {
return () => { void dispose() }
}
/**
* Hang a bare-invocation decoration on one host command; effect disposer
* (rides the caller's fiber). Duplicate names throw.
* @param decoration - host command name + availability + popup spec.
* @returns the disposer removing the registration.
*/
decorate(decoration: CommandDecoration): () => void {
const dispose = this.ctx.effect(() => {
const { decorations } = this.live
if (decorations.has(decoration.name)) {
throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
}
decorations.set(decoration.name, decoration)
return () => { decorations.delete(decoration.name) }
}, 'command.decorate()')
return () => { void dispose() }
}
/**
* Resolve the per-session popup controller (lazy; dies with the session
* scope). The controller's consume callback dispatches the scoped
@@ -139,9 +158,6 @@ export class CommandService extends Service implements CommandServiceContract {
for (const contribution of this.live.contributions.values()) {
if (!contribution.available(session)) continue
if (seen.has(contribution.name)) {
// hostBacked cooperates: the host's catalog row stands, the
// contribution only supplies the bare-invocation popup.
if (contribution.hostBacked === true) continue
throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`)
}
rows.push({ name: contribution.name, description: contribution.description })
@@ -151,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract {
.filter(c => req.position === 'leading' || c.hint === undefined)
}
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
private dispatch(pick: SlashPick): PickOutcome {
const name = pick.candidate.name
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(pick.session)) {
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
this.openPopup(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.
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 })
return 'handled'
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
// Menu-pick execute consumes the trigger span before the detached run
// (scoped event; the input owns the CAS guard).
@@ -173,10 +197,7 @@ export class CommandService extends Service implements CommandServiceContract {
private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
if (!token.startsWith('/')) return undefined
const name = token.slice(1)
// Popup kinds never claim on space; a hostBacked popup defers to the
// host command's own claim (the popup serves only the bare invocation).
const spaceContribution = this.live.contributions.get(name)
if (spaceContribution !== undefined && spaceContribution.hostBacked !== true) return undefined
if (this.live.contributions.has(name)) return undefined // popup 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) }
@@ -198,17 +219,22 @@ export class CommandService extends Service implements CommandServiceContract {
if (name === '') return undefined
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(session)) {
if (bare) {
this.openPopup(contribution, session, { via: 'enter', token })
return 'handled'
}
// hostBacked + arguments: the host command owns the argued path
// (claim or detached run below); a pure contribution stays bare-only.
if (contribution.hostBacked !== true) return undefined
if (!bare) return undefined
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
return 'handled'
}
await this.directory.ensureReady(session.sessionId, signal)
const desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined) return undefined
// Bare enter on a decorated host command opens its popup; an argued line
// never consults the decoration (the claim/detached paths below own it).
if (bare) {
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(session)) {
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
return 'handled'
}
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
if (!bare) return undefined
this.consumeVia(session.sessionId, { via: 'enter', token })
@@ -216,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract {
return 'handled'
}
/** Open the session's popup for one contribution (menu pick / bare enter). */
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
private openPopup(
contribution: CommandContribution,
name: string,
ui: CommandContribution['ui'],
session: ClientSessionContext,
segment: TokenSegment,
): void {
const actx = this.scopeFor(session.sessionId)
if (actx === undefined) return
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
this.popupFor(actx).open(name, ui, session, segment)
}
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */

View File

@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
import type { CommandDescriptor } from '../src/client/directory.ts'
import { CommandService } from '../src/client/service.ts'
@@ -198,18 +198,26 @@ describe('candidates', () => {
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
})
it('a hostBacked contribution cooperates: the host row stands, no duplicate, no throw', async () => {
})
describe('decorations (bare-invocation UI on host commands)', () => {
const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
name: 'goal',
available: () => true,
ui: themeUi(),
...over,
})
it('adds no catalog row: the host row stands alone', async () => {
const { command, source } = await bench()
command.register(themeContribution({ name: 'goal', hostBacked: true }))
command.decorate(goalDecoration())
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
expect(names).toEqual(['plan', 'goal'])
})
})
describe('hostBacked enter/space columns', () => {
it('bare enter opens the popup; an argued line falls through to the host claim', async () => {
it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
const { command, source, mint, warm } = await bench()
command.register(themeContribution({ name: 'goal', hostBacked: true }))
command.decorate(goalDecoration())
const scope = mint('s1')
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
@@ -219,14 +227,38 @@ describe('hostBacked enter/space columns', () => {
expect(argued.claim.token).toBe('/goal ')
})
it('space defers to the host claim instead of the popup', async () => {
it('space never consults the decoration (host claim)', async () => {
const { command, source, warm } = await bench()
command.register(themeContribution({ name: 'goal', hostBacked: true }))
command.decorate(goalDecoration())
await warm(proj('s1'))
const outcome = source.matchSpace!(proj('s1'), '/goal')
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
expect(outcome.claim.token).toBe('/goal ')
})
it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
const { command, source, mint, warm } = await bench()
command.decorate(goalDecoration({ name: 'phantom' }))
const scope = mint('s1')
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
})
it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
const { command, source, warm, executeCalls } = await bench()
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
})
it('duplicate decoration names fail loud', async () => {
const { command } = await bench()
command.decorate(goalDecoration())
expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
})
})
describe('dispatch (menu column)', () => {

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