|
|
@@ -2,10 +2,12 @@
|
|
|
* CommandUiRuntime (`ctx.commandUi`): the '/' command source over the
|
|
|
* session-keyed directory, the client-contribution registry, and the
|
|
|
* per-session popupSelect controllers. Candidate synthesis merges the host
|
|
|
- * catalog with contributions by availability, then position filtering and
|
|
|
- * the `/` menu's shared name ranking (ui-primitives `rankByName`); a
|
|
|
- * host/contribution name collision fails loud. Every execute
|
|
|
- * addresses the session's agent by sessionId — sessions are always
|
|
|
+ * catalog with contributions by availability, gives built-in Host rows their
|
|
|
+ * localized face (presentation.ts), then position-filters; an empty query
|
|
|
+ * lists the Add and Commands sections in usage order, a typed query ranks
|
|
|
+ * every row by the `/` menu's shared name-and-label ranking (ui-primitives
|
|
|
+ * `rankByName`). A host/contribution name collision fails loud. Every
|
|
|
+ * execute addresses the session's agent by sessionId — sessions are always
|
|
|
* agent-backed.
|
|
|
*/
|
|
|
import { Service } from '@deepseek-ai/cordis'
|
|
|
@@ -27,6 +29,7 @@ import type { CommandContribution, CommandDecoration, CommandUiContract } from '
|
|
|
import type { CommandDescriptor } from './directory.ts'
|
|
|
import { CommandDirectory } from './directory.ts'
|
|
|
import { PopupSelectController } from './popup.ts'
|
|
|
+import { builtinRowFace, claimToken, resolveCommandName, sectionRows } from './presentation.ts'
|
|
|
import type { TokenSegment } from './popup.ts'
|
|
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
|
@@ -185,47 +188,58 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */
|
|
|
+ /**
|
|
|
+ * Menu candidates: host catalog + contribution availability, built-in rows
|
|
|
+ * localized, then position filtering; sections for an empty query, the
|
|
|
+ * shared name-and-label ranking for a typed one.
|
|
|
+ */
|
|
|
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]> {
|
|
|
const list = await this.directory.ensureReady(session.sessionId, req.signal)
|
|
|
const rows: InputTriggerCandidate[] = []
|
|
|
const seen = new Set<string>()
|
|
|
for (const c of list) {
|
|
|
seen.add(c.name)
|
|
|
- rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) })
|
|
|
+ rows.push({
|
|
|
+ name: c.name,
|
|
|
+ ...(builtinRowFace(c.name, c.description, this.t) ?? { description: c.description }),
|
|
|
+ ...(c.input !== undefined ? { hint: c.input.hint } : {}),
|
|
|
+ })
|
|
|
}
|
|
|
for (const contribution of this.live.contributions.values()) {
|
|
|
if (!contribution.available(session)) continue
|
|
|
if (seen.has(contribution.name)) {
|
|
|
throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`)
|
|
|
}
|
|
|
- rows.push({ name: contribution.name, description: contribution.description })
|
|
|
+ rows.push({
|
|
|
+ name: contribution.name,
|
|
|
+ ...(contribution.label === undefined ? {} : { label: contribution.label() }),
|
|
|
+ ...(contribution.description === undefined ? {} : { description: contribution.description() }),
|
|
|
+ ...(contribution.icon === undefined ? {} : { icon: contribution.icon }),
|
|
|
+ })
|
|
|
}
|
|
|
- return rankByName(
|
|
|
- rows.filter(c => req.position === 'leading' || c.hint === undefined),
|
|
|
- req.query,
|
|
|
- )
|
|
|
+ const visible = rows.filter(c => req.position === 'leading' || c.hint === undefined)
|
|
|
+ return req.query === '' ? sectionRows(visible, this.t) : rankByName(visible, req.query)
|
|
|
}
|
|
|
|
|
|
- /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
|
|
|
+ /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */
|
|
|
private dispatch(pick: InputTriggerPick): PickOutcome {
|
|
|
const name = pick.candidate.name
|
|
|
const contribution = this.live.contributions.get(name)
|
|
|
if (contribution !== undefined && contribution.available(pick.session)) {
|
|
|
- this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
|
|
|
+ this.invoke(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.
|
|
|
+ // A decoration replaces the HOST row's bare invocation with its popup or
|
|
|
+ // action; 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 })
|
|
|
+ this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
|
|
|
return 'handled'
|
|
|
}
|
|
|
- if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
|
|
|
+ if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc.name, desc.description, this.t)) }
|
|
|
// Menu-pick execute consumes the trigger span before the detached run
|
|
|
// (scoped event; the input owns the CAS guard).
|
|
|
this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
|
|
|
@@ -236,11 +250,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
|
|
/** Decision table, space column: hot-key sync check; only host leadingInput claims. */
|
|
|
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
|
|
|
+ const name = resolveCommandName(token.slice(1))
|
|
|
+ if (this.live.contributions.has(name)) return undefined // popup and action 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) }
|
|
|
+ return { claim: this.leadingClaim(desc, session, token.slice(1)) }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -250,10 +264,14 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
|
|
* args-tolerant.
|
|
|
*
|
|
|
* Envelope policy: an enter submission carrying attachments resolves only
|
|
|
- * through a command declaring attachment acceptance. Every other command route —
|
|
|
- * popup, non-accepting claim, bare detached execute — throws the refusal
|
|
|
- * so the machine surfaces one composer notice and the draft and attachments
|
|
|
- * stay in place; nothing executes and nothing is dropped.
|
|
|
+ * through a command declaring attachment acceptance. Every other submitting
|
|
|
+ * route — popup, non-accepting claim, bare detached execute — throws the
|
|
|
+ * refusal so the machine surfaces one composer notice and the draft and
|
|
|
+ * attachments stay in place; nothing executes and nothing is dropped. An
|
|
|
+ * action submits nothing and runs regardless.
|
|
|
+ *
|
|
|
+ * A typed token is resolved through the localized claim tokens, so a line
|
|
|
+ * written as `/计划` reaches the `plan` descriptor and executes as `/plan`.
|
|
|
*/
|
|
|
private async matchEnter(
|
|
|
session: ClientSessionContext,
|
|
|
@@ -266,16 +284,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
|
|
const ws = trimmed.search(/\s/)
|
|
|
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
|
|
|
const bare = ws === -1
|
|
|
- const name = token.slice(1)
|
|
|
+ const name = resolveCommandName(token.slice(1))
|
|
|
if (name === '') return undefined
|
|
|
+ // The Host line always carries the catalog name.
|
|
|
+ const canonical = `/${name}${trimmed.slice(token.length)}`
|
|
|
const refuseAttachments = (): never => {
|
|
|
throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
|
|
|
}
|
|
|
const contribution = this.live.contributions.get(name)
|
|
|
if (contribution !== undefined && contribution.available(session)) {
|
|
|
if (!bare) return undefined
|
|
|
- if (envelope.attachments > 0) refuseAttachments()
|
|
|
- this.openPopup(name, contribution.ui, session, { via: 'enter', token })
|
|
|
+ if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments()
|
|
|
+ this.invoke(name, contribution.ui, session, { via: 'enter', token })
|
|
|
return 'handled'
|
|
|
}
|
|
|
await this.directory.ensureReady(session.sessionId, signal)
|
|
|
@@ -286,42 +306,57 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
|
|
if (bare) {
|
|
|
const decoration = this.live.decorations.get(name)
|
|
|
if (decoration !== undefined && decoration.available(session)) {
|
|
|
- if (envelope.attachments > 0) refuseAttachments()
|
|
|
- this.openPopup(name, decoration.ui, session, { via: 'enter', token })
|
|
|
+ if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments()
|
|
|
+ this.invoke(name, decoration.ui, session, { via: 'enter', token })
|
|
|
return 'handled'
|
|
|
}
|
|
|
}
|
|
|
if (desc.input !== undefined) {
|
|
|
if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments()
|
|
|
- return { claim: this.leadingClaim(desc, session) }
|
|
|
+ return { claim: this.leadingClaim(desc, session, token.slice(1)) }
|
|
|
}
|
|
|
if (!bare) return undefined
|
|
|
if (envelope.attachments > 0) refuseAttachments()
|
|
|
this.consumeVia(session.sessionId, { via: 'enter', token })
|
|
|
- this.runDetached(desc, session, trimmed)
|
|
|
+ this.runDetached(desc, session, canonical)
|
|
|
return 'handled'
|
|
|
}
|
|
|
|
|
|
- /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
|
|
|
- private openPopup(
|
|
|
+ /**
|
|
|
+ * Invoke one contribution or decoration (menu pick / bare enter): open the
|
|
|
+ * session's popup, or consume the token and run the action.
|
|
|
+ */
|
|
|
+ private invoke(
|
|
|
name: string,
|
|
|
ui: CommandContribution['ui'],
|
|
|
session: ClientSessionContext,
|
|
|
segment: TokenSegment,
|
|
|
): void {
|
|
|
+ if (ui.kind === 'action') {
|
|
|
+ this.consumeVia(session.sessionId, segment)
|
|
|
+ ui.run(session)
|
|
|
+ return
|
|
|
+ }
|
|
|
const actx = this.scopeFor(session.sessionId)
|
|
|
if (actx === undefined) return
|
|
|
this.popupFor(actx).open(name, ui, session, segment)
|
|
|
}
|
|
|
|
|
|
- /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
|
|
|
- private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
|
|
|
- const token = `/${desc.name} `
|
|
|
+ /**
|
|
|
+ * Build the leadingInput claim. The composer keeps the claimed token in
|
|
|
+ * the draft and reads the arguments after it, so the token is the spelling
|
|
|
+ * the draft will carry: the locale's token for a menu pick, the typed
|
|
|
+ * spelling for Space and Enter. The command.execute submit transaction
|
|
|
+ * always sends the catalog name.
|
|
|
+ */
|
|
|
+ private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext, shown: string): CommandClaim {
|
|
|
+ const token = `/${shown} `
|
|
|
+ const line = `/${desc.name} `
|
|
|
return {
|
|
|
token,
|
|
|
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
|
|
|
...(desc.input?.attachments === true ? { attachments: true } : {}),
|
|
|
- submit: (args, _actx, attachments) => this.execute(session, token + args, attachments),
|
|
|
+ submit: (args, _actx, attachments) => this.execute(session, line + args, attachments),
|
|
|
}
|
|
|
}
|
|
|
|