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:
@@ -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;
|
||||
}
|
||||
176
packages/client/ui-commands/src/client/PopupSelectView.tsx
Normal file
176
packages/client/ui-commands/src/client/PopupSelectView.tsx
Normal 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() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
packages/client/ui-commands/src/client/contract.ts
Normal file
89
packages/client/ui-commands/src/client/contract.ts
Normal 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
|
||||
}
|
||||
172
packages/client/ui-commands/src/client/directory.ts
Normal file
172
packages/client/ui-commands/src/client/directory.ts
Normal 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')
|
||||
}
|
||||
73
packages/client/ui-commands/src/client/index.ts
Normal file
73
packages/client/ui-commands/src/client/index.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
26
packages/client/ui-commands/src/client/locales.ts
Normal file
26
packages/client/ui-commands/src/client/locales.ts
Normal 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>
|
||||
292
packages/client/ui-commands/src/client/popup.ts
Normal file
292
packages/client/ui-commands/src/client/popup.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
454
packages/client/ui-commands/src/client/service.ts
Normal file
454
packages/client/ui-commands/src/client/service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
6
packages/client/ui-commands/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-commands/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
10
packages/client/ui-commands/src/index.ts
Normal file
10
packages/client/ui-commands/src/index.ts
Normal 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 {}
|
||||
31
packages/client/ui-commands/src/invariant.ts
Normal file
31
packages/client/ui-commands/src/invariant.ts
Normal 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 */
|
||||
Reference in New Issue
Block a user