feat(web): /permission popup picker (hostBacked contribution)

Bare /permission now opens a flat popupSelect of presets (current value
active, custom excluded) instead of returning a text report — the /model
pattern on a single-level list. The new dsh-client-ui-permission package
registers the contribution; a pick submits '/permission <preset>' through
Session.command, so the picker, the composer chip, and the argued line all
write through the one host command and follow the one pushed projection
frame. Options and availability read the 'permissions' projection.

ui-command gains the hostBacked contribution mode: a same-named host
command is cooperation, not a collision — the host keeps the catalog row,
the argument claim (space and argued-enter fall through to the host path),
and the lifecycle logging, while the contribution supplies only the
bare-invocation popup. The /permission command handler keeps its bare-line
text report for host surfaces without a popup layer (TUI, raw execute).
This commit is contained in:
imccyu
2026-07-28 23:38:22 +08:00
parent c0e7c008cf
commit ff06272774
18 changed files with 446 additions and 5 deletions
+4
View File
@@ -359,6 +359,10 @@
- id: ui-model
name: '@deepseek-ai/dsh-client-ui-model'
# The /permission popup picker (hostBacked over the host /permission command).
- id: ui-permission
name: '@deepseek-ai/dsh-client-ui-permission'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'
+1
View File
@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
+3
View File
@@ -50,6 +50,9 @@
{
"path": "../../packages/client/ui-models"
},
{
"path": "../../packages/client/ui-permission"
},
{
"path": "../../packages/client/locale"
},
@@ -30,13 +30,23 @@ 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.
* 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).
*/
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). */
@@ -139,6 +139,9 @@ 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 })
@@ -170,7 +173,10 @@ export class CommandService extends Service implements CommandServiceContract {
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
// 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
const desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined || desc.input === undefined) return undefined
return { claim: this.leadingClaim(desc, session) }
@@ -192,9 +198,13 @@ 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) return undefined
this.openPopup(contribution, session, { via: 'enter', token })
return 'handled'
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
}
await this.directory.ensureReady(session.sessionId, signal)
const desc = this.directory.resolve(session.sessionId, name)
@@ -197,6 +197,36 @@ describe('candidates', () => {
command.register(themeContribution({ name: 'plan' }))
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 () => {
const { command, source } = await bench()
command.register(themeContribution({ name: 'goal', hostBacked: true }))
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 () => {
const { command, source, mint, warm } = await bench()
command.register(themeContribution({ name: 'goal', hostBacked: true }))
const scope = mint('s1')
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
expect(argued.claim.token).toBe('/goal ')
})
it('space defers to the host claim instead of the popup', async () => {
const { command, source, warm } = await bench()
command.register(themeContribution({ name: 'goal', hostBacked: true }))
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 ')
})
})
describe('dispatch (menu column)', () => {
@@ -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 packages/client/ui-permission/README.md
README.md: '0000000000000000000000000000000000000000'
README.zh.md: '0000000000000000000000000000000000000000'
+19
View File
@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-client-ui-permission
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.
The `/client` export surface is the plugin body (`apply`/`inject`).
## Model Experience
Indirectly, through the host `/permission` command the picker submits: a switch appends the whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), which select the sandbox mode and approval policy later tool calls resolve. Picker interaction adds no prompt content.
#### KV Cache effect
No direct invalidation; the knob consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **No keyless snapshot exercises the picker yet** — the popup flow is covered by unit specs over fake faces; the assembled-transcript scenario rides the deferred approval/preset e2e work.
@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-client-ui-permission
[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 存在时可用;无权限组合不显示选择框。
`/client` 导出面为插件本体(`apply`/`inject`)。
## Model Experience
间接影响,经由选择框提交的 host `/permission` 命令:一次切换追加全量值旋钮事件(`permission/preset``sandbox/mode``approval/policy`),决定后续工具调用解析到的沙箱模式与审批策略。选择框交互本身不添加任何提示词内容。
#### KV Cache effect
无直接失效;请求前缀的变化由旋钮消费方自行承担。
## Known Limitations and Deferred Work
- **尚无无密钥快照覆盖选择框** —— popup 流程由基于 fake face 的单元 spec 覆盖;组装态转写场景随延后的审批/预设 e2e 工作一并补齐。
@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-client-ui-permission",
"description": "Permission preset selection: the /permission popupSelect over the permissions projection and the host /permission command",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}
@@ -0,0 +1,72 @@
/**
* 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.
*/
import type { ClientContext, Session } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
/** Required services (cordis fiber inject). */
export const inject = ['command', 'sessions']
/** Read one session's current permissions projection value (undefined = capability absent). */
function selectOf(session: Session | undefined): PermissionSelect | undefined {
return session?.projections.get('permissions') as PermissionSelect | undefined
}
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
function optionsOf(value: PermissionSelect): SelectOption[] {
return value.options
.filter(option => option.value !== 'custom')
.map(option => ({
id: option.value,
label: option.name,
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
}))
}
/**
* Client plugin body: register the /permission popup picker over the
* permissions projection.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const command = ctx.get('command') as CommandServiceContract
const sessions = ctx.sessions
const sessionFor = (session: ClientSessionContext): Session | undefined =>
sessions.binding(session.sessionId)?.session
ctx.effect(() => command.register({
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).
available: session => selectOf(sessionFor(session)) !== undefined,
ui: {
kind: 'popupSelect',
options: (session) => {
const value = selectOf(sessionFor(session))
if (value === undefined) throw new Error('permission presets are not available on this host')
return Promise.resolve(optionsOf(value))
},
onSelect: async (option, session) => {
const live = sessionFor(session)
if (live === undefined) throw new Error('this session is not materialized yet')
const result = await live.command(`/permission ${option.id}`)
if (!result.ok) throw new Error(`permission switch failed: ${result.error.code}: ${result.error.message}`)
if (!result.value.matched) throw new Error('the host offers no /permission command')
},
},
}), 'ui-permission: /permission contribution')
}
@@ -0,0 +1,9 @@
/**
* Permission preset selection plugin, node half. Pure UI plugin: the empty
* apply exists so the plugin appears in the host cordis.yml / Loader; the
* browser half ships via exports["./client"], discovered through the
* package.json dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-permission`.
* @module @deepseek-ai/dsh-client-ui-permission/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-permission'
/** Cordis companion plugin name. */
export const name = 'client-ui-permission-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a single command contribution registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,108 @@
/**
* 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
* 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
* disposal removes the contribution (HMR safety).
*/
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 { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import { apply, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
const SELECT: PermissionSelect = {
options: [
{ value: 'read-only', name: 'read-only', description: 'Reads only.' },
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
async function bench() {
const ctx = new Context()
let contribution: CommandContribution | undefined
ctx.provide('command', {
register(c: CommandContribution) {
contribution = c
return () => { contribution = undefined }
},
})
const values = new Map<SessionId, PermissionSelect>()
const commands: string[] = []
let commandResult: { ok: boolean; matched?: boolean } = { ok: true, matched: true }
const session = (id: SessionId) => ({
projections: { get: (key: string) => (key === 'permissions' ? values.get(id) : undefined) },
command: (line: string) => {
commands.push(line)
return Promise.resolve(commandResult.ok
? { ok: true as const, value: { matched: commandResult.matched ?? true } }
: { ok: false as const, error: { code: 'internal', message: 'boom' } })
},
})
ctx.provide('sessions', {
binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined),
})
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return {
ctx, fiber, values, commands,
setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
contribution: () => contribution,
}
}
describe('ui-permission browser plugin', () => {
it('registers the hostBacked /permission popup contribution', async () => {
const b = await bench()
const c = b.contribution()!
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 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' })
expect(c.available(proj)).toBe(true)
const options = await c.ui.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)
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
})
it('a pick submits the /permission line; rejection and unmatched throw', async () => {
const b = await bench()
const c = b.contribution()!
const proj = { sessionId: sid('s1') }
b.values.set(sid('s1'), SELECT)
await c.ui.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/)
b.setResult({ ok: true, matched: false })
await expect(c.ui.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') }))
.rejects.toThrow(/not materialized/)
})
it('disposal removes the contribution (HMR safety)', async () => {
const b = await bench()
expect(b.contribution()).toBeDefined()
await b.fiber.dispose()
expect(b.contribution()).toBeUndefined()
})
})
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../ui-command"
},
{
"path": "../ui-slash"
},
{
"path": "../../ui/permission"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-permission', ['lib/types/index.js', 'lib/types/invariant.js'])
+24
View File
@@ -161,6 +161,9 @@ importers:
'@deepseek-ai/dsh-client-ui-models':
specifier: workspace:^
version: link:../../packages/client/ui-models
'@deepseek-ai/dsh-client-ui-permission':
specifier: workspace:^
version: link:../../packages/client/ui-permission
'@deepseek-ai/dsh-client-ui-question':
specifier: workspace:^
version: link:../../packages/client/ui-question
@@ -1106,6 +1109,27 @@ importers:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-permission:
devDependencies:
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-ui-command':
specifier: workspace:^
version: link:../ui-command
'@deepseek-ai/dsh-client-ui-slash':
specifier: workspace:^
version: link:../ui-slash
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-permission':
specifier: workspace:^
version: link:../../ui/permission
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/client/ui-primitives:
dependencies:
'@shikijs/langs':
+1
View File
@@ -43,6 +43,7 @@
{ "path": "./packages/client/ui-skill" },
{ "path": "./packages/client/ui-subagent" },
{ "path": "./packages/client/ui-model" },
{ "path": "./packages/client/ui-permission" },
{ "path": "./packages/client/ui-question" },
{ "path": "./packages/client/ui-trajectory" },
{ "path": "./packages/client/ui-theme" },