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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user