feat: slash system / input service / agent scope

This commit is contained in:
imccyu
2026-07-27 03:17:52 +08:00
parent f3a4833dbf
commit a27be43ac1
210 changed files with 15969 additions and 2213 deletions

View File

@@ -0,0 +1,98 @@
/* Official popupSelect shell card: menu-surface tokens (same family as
* ui-primitives Menu.module.css — figma MenuDropdown r12 / hairline /
* shadow-lv3), anchored by the conversation.input.overlay slot. */
.card {
/* The overlay anchor is a zero-height strip on the composer card's top
edge; entries float themselves above it (same rule as MenuView). */
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 100;
padding: 4px;
display: flex;
flex-direction: column;
min-width: 220px;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
outline: none;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--dsw-alias-text-primary);
}
.rowActive {
background: var(--dsw-alias-fill-hover);
}
.label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail {
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
white-space: nowrap;
}
.check {
display: inline-flex;
color: var(--dsw-alias-text-secondary);
}
.status {
padding: 8px;
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
}
.search {
margin: 2px 2px 4px;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 8px;
background: transparent;
font-size: 13px;
color: var(--dsw-alias-text-primary);
outline: none;
}
.error {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
font-size: 12px;
color: var(--dsw-alias-state-error-primary);
}
.errorText {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.retry {
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 6px;
background: transparent;
font-size: 12px;
color: var(--dsw-alias-text-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,133 @@
/**
* Official popupSelect shell: renders one session's PopupSelectController
* store into the conversation.input.overlay anchor. Unlike the slash menu
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
* inner search input takes focus, plain typing filters the loaded options
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
* the composer, and ←→ keep the search input's native caret. Any pointer
* interaction outside the box dismisses (the click's own target takes
* focus). Closed state renders null; the overlay slot stays mounted.
*/
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
/** Injected business face of the popupSelect overlay entry. */
export interface PopupSelectInjected {
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
popup: PopupSelectController
}
/**
* Render the popupSelect shell overlay entry.
* @param props - injected face: the session's shell controller.
* @returns the select card while open; null while closed.
*/
export function PopupSelectView({ popup }: PopupSelectInjected) {
const state = useSyncExternalStore(
fn => popup.state.subscribe(fn),
() => popup.state.getSnapshot(),
)
const cardRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// Focus ownership: the search input grabs on open (the design's
// transient-layer rule), and ANY outside pointer interaction dismisses —
// capture phase so a click landing anywhere else (textarea included)
// closes the shell before its own handlers run; that click's target then
// takes focus naturally, so no focusComposer here.
useEffect(() => {
if (!state.open) return
searchRef.current?.focus()
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, popup])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
// its native caret movement.
switch (ev.key) {
case 'ArrowDown':
ev.preventDefault()
popup.move(1)
return
case 'ArrowUp':
ev.preventDefault()
popup.move(-1)
return
case 'Enter':
ev.preventDefault()
void popup.select(state.active)
return
case 'Escape':
ev.preventDefault()
popup.dismiss({ focusComposer: true })
return
default:
}
}
return (
<div
ref={cardRef}
className={css.card}
aria-label={`/${String(state.command)} options`}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder="Search…"
aria-label="Filter options"
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>Loading options</div>}
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
/**
* Frozen contract of the client command surface. Types only. The
* CommandService (`ctx.command`) implements this face; business packages
* consume `register` alone.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
/** One option row of a popupSelect shell. */
export interface SelectOption {
readonly id: string
readonly label: string
readonly detail?: string
readonly active?: boolean
}
/**
* Business registration for the popupSelect command kind. Data is
* self-served: options/onSelect use the business package's own protocol.
* The shell component is owned by ui-command; business never sees it. Both
* callbacks receive the ClientSessionContext captured at popup open.
*/
export type CommandUiSpec = {
readonly kind: 'popupSelect'
options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
}
/**
* 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.
*/
export interface CommandContribution {
/** Command name without the leading slash (unique across contributions). */
readonly name: string
/** Menu row description. */
readonly description: string
/** 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
}
/** The `ctx.command` service face visible to business packages. */
export interface CommandServiceContract {
/**
* Register one client command contribution; effect disposer. Duplicate
* names throw at registration.
*/
register(contribution: CommandContribution): () => void
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
popupFor(actx: ClientContext): unknown
}

View File

@@ -0,0 +1,175 @@
/**
* Command-directory cache keyed by session: one entry per served catalog —
* every session is agent-backed, so `command.list({sessionId})` is the only
* address shape. Each entry keeps the single-flight / soft-hard invalidation
* / epoch-guard behavior of the original global cache; the session-key axis
* is the only extra dimension.
*/
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** command.list success value, derived so the wire type authority stays in apiproxy. */
type ListValue = Extract<Awaited<ReturnType<IApiClient['commands']['list']>>['result'], { ok: true }>['value']
/** One host command descriptor as served to the client. */
export type CommandDescriptor = ListValue['commands'][number]
/**
* cold = never pulled; pending = pull in flight with nothing servable;
* ready = snapshot serving (a soft-invalidate repull keeps this status);
* failed = last winning pull rejected, snapshot dropped.
*/
export type DirectoryStatus = 'cold' | 'pending' | 'ready' | 'failed'
/** Injected pull (the service binds command.list off the root connection). */
export type FetchCommands = (sessionId: SessionId) => Promise<readonly CommandDescriptor[]>
/** One session key's cache cell. */
class Entry {
state: DirectoryStatus = 'cold'
commands: readonly CommandDescriptor[] = []
/** Bumped at each pull start; only the latest pull may publish its outcome. */
epoch = 0
lastError: unknown
waiters: Array<() => void> = []
}
/** The session-keyed directory cache. Plain class — the owning service wires events and RPC. */
export class CommandDirectory {
private readonly entries = new Map<SessionId, Entry>()
constructor(private readonly fetchCommands: FetchCommands) {}
/**
* Current cache status for one session.
* @param sessionId - session key.
* @returns the entry status (cold when never touched).
*/
status(sessionId: SessionId): DirectoryStatus {
return this.entries.get(sessionId)?.state ?? 'cold'
}
/**
* Synchronous exact-name lookup over one session's hot snapshot.
* @param sessionId - session key.
* @param name - command name without the leading slash.
* @returns the descriptor, or undefined when absent or the entry is not ready.
*/
resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined {
const entry = this.entries.get(sessionId)
if (entry === undefined || entry.state !== 'ready') return undefined
return entry.commands.find(c => c.name === name)
}
/** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */
invalidateAll(): void {
for (const key of this.entries.keys()) void this.refresh(key)
}
/**
* Hard reset on reconnect: every entry drops its snapshot (the agent world
* may have changed shape across the generation) and prewarms.
*/
resetConnected(): void {
for (const [key, entry] of this.entries) {
entry.state = 'cold'
entry.commands = []
void this.refresh(key)
}
}
/**
* Fire-and-forget prewarm of one session (the command source's scope-birth
* warm hook lands here).
* @param sessionId - session key.
*/
warm(sessionId: SessionId): void {
const entry = this.entry(sessionId)
if (entry.state === 'cold' || entry.state === 'failed') void this.refresh(sessionId)
}
/**
* Start one pull for one session. Publishes ready/failed only while it is
* still the key's latest pull (epoch guard); a ready snapshot is not
* demoted while the pull flies.
* @param sessionId - session key.
* @returns settled when this pull's outcome is published or discarded.
*/
async refresh(sessionId: SessionId): Promise<void> {
const entry = this.entry(sessionId)
const epoch = ++entry.epoch
if (entry.state !== 'ready') entry.state = 'pending'
try {
const commands = await this.fetchCommands(sessionId)
if (epoch !== entry.epoch) return
entry.commands = commands
entry.state = 'ready'
entry.lastError = undefined
} catch (error) {
if (epoch !== entry.epoch) return
entry.commands = []
entry.state = 'failed'
entry.lastError = error
} finally {
if (epoch === entry.epoch) notifyWaiters(entry)
}
}
/**
* Strong-wait until one session's catalog is servable (the enter-
* adjudication "directory must be reached" rule): ready returns at once;
* cold/failed launch a fresh pull; pending joins the flying one. Rejects
* when the awaited pull fails or the signal aborts.
* @param sessionId - session key.
* @param signal - attempt-scoped abort (the SubmitAttempt signal).
* @returns the hot command snapshot.
*/
async ensureReady(sessionId: SessionId, signal: AbortSignal): Promise<readonly CommandDescriptor[]> {
const entry = this.entry(sessionId)
while (true) {
if (entry.state === 'ready') return entry.commands
if (entry.state !== 'pending') void this.refresh(sessionId)
await settled(entry, signal)
if (entry.state === 'failed') {
throw new Error(`command directory warmup failed: ${entry.lastError instanceof Error ? entry.lastError.message : String(entry.lastError)}`)
}
// Still pending (the awaited pull was superseded) → wait for the winner.
}
}
private entry(sessionId: SessionId): Entry {
let entry = this.entries.get(sessionId)
if (entry === undefined) {
entry = new Entry()
this.entries.set(sessionId, entry)
}
return entry
}
}
/** One settlement tick for one entry: resolves at the next winning publish, rejects on abort. */
function settled(entry: Entry, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise((resolve, reject) => {
const waiter = (): void => {
signal.removeEventListener('abort', onAbort)
resolve()
}
const onAbort = (): void => {
entry.waiters = entry.waiters.filter(w => w !== waiter)
reject(abortReason(signal))
}
signal.addEventListener('abort', onAbort, { once: true })
entry.waiters.push(waiter)
})
}
function notifyWaiters(entry: Entry): void {
const woken = entry.waiters
entry.waiters = []
for (const wake of woken) wake()
}
/** Normalize an abort into an Error rejection. */
function abortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new Error('command directory wait aborted')
}

View File

@@ -0,0 +1,61 @@
/**
* Command UI plugin, browser half: CommandService (`ctx.command`) owning the
* capability-keyed directory cache, the '/' command source, the client
* contribution registry, and the per-session popupSelect controllers; the
* popupSelect shell self-registers into conversation.input.overlay with
* per-session resolution.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the
// key's owner) into this program so the overlay registration below typechecks
// against the real declaration — no runtime edge to ui-conversation.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CommandService } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
export { CommandService } from './service.ts'
export { CommandDirectory } from './directory.ts'
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
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,
} from './contract.ts'
declare module 'cordis' {
interface Context {
command: CommandService
}
}
/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
export const inject = ['slash', 'sessions', 'connection']
/**
* Client plugin body: mount the service, then register the popupSelect shell
* into the input overlay once its declarer is up.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.plugin(CommandService)
// Conditional mount, same seam as ui-slash's MenuView registration:
// 'conversation.input.overlay' is declared by the conversation composer
// entry, and the conversation service's presence is the registration-safe
// signal that the declaration is on the ledger.
ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => {
const command = scope.command
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
inject: (sessionId): PopupSelectInjected => {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
return { popup: command.popupFor(actx) }
},
}, PopupSelectView), 'ui-command: popupSelect overlay registration')
})
}

View File

@@ -0,0 +1,251 @@
/**
* Headless popupSelect shell state (design §10): one controller per client
* session, owned by CommandService's per-session map and torn down by the
* session scope disposer. The shell is a transient layer (never in the input
* state machine): it loads options once, filters them locally against the
* shell's own search text, and settles a selection through the context
* captured at open time. Draft consumption and composer focus are injected
* callbacks — the session wiring dispatches the consume-token event (the
* Input side owns the span/bare-token CAS guard) and focuses the composer;
* the controller never touches the input machine.
*/
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { SelectOption } from './contract.ts'
/**
* The command token segment snapshotted at shell-open time, replayed to the
* injected {@link PopupSelectDeps.consume} callback after a successful
* selection. The Input side guards it: a menu-path span consumes iff draftRev
* is unchanged, an enter-path line iff the trimmed draft still equals the
* bare token.
*/
export type TokenSegment =
| { readonly via: 'menu'; readonly span: TokenSpan }
| { readonly via: 'enter'; readonly token: string }
/**
* Structural business spec the shell settles against — the popupSelect half
* of CommandUiSpec, generic in the context value the opener captures (the
* session wiring passes its session projection; the controller only carries
* it from open() to the callbacks).
*/
export interface PopupSpec<TCtx> {
/** Load the option rows once per open (retry after failure reuses the same signal). */
options(context: TCtx, signal: AbortSignal): Promise<readonly SelectOption[]>
/** Settle the picked option against the open-time context. */
onSelect(option: SelectOption, context: TCtx): void | Promise<void>
}
/** Injected session-wiring callbacks of one controller (tests pass fakes). */
export interface PopupSelectDeps {
/**
* Consume the open-time token segment after a successful onSelect (the
* wiring dispatches the consume-token event to the opening session).
* @param segment - the open-time token segment snapshot.
* @returns whether the token was consumed; false (CAS miss) is benign and
* never retried.
*/
consume(segment: TokenSegment): boolean
/** Return focus to the session composer (successful settle and Escape close paths). */
focusComposer(): void
}
/** Popup shell state (the shell component renders from here; closed = render null). */
export interface PopupState {
readonly open: boolean
/** Command name the shell is open for (null while closed). */
readonly command: string | null
/** Options-load lifecycle; 'failed' keeps the shell open for retry(). */
readonly status: 'pending' | 'ready' | 'failed'
/** Options as loaded — never re-fetched per keystroke; views render {@link filterOptions} over them. */
readonly options: readonly SelectOption[]
/** Local filter text over the loaded options. */
readonly search: string
/** Highlight index into the filtered row list (0 when empty/pending). */
readonly active: number
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
readonly submitting: boolean
/** Surfaced settlement failure (options load or onSelect); null when none. */
readonly error: string | null
}
const CLOSED: PopupState = {
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
}
/**
* Filter option rows against the shell's local search text (case-insensitive
* substring over label and detail; blank search keeps every row).
* @param options - the loaded rows.
* @param search - the shell's search text.
* @returns the rows the shell shows and highlights over.
*/
export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] {
const query = search.trim().toLowerCase()
if (query === '') return options
return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false))
}
/** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */
interface OpenBinding<TCtx> {
readonly command: string
readonly spec: PopupSpec<TCtx>
readonly context: TCtx
readonly segment: TokenSegment
readonly abort: AbortController
}
/** The shell's error-strip line for a settlement failure. */
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Headless controller of one session's popupSelect shell. Late settlements
* lose their write rights through binding identity: dismiss/dispose/reopen
* swap the binding, so a settling options fetch or onSelect that no longer
* matches writes nothing and consumes nothing.
*/
export class PopupSelectController<TCtx = unknown> {
/** Shell state store (the overlay component subscribes here). */
readonly state: SnapshotStore<PopupState> = createSnapshotStore<PopupState>(CLOSED)
private binding: OpenBinding<TCtx> | null = null
/**
* @param deps - session-wiring callbacks (token consumption + composer focus).
*/
constructor(private readonly deps: PopupSelectDeps) {}
/**
* Open the shell for one command: publish pending state and fetch options
* once through the business spec. A reopen supersedes the previous shell
* (its options fetch is aborted, its late settlements are dropped).
* @param command - command name the shell serves.
* @param spec - the registered popupSelect spec.
* @param context - open-time context snapshot, handed verbatim to options/onSelect.
* @param segment - open-time token segment snapshot for post-select consumption.
*/
open(command: string, spec: PopupSpec<TCtx>, context: TCtx, segment: TokenSegment): void {
this.binding?.abort.abort()
const binding: OpenBinding<TCtx> = { command, spec, context, segment, abort: new AbortController() }
this.binding = binding
this.state.set({ ...CLOSED, open: true, command })
this.load(binding)
}
/** Run the one options fetch of a binding; settlement rights die with the binding. */
private load(binding: OpenBinding<TCtx>): void {
binding.spec.options(binding.context, binding.abort.signal).then(
(options) => {
if (this.binding !== binding) return
this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null })
},
(error: unknown) => {
if (this.binding !== binding) return
console.error(`[ui-command] popupSelect options failed for /${binding.command}:`, error)
this.state.set({ ...this.state.getSnapshot(), status: 'failed', options: [], active: 0, error: errorText(error) })
},
)
}
/** Re-run a failed options fetch (search survives; no-op unless status is 'failed'). */
retry(): void {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.status !== 'failed') return
this.state.set({ ...s, status: 'pending', error: null })
this.load(binding)
}
/**
* Replace the local search text (pure local filter — the provider is never
* re-queried) and rebase the highlight onto the new filtered list.
* @param search - the shell search input's text.
*/
setSearch(search: string): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || search === s.search) return
this.state.set({ ...s, search, active: 0 })
}
/**
* Move the highlight across the filtered rows (wraps around; no-op unless
* options are ready and no selection is in flight).
* @param dir - +1 down, -1 up.
*/
move(dir: 1 | -1): void {
const s = this.state.getSnapshot()
if (!s.open || s.status !== 'ready' || s.submitting) return
const rows = filterOptions(s.options, s.search)
if (rows.length === 0) return
const active = (s.active + dir + rows.length) % rows.length
this.state.set({ ...s, active })
}
/**
* Set the highlight directly (pointer hover; no-op unless ready, idle, and
* in filtered range).
* @param index - filtered-row index.
*/
highlight(index: number): void {
const s = this.state.getSnapshot()
if (!s.open || s.status !== 'ready' || s.submitting) return
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
this.state.set({ ...s, active: index })
}
/**
* Select one filtered row: single-flight — the first call enters
* `submitting` and later calls no-op until it settles. Success consumes the
* open-time token segment (a false CAS answer is benign), closes, and
* returns focus to the composer. Failure keeps the shell open with search,
* highlight, and token intact, surfaces the error, and re-arms select as
* the retry.
* @param index - filtered-row index (callers pass the highlight or the clicked row).
* @returns settled when the attempt has closed the shell or surfaced its failure.
*/
async select(index: number): Promise<void> {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
const option = filterOptions(s.options, s.search)[index]
if (option === undefined) return
this.state.set({ ...s, submitting: true, error: null })
try {
await binding.spec.onSelect(option, binding.context)
} catch (error) {
console.error(`[ui-command] popupSelect onSelect failed for /${binding.command}:`, error)
if (this.binding !== binding) return // dismissed/reopened/disposed while onSelect flew
this.state.set({ ...this.state.getSnapshot(), submitting: false, error: errorText(error) })
return
}
if (this.binding !== binding) return // late success: no state write, no consumption
this.deps.consume(binding.segment)
this.binding = null
this.state.set(CLOSED)
this.deps.focusComposer()
}
/**
* Close the shell; aborts a flying options fetch and revokes settlement
* rights. An outside pointer interaction dismisses plainly (the click's own
* target takes focus); Escape passes focusComposer to return focus explicitly.
* @param opts - focusComposer: also restore composer focus (Escape path).
*/
dismiss(opts?: { readonly focusComposer?: boolean }): void {
if (this.binding === null) return
this.binding.abort.abort()
this.binding = null
this.state.set(CLOSED)
if (opts?.focusComposer === true) this.deps.focusComposer()
}
/** Scope-teardown disposer: abort in-flight work and clear state (no focus side effect). */
dispose(): void {
this.binding?.abort.abort()
this.binding = null
this.state.set(CLOSED)
}
}

