refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,118 @@
/* 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: min(220px, 100%);
/* Never wider than the composer card (the overlay anchor's width): long
rows truncate instead of pushing the card past the composer's edge. */
max-width: 100%;
/* Height cap: the 320px design maximum, clamped at runtime to the space
* above the composer (inline max-height set in PopupSelectView.tsx). */
max-height: 320px;
overflow: hidden;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
outline: none;
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--dsw-alias-label-primary);
}
.rowActive {
background: var(--dsw-alias-interactive-bg-hover);
}
.label {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail {
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.check {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-primary);
}
.status {
padding: 8px 10px;
font-size: 13px;
color: var(--dsw-alias-label-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-label-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-label-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,176 @@
/**
* 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 (scrolled into view), 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. The card height clamps to the space above the composer.
*/
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
/** Design cap on the card height (same MenuDropdown family as the slash menu). */
const MAX_HEIGHT = 320
/** 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
}
/** Full shell props: injected face + the locale seat. */
export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'>
/**
* Render the popupSelect shell overlay entry.
* @param props - injected face: the session's shell controller; `t` rides the standard locale seat.
* @returns the select card while open; null while closed.
*/
export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
const state = useSyncExternalStore(
fn => popup.state.subscribe(fn),
() => popup.state.getSnapshot(),
)
const cardRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// The card is bottom-anchored above the composer; clamp the design cap to
// the space above it, re-measured on every store update.
const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state)
const active = state.open ? state.active : null
// The search input keeps focus while arrows move a virtual highlight, so
// the browser never scrolls the active row into view — do it here.
useEffect(() => {
if (active === null) return
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active])
// Focus ownership: the search input grabs on open, 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 || state.confirming !== null) return
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, state.confirming, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open && state.confirming === null) searchRef.current?.focus()
}, [state.open, state.confirming])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
const confirmation = state.confirming?.confirmation
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 (
<>
{state.confirming === null && (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
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() }}>{t('retry')}</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{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>
)}
{confirmation !== undefined && (
<RiskConfirmation
open
title={confirmation.title}
description={confirmation.description}
acknowledgeLabel={confirmation.acknowledgeLabel}
cancelLabel={confirmation.cancelLabel}
confirmLabel={confirmation.confirmLabel}
acknowledged={state.acknowledged}
onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
onCancel={() => { popup.cancelConfirmation() }}
onConfirm={() => { void popup.confirm() }}
/>
)}
</>
)
}

View File

@@ -0,0 +1,89 @@
/**
* Frozen contract of the client command surface. Types only. The
* CommandUiRuntime (`ctx.commandUi`) 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-input-trigger/client'
/** Copy for an option that must be acknowledged before onSelect can run. */
export interface SelectConfirmation {
readonly title: string
readonly description: string
readonly acknowledgeLabel: string
readonly cancelLabel: string
readonly confirmLabel: string
}
/** One option row of a popupSelect shell. */
export interface SelectOption {
readonly id: string
readonly label: string
readonly detail?: string
readonly active?: boolean
/** Optional in-page risk gate owned by the shared popup shell. */
readonly confirmation?: SelectConfirmation
}
/**
* 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-commands; 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
}
/**
* 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.commandUi` service face visible to business packages. */
export interface CommandUiContract {
/**
* Register one client command contribution; effect disposer. Duplicate
* 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
}

View File

@@ -0,0 +1,172 @@
/**
* Command-directory cache keyed by session: one entry per served catalog —
* every session is agent-backed, so `command.list({sessionId})` is the only
* request fields. 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 { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
/**
* 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,73 @@
/**
* Command UI plugin, browser half: CommandUiRuntime (`ctx.commandUi`) 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'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CommandUiRuntime } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
import { en, zh, type CommandKey } from './locales.ts'
export { CommandUiRuntime } 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, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption,
} from './contract.ts'
export type { CommandKey } from './locales.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
commandUi: CommandUiRuntime
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The popupSelect shell's copy. */
command: CommandKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'command'
/** Required services: the '/' source registry, session scopes, commands Remote, and locale registry. */
export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale']
/**
* 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.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries')
ctx.plugin(CommandUiRuntime)
ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => {
const command = scope.commandUi
const sessions = scope.sessions
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
locale: NS,
inject: (sessionId): PopupSelectInjected => {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-commands: session "${String(sessionId)}" resolved no scope`)
return { popup: command.popupFor(actx) }
},
}, PopupSelectView))
})
}

View File

@@ -0,0 +1,26 @@
/** `command` namespace dictionaries (the popupSelect shell's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'search.placeholder': '搜索…',
'search.aria': '筛选选项',
'status.loading': '正在加载选项…',
'status.applying': '正在应用…',
'status.empty': '无选项',
'overlay.aria': '/{command} 选项',
'listbox.aria': '/{command} 匹配项',
} satisfies Record<string, string>
/** The command namespace key union. */
export type CommandKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'search.placeholder': 'Search…',
'search.aria': 'Filter options',
'status.loading': 'Loading options…',
'status.applying': 'Applying…',
'status.empty': 'No options',
'overlay.aria': '/{command} options',
'listbox.aria': '/{command} matches',
} satisfies Record<CommandKey, string>

