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

View File

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

View File

@@ -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 {}

View File

@@ -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 */