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:
@@ -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
|
||||
}
|
||||
@@ -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' {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user