View File

@@ -0,0 +1,292 @@
/**
* Headless popupSelect shell state: one controller per client
* session, owned by CommandUiRuntime'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-input-trigger/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
/** Option waiting for explicit risk acknowledgement; null during normal selection. */
readonly confirming: SelectOption | null
/** Caller-controlled checkbox state for the pending confirmation. */
readonly acknowledged: 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, confirming: null, acknowledged: 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-commands] 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 || s.confirming !== null || 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 || s.confirming !== null) 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 || s.confirming !== null) 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 || s.confirming !== null) return
const option = filterOptions(s.options, s.search)[index]
if (option === undefined) return
if (option.confirmation !== undefined) {
this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
return
}
await this.settle(binding, option)
}
/**
* Update the explicit checkbox for the currently pending risk gate.
* @param acknowledged - whether the user has acknowledged the displayed risk.
*/
acknowledge(acknowledged: boolean): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
this.state.set({ ...s, acknowledged })
}
/** Cancel only the risk gate and return to the still-open option picker. */
cancelConfirmation(): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || s.confirming === null) return
this.state.set({ ...s, confirming: null, acknowledged: false })
}
/** Settle the gated option only after the checkbox is acknowledged. */
async confirm(): Promise<void> {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
await this.settle(binding, s.confirming)
}
/** Run the business settlement for an already admitted option. */
private async settle(binding: OpenBinding<TCtx>, option: SelectOption): Promise<void> {
const s = this.state.getSnapshot()
if (this.binding !== binding || !s.open || s.submitting) return
this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
try {
await binding.spec.onSelect(option, binding.context)
} catch (error) {
console.error(`[ui-commands] 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,454 @@
/**
* 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 fuzzy 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 '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick,
SubmitOutcome,
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { CommandContribution, CommandDecoration, CommandUiContract } 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'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* This browser client completed one admitted Host command execution.
* Other clients receive the durable command nodes but never this local
* submission acknowledgment.
* @param sessionId - Session addressed by the local submission.
* @param name - Executed command name without the leading slash.
* @param result - Host command result returned to this browser.
* @mode emit
*/
'command/executed'(sessionId: SessionId, name: string, result: CommandResult): void
}
}
/** Recover the command name from a line the Host confirmed as executed. */
function submittedCommandName(line: string): string {
const trimmed = line.trim()
const separator = trimmed.search(/\s/u)
return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1)
}
/** 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>>
}
/** One fuzzy match with its stable source position. */
interface RankedCandidate {
readonly candidate: InputTriggerCandidate
readonly index: number
readonly prefix: boolean
readonly score: number
}
/** Extra weight for command-name starts and separator boundaries. */
function boundaryBonus(name: string, index: number): number {
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
}
/**
* Score the strongest ordered-subsequence alignment in O(name × query).
* Boundary and adjacent matches earn weight; skipped and leading characters
* cost weight.
*/
function fuzzyScore(name: string, query: string): number | undefined {
if (query === '') return 0
if (query.length > name.length) return undefined
const noMatch = Number.NEGATIVE_INFINITY
let previous = Array<number>(name.length).fill(noMatch)
for (let index = 0; index < name.length; index++) {
if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
}
for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
const current = Array<number>(name.length).fill(noMatch)
let bestGapped = noMatch
for (let index = 0; index < name.length; index++) {
const gappedIndex = index - 2
if (gappedIndex >= 0) {
const prior = previous[gappedIndex] ?? noMatch
if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex)
}
if (name.charAt(index) !== query.charAt(queryIndex)) continue
const bonus = 1 + boundaryBonus(name, index)
const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch
if (adjacent !== noMatch) current[index] = adjacent + bonus + 4
if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index)
}
previous = current
}
let best = noMatch
for (const score of previous) best = Math.max(best, score)
return best === noMatch ? undefined : best
}
/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */
function fuzzyCandidates(candidates: readonly InputTriggerCandidate[], rawQuery: string): readonly InputTriggerCandidate[] {
const query = rawQuery.toLowerCase()
if (query === '') return candidates
const ranked: RankedCandidate[] = []
candidates.forEach((candidate, index) => {
const name = candidate.name.toLowerCase()
const score = fuzzyScore(name, query)
if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score })
})
ranked.sort((left, right) =>
Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
return ranked.map(match => match.candidate)
}
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandUiRuntime extends Service implements CommandUiContract {
static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), decorations: 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, 'commandUi')
this.directory = new CommandDirectory(async (sessionId) => {
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
const result = await ctx.remote.commands.list(sessionId)
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value
})
const inputTriggers = ctx.get('inputTriggers')
if (inputTriggers === undefined) throw new Error('ui-commands: slash service unavailable')
ctx.effect(() => inputTriggers.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.remote.$on('commands/change', () => { this.directory.invalidateAll() })
// A preset switch changes which commands one session's agent resolves and
// registers nothing globally, so the registry-wide signal above never
// fires for it: repull that key alone, soft, so the old snapshot serves
// the menu until the new one lands.
ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) })
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 {
const dispose = this.ctx.effect(() => {
const { contributions } = this.live
if (contributions.has(contribution.name)) {
throw new Error(`ui-commands: duplicate contribution for /${contribution.name}`)
}
contributions.set(contribution.name, contribution)
return () => { contributions.delete(contribution.name) }
}, 'command.register()')
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-commands: 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
* 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 position filtering and fuzzy name ranking. */
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 } : {}) })
}
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 })
}
return fuzzyCandidates(
rows.filter(c => req.position === 'leading' || c.hint === undefined),
req.query,
)
}
/** Decision table, menu column: contribution/decorated-host → popup; 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 })
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).
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(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 })
this.runDetached(desc, session, trimmed)
return 'handled'
}
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
private openPopup(
name: string,
ui: CommandContribution['ui'],
session: ClientSessionContext,
segment: TokenSegment,
): void {
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} `
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 — pure
* admission semantics. An unmatched line reports an error outcome (the
* composer's immediate admission feedback); an admitted command reports
* plain success regardless of its handler outcome, because the host
* executor durably logged the lifecycle (`command/run`/`command/done`) and
* the outcome renders as a persistent flow node — the composer never
* echoes it. Transport failures throw.
*/
private async execute(
session: ClientSessionContext,
line: string,
): Promise<SubmitOutcome> {
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result)
return { kind: 'success' }
}
/** Publish the local acknowledgment without letting an observer change command admission. */
private notifyExecuted(sessionId: SessionId, name: string, result: CommandResult): void {
const args = ['command/executed', sessionId, name, result]
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
try {
const returned = listener(sessionId, name, result)
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
this.warnExecutedListenerFailure(name, error)
})
}
} catch (error) {
this.warnExecutedListenerFailure(name, error)
}
}
}
/** Log one contained `command/executed` observer failure. */
private warnExecutedListenerFailure(name: string, error: unknown): void {
this.ctx.logger.warn('client command: a command/executed listener for "%s" failed', name)
this.ctx.logger.warn(error)
}
/**
* Fire-and-forget execute for the internal ('handled') paths. Outcomes are
* NOT surfaced here: the host executor durably logs the command lifecycle
* (`command/run`/`command/done`), and the mux-broadcast events render as a
* persistent flow node on every tab. Only a transport/admission failure —
* which never entered a handler and therefore never logged — falls back to
* the composer notice as immediate feedback.
*/
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
void this.execute(session, line).then(
(outcome) => {
// matched:false maps to an error outcome with no logged lifecycle.
if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
},
(error: unknown) => {
this.noticeFor(session.sessionId, '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 an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
const actx = this.scopeFor(id)
if (actx === undefined) return
const conversation = actx.get('conversation')
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(): ISessions {
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('ui-commands: 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 dsh.client
* declaration. The host command registry itself mounts separately
* (bootHost + CommandUiRuntime).
*/
/** 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-commands`.
* @module @deepseek-ai/dsh-client-ui-commands/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-commands'
/** Cordis companion plugin name. */
export const name = 'client-ui-commands-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 */