View File

@@ -0,0 +1,293 @@
/**
* CommandService (`ctx.command`): 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 query/position filtering;
* a host/contribution name collision fails loud. Every execute addresses the
* session's agent by sessionId — sessions are always agent-backed.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the notice route reads ctx.conversation.input — no runtime edge.
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SlashServiceContract, SubmitOutcome,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandServiceContract } from './contract.ts'
import type { CommandDescriptor } from './directory.ts'
import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
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 popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
}
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
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() }
/**
* @param ctx - owning root context (plugin fiber; the service registers
* itself as `command` and follows that fiber's lifetime).
*/
constructor(ctx: Context) {
super(ctx, 'command')
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
this.directory = new CommandDirectory(async (sessionId) => {
const { result } = await connection.api.commands.list({ sessionId })
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value.commands
})
const slash = ctx.get('slash') as SlashServiceContract | undefined
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
ctx.effect(() => slash.registerSource({
trigger: '/',
name: 'command',
candidates: (session, req) => this.candidates(session, req),
onPick: pick => this.dispatch(pick),
matchSpace: (session, token) => this.matchSpace(session, token),
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
warm: (session) => { this.directory.warm(session.sessionId) },
}), 'command: slash source')
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
ctx.on('connection/reset', () => { this.directory.resetConnected() })
}
/**
* Register one client command contribution; effect disposer (rides the
* caller's fiber). Duplicate names throw.
* @param contribution - the contribution (descriptor + availability + popup spec).
* @returns the disposer removing the registration.
*/
register(contribution: CommandContribution): () => void {
return this.ctx.effect(() => {
const { contributions } = this.live
if (contributions.has(contribution.name)) {
throw new Error(`ui-command: duplicate contribution for /${contribution.name}`)
}
contributions.set(contribution.name, contribution)
return () => { contributions.delete(contribution.name) }
}, 'command.register()')
}
/**
* Resolve the per-session popup controller (lazy; dies with the session
* scope). The controller's consume callback dispatches the scoped
* consume-token event back to this session; focusComposer reaches the
* composer through the overlay slot currency.
* @param actx - session-scope ctx.
* @returns the resident controller.
*/
popupFor(actx: ClientContext): PopupSelectController<ClientSessionContext> {
const sessions = this.sessions()
const id = sessions.scopeOf(actx)
if (id === undefined) throw new Error('command.popupFor requires a session scope')
const { popups } = this.live
const existing = popups.get(id)
if (existing !== undefined) return existing
const controller = new PopupSelectController<ClientSessionContext>({
consume: segment => actx.bail(actx, 'slash/input-consume-token', {
guard: segment.via === 'menu'
? { kind: 'span', span: segment.span }
: { kind: 'bare-token', token: segment.token },
}) === true,
focusComposer: () => { this.focusHooks.get(id)?.() },
})
popups.set(id, controller)
actx.effect(() => () => {
controller.dispose()
popups.delete(id)
this.focusHooks.delete(id)
}, 'command: session popup')
return controller
}
/** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
private readonly focusHooks = new Map<SessionId, () => void>()
/**
* Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
* @param id - session id.
* @param focus - textarea focus callback.
* @returns the unbind disposer.
*/
bindComposerFocus(id: SessionId, focus: () => void): () => void {
this.focusHooks.set(id, focus)
return () => {
if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id)
}
}
/** Menu candidates: host catalog + contribution availability, then query/position filtering. */
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
const list = await this.directory.ensureReady(session.sessionId, req.signal)
const rows: SlashCandidate[] = []
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 } : {}) })
}
for (const contribution of this.live.contributions.values()) {
if (!contribution.available(session)) continue
if (seen.has(contribution.name)) {
throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`)
}
rows.push({ name: contribution.name, description: contribution.description })
}
return rows
.filter(c => c.name.startsWith(req.query))
.filter(c => req.position === 'leading' || c.hint === undefined)
}
/** Decision table, menu column: contribution → 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 })
return 'handled'
}
const desc = this.directory.resolve(pick.session.sessionId, name)
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
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).
this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
this.runDetached(desc, pick.session, `/${name}`)
return 'handled'
}
/** 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 desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined || desc.input === undefined) return undefined
return { claim: this.leadingClaim(desc, session) }
}
/**
* Decision table, enter column. Strong-waits the session's catalog (a
* warmup failure rejects — never a silent downgrade). Contributions and
* bare host commands act on the bare token only; leadingInput claims
* args-tolerant.
*/
private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise<PickOutcome> {
const trimmed = line.trim()
if (!trimmed.startsWith('/')) return undefined
const ws = trimmed.search(/\s/)
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
const bare = ws === -1
const name = token.slice(1)
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'
}
await this.directory.ensureReady(session.sessionId, signal)
const desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined) return undefined
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
if (!bare) return undefined
this.consumeVia(session.sessionId, { via: 'enter', token })
this.runDetached(desc, session, trimmed)
return 'handled'
}
/** Open the session's popup for one contribution (menu pick / bare enter). */
private openPopup(
contribution: CommandContribution,
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)
}
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
const token = `/${desc.name} `
return {
token,
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
submit: (args, _actx) => this.execute(session, token + args),
}
}
/** The command.execute transaction, addressed to the session's agent. */
private async execute(
session: ClientSessionContext,
line: string,
): Promise<SubmitOutcome> {
const connection = this.ctx.get('connection') as ConnectionHandle
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
const detached = result.value.result
return detached === undefined
? { kind: 'success' }
: { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
}
/**
* Fire-and-forget execute for the internal ('handled') paths. The detached
* result surfaces as a notice routed to the triggering session's composer,
* so a late result lands on its own session after a switch.
*/
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
void this.execute(session, line).then(
(outcome) => {
if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`)
else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text)
},
(error: unknown) => {
this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error))
},
)
}
/** Dispatch a consume-token event to one session (menu-pick / bare-enter execute paths). */
private consumeVia(id: SessionId, segment: TokenSegment): void {
const actx = this.scopeFor(id)
if (actx === undefined) return
actx.bail(actx, 'slash/input-consume-token', {
guard: segment.via === 'menu'
? { kind: 'span', span: segment.span }
: { kind: 'bare-token', token: segment.token },
})
}
/** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
const actx = this.scopeFor(id)
if (actx === undefined) return
const conversation = actx.get('conversation') as ConversationService | undefined
if (conversation === undefined) return
conversation.input.for(actx).notify(level, text)
}
/** id → actx interchange (registered exchange point: this service coordinates for projection-only sources). */
private scopeFor(id: SessionId): ClientContext | undefined {
return this.sessions().scope(id)
}
private sessions(): SessionsService {
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
return sessions
}
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,10 @@
/**
* Command UI 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. The host command registry itself mounts separately
* (bootHost + CommandService).
*/
/** Host plugin body — no host-side behavior for the command UI plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-command`.
* @module @deepseek-ai/dsh-client-ui-command/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-command'
/** Cordis companion plugin name. */
export const name = 'client-ui-command-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a browser-side source over the wire command
* directory — it emits no cordis events and owns no cross-plugin mutable
* state; dispatch and cache behavior are asserted by this package's specs.
*/
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 */