feat: slash system / input service / agent scope
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/* Official popupSelect shell card: menu-surface tokens (same family as
|
||||
* ui-primitives Menu.module.css — figma MenuDropdown r12 / hairline /
|
||||
* shadow-lv3), anchored by the conversation.input.overlay slot. */
|
||||
|
||||
.card {
|
||||
/* The overlay anchor is a zero-height strip on the composer card's top
|
||||
edge; entries float themselves above it (same rule as MenuView). */
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-text-primary);
|
||||
}
|
||||
|
||||
.rowActive {
|
||||
background: var(--dsw-alias-fill-hover);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
color: var(--dsw-alias-text-secondary);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-text-tertiary);
|
||||
}
|
||||
|
||||
.search {
|
||||
margin: 2px 2px 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.retry {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Official popupSelect shell: renders one session's PopupSelectController
|
||||
* store into the conversation.input.overlay anchor. Unlike the slash menu
|
||||
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
|
||||
* inner search input takes focus, plain typing filters the loaded options
|
||||
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
|
||||
* the composer, and ←→ keep the search input's native caret. Any pointer
|
||||
* interaction outside the box dismisses (the click's own target takes
|
||||
* focus). Closed state renders null; the overlay slot stays mounted.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { filterOptions } from './popup.ts'
|
||||
import type { PopupSelectController } from './popup.ts'
|
||||
import css from './PopupSelectView.module.css'
|
||||
|
||||
/** Injected business face of the popupSelect overlay entry. */
|
||||
export interface PopupSelectInjected {
|
||||
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
|
||||
popup: PopupSelectController
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the popupSelect shell overlay entry.
|
||||
* @param props - injected face: the session's shell controller.
|
||||
* @returns the select card while open; null while closed.
|
||||
*/
|
||||
export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => popup.state.subscribe(fn),
|
||||
() => popup.state.getSnapshot(),
|
||||
)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus ownership: the search input grabs on open (the design's
|
||||
// transient-layer rule), and ANY outside pointer interaction dismisses —
|
||||
// capture phase so a click landing anywhere else (textarea included)
|
||||
// closes the shell before its own handlers run; that click's target then
|
||||
// takes focus naturally, so no focusComposer here.
|
||||
useEffect(() => {
|
||||
if (!state.open) return
|
||||
searchRef.current?.focus()
|
||||
const onPointerDown = (ev: PointerEvent): void => {
|
||||
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
|
||||
popup.dismiss()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}, [state.open, popup])
|
||||
|
||||
if (!state.open) return null
|
||||
|
||||
const rows = filterOptions(state.options, state.search)
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
|
||||
// its native caret movement.
|
||||
switch (ev.key) {
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault()
|
||||
popup.move(1)
|
||||
return
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault()
|
||||
popup.move(-1)
|
||||
return
|
||||
case 'Enter':
|
||||
ev.preventDefault()
|
||||
void popup.select(state.active)
|
||||
return
|
||||
case 'Escape':
|
||||
ev.preventDefault()
|
||||
popup.dismiss({ focusComposer: true })
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
aria-label={`/${String(state.command)} options`}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
aria-label="Filter options"
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
/>
|
||||
{state.error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>Loading options…</div>}
|
||||
{state.submitting && <div className={css.status}>Applying…</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>{option.label}</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Frozen contract of the client command surface. Types only. The
|
||||
* CommandService (`ctx.command`) implements this face; business packages
|
||||
* consume `register` alone.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** One option row of a popupSelect shell. */
|
||||
export interface SelectOption {
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly detail?: string
|
||||
readonly active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Business registration for the popupSelect command kind. Data is
|
||||
* self-served: options/onSelect use the business package's own protocol.
|
||||
* The shell component is owned by ui-command; business never sees it. Both
|
||||
* callbacks receive the ClientSessionContext captured at popup open.
|
||||
*/
|
||||
export type CommandUiSpec = {
|
||||
readonly kind: 'popupSelect'
|
||||
options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
|
||||
onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* One client-owned command contribution: a slash-menu entry whose behavior
|
||||
* lives entirely on the client (no host descriptor). Merged with the host
|
||||
* catalog by name — a collision with a host command fails loud at candidate
|
||||
* synthesis, never shadows.
|
||||
*/
|
||||
export interface CommandContribution {
|
||||
/** Command name without the leading slash (unique across contributions). */
|
||||
readonly name: string
|
||||
/** Menu row description. */
|
||||
readonly description: string
|
||||
/** Capability filter, called with a fresh projection per candidate pass. */
|
||||
available(session: ClientSessionContext): boolean
|
||||
/** The command's UI behavior (this phase: popupSelect only). */
|
||||
readonly ui: CommandUiSpec
|
||||
}
|
||||
|
||||
/** The `ctx.command` service face visible to business packages. */
|
||||
export interface CommandServiceContract {
|
||||
/**
|
||||
* Register one client command contribution; effect disposer. Duplicate
|
||||
* names throw at registration.
|
||||
*/
|
||||
register(contribution: CommandContribution): () => void
|
||||
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
|
||||
popupFor(actx: ClientContext): unknown
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Command-directory cache keyed by session: one entry per served catalog —
|
||||
* every session is agent-backed, so `command.list({sessionId})` is the only
|
||||
* address shape. Each entry keeps the single-flight / soft-hard invalidation
|
||||
* / epoch-guard behavior of the original global cache; the session-key axis
|
||||
* is the only extra dimension.
|
||||
*/
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** command.list success value, derived so the wire type authority stays in apiproxy. */
|
||||
type ListValue = Extract<Awaited<ReturnType<IApiClient['commands']['list']>>['result'], { ok: true }>['value']
|
||||
|
||||
/** One host command descriptor as served to the client. */
|
||||
export type CommandDescriptor = ListValue['commands'][number]
|
||||
|
||||
/**
|
||||
* cold = never pulled; pending = pull in flight with nothing servable;
|
||||
* ready = snapshot serving (a soft-invalidate repull keeps this status);
|
||||
* failed = last winning pull rejected, snapshot dropped.
|
||||
*/
|
||||
export type DirectoryStatus = 'cold' | 'pending' | 'ready' | 'failed'
|
||||
|
||||
/** Injected pull (the service binds command.list off the root connection). */
|
||||
export type FetchCommands = (sessionId: SessionId) => Promise<readonly CommandDescriptor[]>
|
||||
|
||||
/** One session key's cache cell. */
|
||||
class Entry {
|
||||
state: DirectoryStatus = 'cold'
|
||||
commands: readonly CommandDescriptor[] = []
|
||||
/** Bumped at each pull start; only the latest pull may publish its outcome. */
|
||||
epoch = 0
|
||||
lastError: unknown
|
||||
waiters: Array<() => void> = []
|
||||
}
|
||||
|
||||
/** The session-keyed directory cache. Plain class — the owning service wires events and RPC. */
|
||||
export class CommandDirectory {
|
||||
private readonly entries = new Map<SessionId, Entry>()
|
||||
|
||||
constructor(private readonly fetchCommands: FetchCommands) {}
|
||||
|
||||
/**
|
||||
* Current cache status for one session.
|
||||
* @param sessionId - session key.
|
||||
* @returns the entry status (cold when never touched).
|
||||
*/
|
||||
status(sessionId: SessionId): DirectoryStatus {
|
||||
return this.entries.get(sessionId)?.state ?? 'cold'
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous exact-name lookup over one session's hot snapshot.
|
||||
* @param sessionId - session key.
|
||||
* @param name - command name without the leading slash.
|
||||
* @returns the descriptor, or undefined when absent or the entry is not ready.
|
||||
*/
|
||||
resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined {
|
||||
const entry = this.entries.get(sessionId)
|
||||
if (entry === undefined || entry.state !== 'ready') return undefined
|
||||
return entry.commands.find(c => c.name === name)
|
||||
}
|
||||
|
||||
/** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */
|
||||
invalidateAll(): void {
|
||||
for (const key of this.entries.keys()) void this.refresh(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset on reconnect: every entry drops its snapshot (the agent world
|
||||
* may have changed shape across the generation) and prewarms.
|
||||
*/
|
||||
resetConnected(): void {
|
||||
for (const [key, entry] of this.entries) {
|
||||
entry.state = 'cold'
|
||||
entry.commands = []
|
||||
void this.refresh(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget prewarm of one session (the command source's scope-birth
|
||||
* warm hook lands here).
|
||||
* @param sessionId - session key.
|
||||
*/
|
||||
warm(sessionId: SessionId): void {
|
||||
const entry = this.entry(sessionId)
|
||||
if (entry.state === 'cold' || entry.state === 'failed') void this.refresh(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one pull for one session. Publishes ready/failed only while it is
|
||||
* still the key's latest pull (epoch guard); a ready snapshot is not
|
||||
* demoted while the pull flies.
|
||||
* @param sessionId - session key.
|
||||
* @returns settled when this pull's outcome is published or discarded.
|
||||
*/
|
||||
async refresh(sessionId: SessionId): Promise<void> {
|
||||
const entry = this.entry(sessionId)
|
||||
const epoch = ++entry.epoch
|
||||
if (entry.state !== 'ready') entry.state = 'pending'
|
||||
try {
|
||||
const commands = await this.fetchCommands(sessionId)
|
||||
if (epoch !== entry.epoch) return
|
||||
entry.commands = commands
|
||||
entry.state = 'ready'
|
||||
entry.lastError = undefined
|
||||
} catch (error) {
|
||||
if (epoch !== entry.epoch) return
|
||||
entry.commands = []
|
||||
entry.state = 'failed'
|
||||
entry.lastError = error
|
||||
} finally {
|
||||
if (epoch === entry.epoch) notifyWaiters(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strong-wait until one session's catalog is servable (the enter-
|
||||
* adjudication "directory must be reached" rule): ready returns at once;
|
||||
* cold/failed launch a fresh pull; pending joins the flying one. Rejects
|
||||
* when the awaited pull fails or the signal aborts.
|
||||
* @param sessionId - session key.
|
||||
* @param signal - attempt-scoped abort (the SubmitAttempt signal).
|
||||
* @returns the hot command snapshot.
|
||||
*/
|
||||
async ensureReady(sessionId: SessionId, signal: AbortSignal): Promise<readonly CommandDescriptor[]> {
|
||||
const entry = this.entry(sessionId)
|
||||
while (true) {
|
||||
if (entry.state === 'ready') return entry.commands
|
||||
if (entry.state !== 'pending') void this.refresh(sessionId)
|
||||
await settled(entry, signal)
|
||||
if (entry.state === 'failed') {
|
||||
throw new Error(`command directory warmup failed: ${entry.lastError instanceof Error ? entry.lastError.message : String(entry.lastError)}`)
|
||||
}
|
||||
// Still pending (the awaited pull was superseded) → wait for the winner.
|
||||
}
|
||||
}
|
||||
|
||||
private entry(sessionId: SessionId): Entry {
|
||||
let entry = this.entries.get(sessionId)
|
||||
if (entry === undefined) {
|
||||
entry = new Entry()
|
||||
this.entries.set(sessionId, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
/** One settlement tick for one entry: resolves at the next winning publish, rejects on abort. */
|
||||
function settled(entry: Entry, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(abortReason(signal))
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
entry.waiters = entry.waiters.filter(w => w !== waiter)
|
||||
reject(abortReason(signal))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
entry.waiters.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
function notifyWaiters(entry: Entry): void {
|
||||
const woken = entry.waiters
|
||||
entry.waiters = []
|
||||
for (const wake of woken) wake()
|
||||
}
|
||||
|
||||
/** Normalize an abort into an Error rejection. */
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error ? signal.reason : new Error('command directory wait aborted')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Command UI plugin, browser half: CommandService (`ctx.command`) owning the
|
||||
* capability-keyed directory cache, the '/' command source, the client
|
||||
* contribution registry, and the per-session popupSelect controllers; the
|
||||
* popupSelect shell self-registers into conversation.input.overlay with
|
||||
* per-session resolution.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the
|
||||
// key's owner) into this program so the overlay registration below typechecks
|
||||
// against the real declaration — no runtime edge to ui-conversation.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CommandService } from './service.ts'
|
||||
import type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
import { PopupSelectView } from './PopupSelectView.tsx'
|
||||
|
||||
export { CommandService } from './service.ts'
|
||||
export { CommandDirectory } from './directory.ts'
|
||||
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
|
||||
export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
} from './contract.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
command: CommandService
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
|
||||
export const inject = ['slash', 'sessions', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register the popupSelect shell
|
||||
* into the input overlay once its declarer is up.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(CommandService)
|
||||
// Conditional mount, same seam as ui-slash's MenuView registration:
|
||||
// 'conversation.input.overlay' is declared by the conversation composer
|
||||
// entry, and the conversation service's presence is the registration-safe
|
||||
// signal that the declaration is on the ledger.
|
||||
ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => {
|
||||
const command = scope.command
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.overlay',
|
||||
id: 'command-popup',
|
||||
order: 1,
|
||||
inject: (sessionId): PopupSelectInjected => {
|
||||
const actx = sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
|
||||
return { popup: command.popupFor(actx) }
|
||||
},
|
||||
}, PopupSelectView), 'ui-command: popupSelect overlay registration')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Headless popupSelect shell state (design §10): one controller per client
|
||||
* session, owned by CommandService's per-session map and torn down by the
|
||||
* session scope disposer. The shell is a transient layer (never in the input
|
||||
* state machine): it loads options once, filters them locally against the
|
||||
* shell's own search text, and settles a selection through the context
|
||||
* captured at open time. Draft consumption and composer focus are injected
|
||||
* callbacks — the session wiring dispatches the consume-token event (the
|
||||
* Input side owns the span/bare-token CAS guard) and focuses the composer;
|
||||
* the controller never touches the input machine.
|
||||
*/
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { SelectOption } from './contract.ts'
|
||||
|
||||
/**
|
||||
* The command token segment snapshotted at shell-open time, replayed to the
|
||||
* injected {@link PopupSelectDeps.consume} callback after a successful
|
||||
* selection. The Input side guards it: a menu-path span consumes iff draftRev
|
||||
* is unchanged, an enter-path line iff the trimmed draft still equals the
|
||||
* bare token.
|
||||
*/
|
||||
export type TokenSegment =
|
||||
| { readonly via: 'menu'; readonly span: TokenSpan }
|
||||
| { readonly via: 'enter'; readonly token: string }
|
||||
|
||||
/**
|
||||
* Structural business spec the shell settles against — the popupSelect half
|
||||
* of CommandUiSpec, generic in the context value the opener captures (the
|
||||
* session wiring passes its session projection; the controller only carries
|
||||
* it from open() to the callbacks).
|
||||
*/
|
||||
export interface PopupSpec<TCtx> {
|
||||
/** Load the option rows once per open (retry after failure reuses the same signal). */
|
||||
options(context: TCtx, signal: AbortSignal): Promise<readonly SelectOption[]>
|
||||
/** Settle the picked option against the open-time context. */
|
||||
onSelect(option: SelectOption, context: TCtx): void | Promise<void>
|
||||
}
|
||||
|
||||
/** Injected session-wiring callbacks of one controller (tests pass fakes). */
|
||||
export interface PopupSelectDeps {
|
||||
/**
|
||||
* Consume the open-time token segment after a successful onSelect (the
|
||||
* wiring dispatches the consume-token event to the opening session).
|
||||
* @param segment - the open-time token segment snapshot.
|
||||
* @returns whether the token was consumed; false (CAS miss) is benign and
|
||||
* never retried.
|
||||
*/
|
||||
consume(segment: TokenSegment): boolean
|
||||
/** Return focus to the session composer (successful settle and Escape close paths). */
|
||||
focusComposer(): void
|
||||
}
|
||||
|
||||
/** Popup shell state (the shell component renders from here; closed = render null). */
|
||||
export interface PopupState {
|
||||
readonly open: boolean
|
||||
/** Command name the shell is open for (null while closed). */
|
||||
readonly command: string | null
|
||||
/** Options-load lifecycle; 'failed' keeps the shell open for retry(). */
|
||||
readonly status: 'pending' | 'ready' | 'failed'
|
||||
/** Options as loaded — never re-fetched per keystroke; views render {@link filterOptions} over them. */
|
||||
readonly options: readonly SelectOption[]
|
||||
/** Local filter text over the loaded options. */
|
||||
readonly search: string
|
||||
/** Highlight index into the filtered row list (0 when empty/pending). */
|
||||
readonly active: number
|
||||
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
|
||||
readonly submitting: boolean
|
||||
/** Surfaced settlement failure (options load or onSelect); null when none. */
|
||||
readonly error: string | null
|
||||
}
|
||||
|
||||
const CLOSED: PopupState = {
|
||||
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter option rows against the shell's local search text (case-insensitive
|
||||
* substring over label and detail; blank search keeps every row).
|
||||
* @param options - the loaded rows.
|
||||
* @param search - the shell's search text.
|
||||
* @returns the rows the shell shows and highlights over.
|
||||
*/
|
||||
export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (query === '') return options
|
||||
return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false))
|
||||
}
|
||||
|
||||
/** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */
|
||||
interface OpenBinding<TCtx> {
|
||||
readonly command: string
|
||||
readonly spec: PopupSpec<TCtx>
|
||||
readonly context: TCtx
|
||||
readonly segment: TokenSegment
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
/** The shell's error-strip line for a settlement failure. */
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless controller of one session's popupSelect shell. Late settlements
|
||||
* lose their write rights through binding identity: dismiss/dispose/reopen
|
||||
* swap the binding, so a settling options fetch or onSelect that no longer
|
||||
* matches writes nothing and consumes nothing.
|
||||
*/
|
||||
export class PopupSelectController<TCtx = unknown> {
|
||||
/** Shell state store (the overlay component subscribes here). */
|
||||
readonly state: SnapshotStore<PopupState> = createSnapshotStore<PopupState>(CLOSED)
|
||||
private binding: OpenBinding<TCtx> | null = null
|
||||
|
||||
/**
|
||||
* @param deps - session-wiring callbacks (token consumption + composer focus).
|
||||
*/
|
||||
constructor(private readonly deps: PopupSelectDeps) {}
|
||||
|
||||
/**
|
||||
* Open the shell for one command: publish pending state and fetch options
|
||||
* once through the business spec. A reopen supersedes the previous shell
|
||||
* (its options fetch is aborted, its late settlements are dropped).
|
||||
* @param command - command name the shell serves.
|
||||
* @param spec - the registered popupSelect spec.
|
||||
* @param context - open-time context snapshot, handed verbatim to options/onSelect.
|
||||
* @param segment - open-time token segment snapshot for post-select consumption.
|
||||
*/
|
||||
open(command: string, spec: PopupSpec<TCtx>, context: TCtx, segment: TokenSegment): void {
|
||||
this.binding?.abort.abort()
|
||||
const binding: OpenBinding<TCtx> = { command, spec, context, segment, abort: new AbortController() }
|
||||
this.binding = binding
|
||||
this.state.set({ ...CLOSED, open: true, command })
|
||||
this.load(binding)
|
||||
}
|
||||
|
||||
/** Run the one options fetch of a binding; settlement rights die with the binding. */
|
||||
private load(binding: OpenBinding<TCtx>): void {
|
||||
binding.spec.options(binding.context, binding.abort.signal).then(
|
||||
(options) => {
|
||||
if (this.binding !== binding) return
|
||||
this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.binding !== binding) return
|
||||
console.error(`[ui-command] popupSelect options failed for /${binding.command}:`, error)
|
||||
this.state.set({ ...this.state.getSnapshot(), status: 'failed', options: [], active: 0, error: errorText(error) })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-run a failed options fetch (search survives; no-op unless status is 'failed'). */
|
||||
retry(): void {
|
||||
const binding = this.binding
|
||||
const s = this.state.getSnapshot()
|
||||
if (binding === null || !s.open || s.status !== 'failed') return
|
||||
this.state.set({ ...s, status: 'pending', error: null })
|
||||
this.load(binding)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the local search text (pure local filter — the provider is never
|
||||
* re-queried) and rebase the highlight onto the new filtered list.
|
||||
* @param search - the shell search input's text.
|
||||
*/
|
||||
setSearch(search: string): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.submitting || search === s.search) return
|
||||
this.state.set({ ...s, search, active: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the highlight across the filtered rows (wraps around; no-op unless
|
||||
* options are ready and no selection is in flight).
|
||||
* @param dir - +1 down, -1 up.
|
||||
*/
|
||||
move(dir: 1 | -1): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.status !== 'ready' || s.submitting) return
|
||||
const rows = filterOptions(s.options, s.search)
|
||||
if (rows.length === 0) return
|
||||
const active = (s.active + dir + rows.length) % rows.length
|
||||
this.state.set({ ...s, active })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the highlight directly (pointer hover; no-op unless ready, idle, and
|
||||
* in filtered range).
|
||||
* @param index - filtered-row index.
|
||||
*/
|
||||
highlight(index: number): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.status !== 'ready' || s.submitting) return
|
||||
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
|
||||
this.state.set({ ...s, active: index })
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one filtered row: single-flight — the first call enters
|
||||
* `submitting` and later calls no-op until it settles. Success consumes the
|
||||
* open-time token segment (a false CAS answer is benign), closes, and
|
||||
* returns focus to the composer. Failure keeps the shell open with search,
|
||||
* highlight, and token intact, surfaces the error, and re-arms select as
|
||||
* the retry.
|
||||
* @param index - filtered-row index (callers pass the highlight or the clicked row).
|
||||
* @returns settled when the attempt has closed the shell or surfaced its failure.
|
||||
*/
|
||||
async select(index: number): Promise<void> {
|
||||
const binding = this.binding
|
||||
const s = this.state.getSnapshot()
|
||||
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
|
||||
const option = filterOptions(s.options, s.search)[index]
|
||||
if (option === undefined) return
|
||||
this.state.set({ ...s, submitting: true, error: null })
|
||||
try {
|
||||
await binding.spec.onSelect(option, binding.context)
|
||||
} catch (error) {
|
||||
console.error(`[ui-command] popupSelect onSelect failed for /${binding.command}:`, error)
|
||||
if (this.binding !== binding) return // dismissed/reopened/disposed while onSelect flew
|
||||
this.state.set({ ...this.state.getSnapshot(), submitting: false, error: errorText(error) })
|
||||
return
|
||||
}
|
||||
if (this.binding !== binding) return // late success: no state write, no consumption
|
||||
this.deps.consume(binding.segment)
|
||||
this.binding = null
|
||||
this.state.set(CLOSED)
|
||||
this.deps.focusComposer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the shell; aborts a flying options fetch and revokes settlement
|
||||
* rights. An outside pointer interaction dismisses plainly (the click's own
|
||||
* target takes focus); Escape passes focusComposer to return focus explicitly.
|
||||
* @param opts - focusComposer: also restore composer focus (Escape path).
|
||||
*/
|
||||
dismiss(opts?: { readonly focusComposer?: boolean }): void {
|
||||
if (this.binding === null) return
|
||||
this.binding.abort.abort()
|
||||
this.binding = null
|
||||
this.state.set(CLOSED)
|
||||
if (opts?.focusComposer === true) this.deps.focusComposer()
|
||||
}
|
||||
|
||||
/** Scope-teardown disposer: abort in-flight work and clear state (no focus side effect). */
|
||||
dispose(): void {
|
||||
this.binding?.abort.abort()
|
||||
this.binding = null
|
||||
this.state.set(CLOSED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* CommandService (`ctx.command`): the '/' command source over the
|
||||
* session-keyed directory, the client-contribution registry, and the
|
||||
* per-session popupSelect controllers. Candidate synthesis merges the host
|
||||
* catalog with contributions by availability, then query/position filtering;
|
||||
* a host/contribution name collision fails loud. Every execute addresses the
|
||||
* session's agent by sessionId — sessions are always agent-backed.
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the notice route reads ctx.conversation.input — no runtime edge.
|
||||
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SlashServiceContract, SubmitOutcome,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandDescriptor } from './directory.ts'
|
||||
import { CommandDirectory } from './directory.ts'
|
||||
import { PopupSelectController } from './popup.ts'
|
||||
import type { TokenSegment } from './popup.ts'
|
||||
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
readonly contributions: Map<string, CommandContribution>
|
||||
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
|
||||
}
|
||||
|
||||
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
|
||||
export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (plugin fiber; the service registers
|
||||
* itself as `command` and follows that fiber's lifetime).
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'command')
|
||||
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
|
||||
this.directory = new CommandDirectory(async (sessionId) => {
|
||||
const { result } = await connection.api.commands.list({ sessionId })
|
||||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.commands
|
||||
})
|
||||
const slash = ctx.get('slash') as SlashServiceContract | undefined
|
||||
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
|
||||
ctx.effect(() => slash.registerSource({
|
||||
trigger: '/',
|
||||
name: 'command',
|
||||
candidates: (session, req) => this.candidates(session, req),
|
||||
onPick: pick => this.dispatch(pick),
|
||||
matchSpace: (session, token) => this.matchSpace(session, token),
|
||||
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
|
||||
ctx.on('connection/reset', () => { this.directory.resetConnected() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one client command contribution; effect disposer (rides the
|
||||
* caller's fiber). Duplicate names throw.
|
||||
* @param contribution - the contribution (descriptor + availability + popup spec).
|
||||
* @returns the disposer removing the registration.
|
||||
*/
|
||||
register(contribution: CommandContribution): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const { contributions } = this.live
|
||||
if (contributions.has(contribution.name)) {
|
||||
throw new Error(`ui-command: duplicate contribution for /${contribution.name}`)
|
||||
}
|
||||
contributions.set(contribution.name, contribution)
|
||||
return () => { contributions.delete(contribution.name) }
|
||||
}, 'command.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-session popup controller (lazy; dies with the session
|
||||
* scope). The controller's consume callback dispatches the scoped
|
||||
* consume-token event back to this session; focusComposer reaches the
|
||||
* composer through the overlay slot currency.
|
||||
* @param actx - session-scope ctx.
|
||||
* @returns the resident controller.
|
||||
*/
|
||||
popupFor(actx: ClientContext): PopupSelectController<ClientSessionContext> {
|
||||
const sessions = this.sessions()
|
||||
const id = sessions.scopeOf(actx)
|
||||
if (id === undefined) throw new Error('command.popupFor requires a session scope')
|
||||
const { popups } = this.live
|
||||
const existing = popups.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
const controller = new PopupSelectController<ClientSessionContext>({
|
||||
consume: segment => actx.bail(actx, 'slash/input-consume-token', {
|
||||
guard: segment.via === 'menu'
|
||||
? { kind: 'span', span: segment.span }
|
||||
: { kind: 'bare-token', token: segment.token },
|
||||
}) === true,
|
||||
focusComposer: () => { this.focusHooks.get(id)?.() },
|
||||
})
|
||||
popups.set(id, controller)
|
||||
actx.effect(() => () => {
|
||||
controller.dispose()
|
||||
popups.delete(id)
|
||||
this.focusHooks.delete(id)
|
||||
}, 'command: session popup')
|
||||
return controller
|
||||
}
|
||||
|
||||
/** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
|
||||
private readonly focusHooks = new Map<SessionId, () => void>()
|
||||
|
||||
/**
|
||||
* Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
|
||||
* @param id - session id.
|
||||
* @param focus - textarea focus callback.
|
||||
* @returns the unbind disposer.
|
||||
*/
|
||||
bindComposerFocus(id: SessionId, focus: () => void): () => void {
|
||||
this.focusHooks.set(id, focus)
|
||||
return () => {
|
||||
if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Menu candidates: host catalog + contribution availability, then query/position filtering. */
|
||||
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
|
||||
const list = await this.directory.ensureReady(session.sessionId, req.signal)
|
||||
const rows: SlashCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const c of list) {
|
||||
seen.add(c.name)
|
||||
rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) })
|
||||
}
|
||||
for (const contribution of this.live.contributions.values()) {
|
||||
if (!contribution.available(session)) continue
|
||||
if (seen.has(contribution.name)) {
|
||||
throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`)
|
||||
}
|
||||
rows.push({ name: contribution.name, description: contribution.description })
|
||||
}
|
||||
return rows
|
||||
.filter(c => c.name.startsWith(req.query))
|
||||
.filter(c => req.position === 'leading' || c.hint === undefined)
|
||||
}
|
||||
|
||||
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
|
||||
private dispatch(pick: SlashPick): PickOutcome {
|
||||
const name = pick.candidate.name
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(pick.session)) {
|
||||
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
|
||||
return 'handled'
|
||||
}
|
||||
const desc = this.directory.resolve(pick.session.sessionId, name)
|
||||
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
|
||||
// Menu-pick execute consumes the trigger span before the detached run
|
||||
// (scoped event; the input owns the CAS guard).
|
||||
this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
|
||||
this.runDetached(desc, pick.session, `/${name}`)
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
/** Decision table, space column: hot-key sync check; only host leadingInput claims. */
|
||||
private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
|
||||
if (!token.startsWith('/')) return undefined
|
||||
const name = token.slice(1)
|
||||
if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
|
||||
const desc = this.directory.resolve(session.sessionId, name)
|
||||
if (desc === undefined || desc.input === undefined) return undefined
|
||||
return { claim: this.leadingClaim(desc, session) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision table, enter column. Strong-waits the session's catalog (a
|
||||
* warmup failure rejects — never a silent downgrade). Contributions and
|
||||
* bare host commands act on the bare token only; leadingInput claims
|
||||
* args-tolerant.
|
||||
*/
|
||||
private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise<PickOutcome> {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('/')) return undefined
|
||||
const ws = trimmed.search(/\s/)
|
||||
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
|
||||
const bare = ws === -1
|
||||
const name = token.slice(1)
|
||||
if (name === '') return undefined
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(session)) {
|
||||
if (!bare) return undefined
|
||||
this.openPopup(contribution, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
await this.directory.ensureReady(session.sessionId, signal)
|
||||
const desc = this.directory.resolve(session.sessionId, name)
|
||||
if (desc === undefined) return undefined
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
|
||||
if (!bare) return undefined
|
||||
this.consumeVia(session.sessionId, { via: 'enter', token })
|
||||
this.runDetached(desc, session, trimmed)
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
/** Open the session's popup for one contribution (menu pick / bare enter). */
|
||||
private openPopup(
|
||||
contribution: CommandContribution,
|
||||
session: ClientSessionContext,
|
||||
segment: TokenSegment,
|
||||
): void {
|
||||
const actx = this.scopeFor(session.sessionId)
|
||||
if (actx === undefined) return
|
||||
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
|
||||
}
|
||||
|
||||
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
|
||||
private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
|
||||
const token = `/${desc.name} `
|
||||
return {
|
||||
token,
|
||||
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
|
||||
submit: (args, _actx) => this.execute(session, token + args),
|
||||
}
|
||||
}
|
||||
|
||||
/** The command.execute transaction, addressed to the session's agent. */
|
||||
private async execute(
|
||||
session: ClientSessionContext,
|
||||
line: string,
|
||||
): Promise<SubmitOutcome> {
|
||||
const connection = this.ctx.get('connection') as ConnectionHandle
|
||||
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
const detached = result.value.result
|
||||
return detached === undefined
|
||||
? { kind: 'success' }
|
||||
: { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget execute for the internal ('handled') paths. The detached
|
||||
* result surfaces as a notice routed to the triggering session's composer,
|
||||
* so a late result lands on its own session after a switch.
|
||||
*/
|
||||
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
|
||||
void this.execute(session, line).then(
|
||||
(outcome) => {
|
||||
if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`)
|
||||
else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text)
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Dispatch a consume-token event to one session (menu-pick / bare-enter execute paths). */
|
||||
private consumeVia(id: SessionId, segment: TokenSegment): void {
|
||||
const actx = this.scopeFor(id)
|
||||
if (actx === undefined) return
|
||||
actx.bail(actx, 'slash/input-consume-token', {
|
||||
guard: segment.via === 'menu'
|
||||
? { kind: 'span', span: segment.span }
|
||||
: { kind: 'bare-token', token: segment.token },
|
||||
})
|
||||
}
|
||||
|
||||
/** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */
|
||||
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
|
||||
const actx = this.scopeFor(id)
|
||||
if (actx === undefined) return
|
||||
const conversation = actx.get('conversation') as ConversationService | undefined
|
||||
if (conversation === undefined) return
|
||||
conversation.input.for(actx).notify(level, text)
|
||||
}
|
||||
|
||||
/** id → actx interchange (registered exchange point: this service coordinates for projection-only sources). */
|
||||
private scopeFor(id: SessionId): ClientContext | undefined {
|
||||
return this.sessions().scope(id)
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Command UI plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* declaration. The host command registry itself mounts separately
|
||||
* (bootHost + CommandService).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the command UI plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-command`.
|
||||
* @module @deepseek-ai/dsh-client-ui-command/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-command'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-command-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a browser-side source over the wire command
|
||||
* directory — it emits no cordis events and owns no cross-plugin mutable
|
||||
* state; dispatch and cache behavior are asserted by this package's specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user