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

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

View File

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

View File

@@ -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)', () => {