feat: slash system / input service / agent scope
This commit is contained in:
24
packages/client/ui-command/README.md
Normal file
24
packages/client/ui-command/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-ui-command
|
||||
|
||||
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
|
||||
|
||||
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the host `command.execute` RPC this package's dispatch and `claim.submit` paths trigger: a matched command's handler mutates host domain state that other packages project into the next request (the `/plan` handler flips plan mode, whose owning package injects its `plan:policy` system-prompt section), while the command line itself, the detached result, and every menu/notice rendering stay client-side and never enter the session log.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None directly; this package neither assembles nor sends a provider request. Command handlers it triggers may change what the owning host packages contribute to the next request's system prompt (a section appearing or disappearing replaces earlier request tokens and invalidates the provider prefix from that point), but that effect is owned and documented by each command's host package.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The popupSelect shell has no shipped business consumer** — model selection (host `selectModel`) is the design's reference case and lands with its own feature work; until then the shell is exercised by package tests only.
|
||||
- **Detached-result notices fall back to the console off-session** — the fire-and-forget paths route results to the triggering session's composer via `SessionInput.notify`; after session teardown the console line is the only remaining surface.
|
||||
72
packages/client/ui-command/package.json
Normal file
72
packages/client/ui-command/package.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-command",
|
||||
"description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
133
packages/client/ui-command/src/client/PopupSelectView.tsx
Normal file
133
packages/client/ui-command/src/client/PopupSelectView.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Official popupSelect shell: renders one session's PopupSelectController
|
||||
* store into the conversation.input.overlay anchor. Unlike the slash menu
|
||||
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
|
||||
* inner search input takes focus, plain typing filters the loaded options
|
||||
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
|
||||
* the composer, and ←→ keep the search input's native caret. Any pointer
|
||||
* interaction outside the box dismisses (the click's own target takes
|
||||
* focus). Closed state renders null; the overlay slot stays mounted.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { filterOptions } from './popup.ts'
|
||||
import type { PopupSelectController } from './popup.ts'
|
||||
import css from './PopupSelectView.module.css'
|
||||
|
||||
/** Injected business face of the popupSelect overlay entry. */
|
||||
export interface PopupSelectInjected {
|
||||
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
|
||||
popup: PopupSelectController
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the popupSelect shell overlay entry.
|
||||
* @param props - injected face: the session's shell controller.
|
||||
* @returns the select card while open; null while closed.
|
||||
*/
|
||||
export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => popup.state.subscribe(fn),
|
||||
() => popup.state.getSnapshot(),
|
||||
)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus ownership: the search input grabs on open (the design's
|
||||
// transient-layer rule), and ANY outside pointer interaction dismisses —
|
||||
// capture phase so a click landing anywhere else (textarea included)
|
||||
// closes the shell before its own handlers run; that click's target then
|
||||
// takes focus naturally, so no focusComposer here.
|
||||
useEffect(() => {
|
||||
if (!state.open) return
|
||||
searchRef.current?.focus()
|
||||
const onPointerDown = (ev: PointerEvent): void => {
|
||||
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
|
||||
popup.dismiss()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}, [state.open, popup])
|
||||
|
||||
if (!state.open) return null
|
||||
|
||||
const rows = filterOptions(state.options, state.search)
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
|
||||
// its native caret movement.
|
||||
switch (ev.key) {
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault()
|
||||
popup.move(1)
|
||||
return
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault()
|
||||
popup.move(-1)
|
||||
return
|
||||
case 'Enter':
|
||||
ev.preventDefault()
|
||||
void popup.select(state.active)
|
||||
return
|
||||
case 'Escape':
|
||||
ev.preventDefault()
|
||||
popup.dismiss({ focusComposer: true })
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
aria-label={`/${String(state.command)} options`}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
aria-label="Filter options"
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
/>
|
||||
{state.error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>Loading options…</div>}
|
||||
{state.submitting && <div className={css.status}>Applying…</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>{option.label}</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
packages/client/ui-command/src/client/contract.ts
Normal file
55
packages/client/ui-command/src/client/contract.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Frozen contract of the client command surface. Types only. The
|
||||
* CommandService (`ctx.command`) implements this face; business packages
|
||||
* consume `register` alone.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** One option row of a popupSelect shell. */
|
||||
export interface SelectOption {
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly detail?: string
|
||||
readonly active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Business registration for the popupSelect command kind. Data is
|
||||
* self-served: options/onSelect use the business package's own protocol.
|
||||
* The shell component is owned by ui-command; business never sees it. Both
|
||||
* callbacks receive the ClientSessionContext captured at popup open.
|
||||
*/
|
||||
export type CommandUiSpec = {
|
||||
readonly kind: 'popupSelect'
|
||||
options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
|
||||
onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* One client-owned command contribution: a slash-menu entry whose behavior
|
||||
* lives entirely on the client (no host descriptor). Merged with the host
|
||||
* catalog by name — a collision with a host command fails loud at candidate
|
||||
* synthesis, never shadows.
|
||||
*/
|
||||
export interface CommandContribution {
|
||||
/** Command name without the leading slash (unique across contributions). */
|
||||
readonly name: string
|
||||
/** Menu row description. */
|
||||
readonly description: string
|
||||
/** Capability filter, called with a fresh projection per candidate pass. */
|
||||
available(session: ClientSessionContext): boolean
|
||||
/** The command's UI behavior (this phase: popupSelect only). */
|
||||
readonly ui: CommandUiSpec
|
||||
}
|
||||
|
||||
/** The `ctx.command` service face visible to business packages. */
|
||||
export interface CommandServiceContract {
|
||||
/**
|
||||
* Register one client command contribution; effect disposer. Duplicate
|
||||
* names throw at registration.
|
||||
*/
|
||||
register(contribution: CommandContribution): () => void
|
||||
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
|
||||
popupFor(actx: ClientContext): unknown
|
||||
}
|
||||
175
packages/client/ui-command/src/client/directory.ts
Normal file
175
packages/client/ui-command/src/client/directory.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Command-directory cache keyed by session: one entry per served catalog —
|
||||
* every session is agent-backed, so `command.list({sessionId})` is the only
|
||||
* address shape. Each entry keeps the single-flight / soft-hard invalidation
|
||||
* / epoch-guard behavior of the original global cache; the session-key axis
|
||||
* is the only extra dimension.
|
||||
*/
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** command.list success value, derived so the wire type authority stays in apiproxy. */
|
||||
type ListValue = Extract<Awaited<ReturnType<IApiClient['commands']['list']>>['result'], { ok: true }>['value']
|
||||
|
||||
/** One host command descriptor as served to the client. */
|
||||
export type CommandDescriptor = ListValue['commands'][number]
|
||||
|
||||
/**
|
||||
* cold = never pulled; pending = pull in flight with nothing servable;
|
||||
* ready = snapshot serving (a soft-invalidate repull keeps this status);
|
||||
* failed = last winning pull rejected, snapshot dropped.
|
||||
*/
|
||||
export type DirectoryStatus = 'cold' | 'pending' | 'ready' | 'failed'
|
||||
|
||||
/** Injected pull (the service binds command.list off the root connection). */
|
||||
export type FetchCommands = (sessionId: SessionId) => Promise<readonly CommandDescriptor[]>
|
||||
|
||||
/** One session key's cache cell. */
|
||||
class Entry {
|
||||
state: DirectoryStatus = 'cold'
|
||||
commands: readonly CommandDescriptor[] = []
|
||||
/** Bumped at each pull start; only the latest pull may publish its outcome. */
|
||||
epoch = 0
|
||||
lastError: unknown
|
||||
waiters: Array<() => void> = []
|
||||
}
|
||||
|
||||
/** The session-keyed directory cache. Plain class — the owning service wires events and RPC. */
|
||||
export class CommandDirectory {
|
||||
private readonly entries = new Map<SessionId, Entry>()
|
||||
|
||||
constructor(private readonly fetchCommands: FetchCommands) {}
|
||||
|
||||
/**
|
||||
* Current cache status for one session.
|
||||
* @param sessionId - session key.
|
||||
* @returns the entry status (cold when never touched).
|
||||
*/
|
||||
status(sessionId: SessionId): DirectoryStatus {
|
||||
return this.entries.get(sessionId)?.state ?? 'cold'
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous exact-name lookup over one session's hot snapshot.
|
||||
* @param sessionId - session key.
|
||||
* @param name - command name without the leading slash.
|
||||
* @returns the descriptor, or undefined when absent or the entry is not ready.
|
||||
*/
|
||||
resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined {
|
||||
const entry = this.entries.get(sessionId)
|
||||
if (entry === undefined || entry.state !== 'ready') return undefined
|
||||
return entry.commands.find(c => c.name === name)
|
||||
}
|
||||
|
||||
/** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */
|
||||
invalidateAll(): void {
|
||||
for (const key of this.entries.keys()) void this.refresh(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset on reconnect: every entry drops its snapshot (the agent world
|
||||
* may have changed shape across the generation) and prewarms.
|
||||
*/
|
||||
resetConnected(): void {
|
||||
for (const [key, entry] of this.entries) {
|
||||
entry.state = 'cold'
|
||||
entry.commands = []
|
||||
void this.refresh(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget prewarm of one session (the command source's scope-birth
|
||||
* warm hook lands here).
|
||||
* @param sessionId - session key.
|
||||
*/
|
||||
warm(sessionId: SessionId): void {
|
||||
const entry = this.entry(sessionId)
|
||||
if (entry.state === 'cold' || entry.state === 'failed') void this.refresh(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one pull for one session. Publishes ready/failed only while it is
|
||||
* still the key's latest pull (epoch guard); a ready snapshot is not
|
||||
* demoted while the pull flies.
|
||||
* @param sessionId - session key.
|
||||
* @returns settled when this pull's outcome is published or discarded.
|
||||
*/
|
||||
async refresh(sessionId: SessionId): Promise<void> {
|
||||
const entry = this.entry(sessionId)
|
||||
const epoch = ++entry.epoch
|
||||
if (entry.state !== 'ready') entry.state = 'pending'
|
||||
try {
|
||||
const commands = await this.fetchCommands(sessionId)
|
||||
if (epoch !== entry.epoch) return
|
||||
entry.commands = commands
|
||||
entry.state = 'ready'
|
||||
entry.lastError = undefined
|
||||
} catch (error) {
|
||||
if (epoch !== entry.epoch) return
|
||||
entry.commands = []
|
||||
entry.state = 'failed'
|
||||
entry.lastError = error
|
||||
} finally {
|
||||
if (epoch === entry.epoch) notifyWaiters(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strong-wait until one session's catalog is servable (the enter-
|
||||
* adjudication "directory must be reached" rule): ready returns at once;
|
||||
* cold/failed launch a fresh pull; pending joins the flying one. Rejects
|
||||
* when the awaited pull fails or the signal aborts.
|
||||
* @param sessionId - session key.
|
||||
* @param signal - attempt-scoped abort (the SubmitAttempt signal).
|
||||
* @returns the hot command snapshot.
|
||||
*/
|
||||
async ensureReady(sessionId: SessionId, signal: AbortSignal): Promise<readonly CommandDescriptor[]> {
|
||||
const entry = this.entry(sessionId)
|
||||
while (true) {
|
||||
if (entry.state === 'ready') return entry.commands
|
||||
if (entry.state !== 'pending') void this.refresh(sessionId)
|
||||
await settled(entry, signal)
|
||||
if (entry.state === 'failed') {
|
||||
throw new Error(`command directory warmup failed: ${entry.lastError instanceof Error ? entry.lastError.message : String(entry.lastError)}`)
|
||||
}
|
||||
// Still pending (the awaited pull was superseded) → wait for the winner.
|
||||
}
|
||||
}
|
||||
|
||||
private entry(sessionId: SessionId): Entry {
|
||||
let entry = this.entries.get(sessionId)
|
||||
if (entry === undefined) {
|
||||
entry = new Entry()
|
||||
this.entries.set(sessionId, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
/** One settlement tick for one entry: resolves at the next winning publish, rejects on abort. */
|
||||
function settled(entry: Entry, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(abortReason(signal))
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
entry.waiters = entry.waiters.filter(w => w !== waiter)
|
||||
reject(abortReason(signal))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
entry.waiters.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
function notifyWaiters(entry: Entry): void {
|
||||
const woken = entry.waiters
|
||||
entry.waiters = []
|
||||
for (const wake of woken) wake()
|
||||
}
|
||||
|
||||
/** Normalize an abort into an Error rejection. */
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error ? signal.reason : new Error('command directory wait aborted')
|
||||
}
|
||||
61
packages/client/ui-command/src/client/index.ts
Normal file
61
packages/client/ui-command/src/client/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Command UI plugin, browser half: CommandService (`ctx.command`) owning the
|
||||
* capability-keyed directory cache, the '/' command source, the client
|
||||
* contribution registry, and the per-session popupSelect controllers; the
|
||||
* popupSelect shell self-registers into conversation.input.overlay with
|
||||
* per-session resolution.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the
|
||||
// key's owner) into this program so the overlay registration below typechecks
|
||||
// against the real declaration — no runtime edge to ui-conversation.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CommandService } from './service.ts'
|
||||
import type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
import { PopupSelectView } from './PopupSelectView.tsx'
|
||||
|
||||
export { CommandService } from './service.ts'
|
||||
export { CommandDirectory } from './directory.ts'
|
||||
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
|
||||
export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
} from './contract.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
command: CommandService
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
|
||||
export const inject = ['slash', 'sessions', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register the popupSelect shell
|
||||
* into the input overlay once its declarer is up.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(CommandService)
|
||||
// Conditional mount, same seam as ui-slash's MenuView registration:
|
||||
// 'conversation.input.overlay' is declared by the conversation composer
|
||||
// entry, and the conversation service's presence is the registration-safe
|
||||
// signal that the declaration is on the ledger.
|
||||
ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => {
|
||||
const command = scope.command
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.overlay',
|
||||
id: 'command-popup',
|
||||
order: 1,
|
||||
inject: (sessionId): PopupSelectInjected => {
|
||||
const actx = sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
|
||||
return { popup: command.popupFor(actx) }
|
||||
},
|
||||
}, PopupSelectView), 'ui-command: popupSelect overlay registration')
|
||||
})
|
||||
}
|
||||
251
packages/client/ui-command/src/client/popup.ts
Normal file
251
packages/client/ui-command/src/client/popup.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Headless popupSelect shell state (design §10): one controller per client
|
||||
* session, owned by CommandService's per-session map and torn down by the
|
||||
* session scope disposer. The shell is a transient layer (never in the input
|
||||
* state machine): it loads options once, filters them locally against the
|
||||
* shell's own search text, and settles a selection through the context
|
||||
* captured at open time. Draft consumption and composer focus are injected
|
||||
* callbacks — the session wiring dispatches the consume-token event (the
|
||||
* Input side owns the span/bare-token CAS guard) and focuses the composer;
|
||||
* the controller never touches the input machine.
|
||||
*/
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { SelectOption } from './contract.ts'
|
||||
|
||||
/**
|
||||
* The command token segment snapshotted at shell-open time, replayed to the
|
||||
* injected {@link PopupSelectDeps.consume} callback after a successful
|
||||
* selection. The Input side guards it: a menu-path span consumes iff draftRev
|
||||
* is unchanged, an enter-path line iff the trimmed draft still equals the
|
||||
* bare token.
|
||||
*/
|
||||
export type TokenSegment =
|
||||
| { readonly via: 'menu'; readonly span: TokenSpan }
|
||||
| { readonly via: 'enter'; readonly token: string }
|
||||
|
||||
/**
|
||||
* Structural business spec the shell settles against — the popupSelect half
|
||||
* of CommandUiSpec, generic in the context value the opener captures (the
|
||||
* session wiring passes its session projection; the controller only carries
|
||||
* it from open() to the callbacks).
|
||||
*/
|
||||
export interface PopupSpec<TCtx> {
|
||||
/** Load the option rows once per open (retry after failure reuses the same signal). */
|
||||
options(context: TCtx, signal: AbortSignal): Promise<readonly SelectOption[]>
|
||||
/** Settle the picked option against the open-time context. */
|
||||
onSelect(option: SelectOption, context: TCtx): void | Promise<void>
|
||||
}
|
||||
|
||||
/** Injected session-wiring callbacks of one controller (tests pass fakes). */
|
||||
export interface PopupSelectDeps {
|
||||
/**
|
||||
* Consume the open-time token segment after a successful onSelect (the
|
||||
* wiring dispatches the consume-token event to the opening session).
|
||||
* @param segment - the open-time token segment snapshot.
|
||||
* @returns whether the token was consumed; false (CAS miss) is benign and
|
||||
* never retried.
|
||||
*/
|
||||
consume(segment: TokenSegment): boolean
|
||||
/** Return focus to the session composer (successful settle and Escape close paths). */
|
||||
focusComposer(): void
|
||||
}
|
||||
|
||||
/** Popup shell state (the shell component renders from here; closed = render null). */
|
||||
export interface PopupState {
|
||||
readonly open: boolean
|
||||
/** Command name the shell is open for (null while closed). */
|
||||
readonly command: string | null
|
||||
/** Options-load lifecycle; 'failed' keeps the shell open for retry(). */
|
||||
readonly status: 'pending' | 'ready' | 'failed'
|
||||
/** Options as loaded — never re-fetched per keystroke; views render {@link filterOptions} over them. */
|
||||
readonly options: readonly SelectOption[]
|
||||
/** Local filter text over the loaded options. */
|
||||
readonly search: string
|
||||
/** Highlight index into the filtered row list (0 when empty/pending). */
|
||||
readonly active: number
|
||||
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
|
||||
readonly submitting: boolean
|
||||
/** Surfaced settlement failure (options load or onSelect); null when none. */
|
||||
readonly error: string | null
|
||||
}
|
||||
|
||||
const CLOSED: PopupState = {
|
||||
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter option rows against the shell's local search text (case-insensitive
|
||||
* substring over label and detail; blank search keeps every row).
|
||||
* @param options - the loaded rows.
|
||||
* @param search - the shell's search text.
|
||||
* @returns the rows the shell shows and highlights over.
|
||||
*/
|
||||
export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (query === '') return options
|
||||
return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false))
|
||||
}
|
||||
|
||||
/** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */
|
||||
interface OpenBinding<TCtx> {
|
||||
readonly command: string
|
||||
readonly spec: PopupSpec<TCtx>
|
||||
readonly context: TCtx
|
||||
readonly segment: TokenSegment
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
/** The shell's error-strip line for a settlement failure. */
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless controller of one session's popupSelect shell. Late settlements
|
||||
* lose their write rights through binding identity: dismiss/dispose/reopen
|
||||
* swap the binding, so a settling options fetch or onSelect that no longer
|
||||
* matches writes nothing and consumes nothing.
|
||||
*/
|
||||
export class PopupSelectController<TCtx = unknown> {
|
||||
/** Shell state store (the overlay component subscribes here). */
|
||||
readonly state: SnapshotStore<PopupState> = createSnapshotStore<PopupState>(CLOSED)
|
||||
private binding: OpenBinding<TCtx> | null = null
|
||||
|
||||
/**
|
||||
* @param deps - session-wiring callbacks (token consumption + composer focus).
|
||||
*/
|
||||
constructor(private readonly deps: PopupSelectDeps) {}
|
||||
|
||||
/**
|
||||
* Open the shell for one command: publish pending state and fetch options
|
||||
* once through the business spec. A reopen supersedes the previous shell
|
||||
* (its options fetch is aborted, its late settlements are dropped).
|
||||
* @param command - command name the shell serves.
|
||||
* @param spec - the registered popupSelect spec.
|
||||
* @param context - open-time context snapshot, handed verbatim to options/onSelect.
|
||||
* @param segment - open-time token segment snapshot for post-select consumption.
|
||||
*/
|
||||
open(command: string, spec: PopupSpec<TCtx>, context: TCtx, segment: TokenSegment): void {
|
||||
this.binding?.abort.abort()
|
||||
const binding: OpenBinding<TCtx> = { command, spec, context, segment, abort: new AbortController() }
|
||||
this.binding = binding
|
||||
this.state.set({ ...CLOSED, open: true, command })
|
||||
this.load(binding)
|
||||
}
|
||||
|
||||
/** Run the one options fetch of a binding; settlement rights die with the binding. */
|
||||
private load(binding: OpenBinding<TCtx>): void {
|
||||
binding.spec.options(binding.context, binding.abort.signal).then(
|
||||
(options) => {
|
||||
if (this.binding !== binding) return
|
||||
this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.binding !== binding) return
|
||||
console.error(`[ui-command] popupSelect options failed for /${binding.command}:`, error)
|
||||
this.state.set({ ...this.state.getSnapshot(), status: 'failed', options: [], active: 0, error: errorText(error) })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-run a failed options fetch (search survives; no-op unless status is 'failed'). */
|
||||
retry(): void {
|
||||
const binding = this.binding
|
||||
const s = this.state.getSnapshot()
|
||||
if (binding === null || !s.open || s.status !== 'failed') return
|
||||
this.state.set({ ...s, status: 'pending', error: null })
|
||||
this.load(binding)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the local search text (pure local filter — the provider is never
|
||||
* re-queried) and rebase the highlight onto the new filtered list.
|
||||
* @param search - the shell search input's text.
|
||||
*/
|
||||
setSearch(search: string): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.submitting || search === s.search) return
|
||||
this.state.set({ ...s, search, active: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the highlight across the filtered rows (wraps around; no-op unless
|
||||
* options are ready and no selection is in flight).
|
||||
* @param dir - +1 down, -1 up.
|
||||
*/
|
||||
move(dir: 1 | -1): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.status !== 'ready' || s.submitting) return
|
||||
const rows = filterOptions(s.options, s.search)
|
||||
if (rows.length === 0) return
|
||||
const active = (s.active + dir + rows.length) % rows.length
|
||||
this.state.set({ ...s, active })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the highlight directly (pointer hover; no-op unless ready, idle, and
|
||||
* in filtered range).
|
||||
* @param index - filtered-row index.
|
||||
*/
|
||||
highlight(index: number): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.status !== 'ready' || s.submitting) return
|
||||
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
|
||||
this.state.set({ ...s, active: index })
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one filtered row: single-flight — the first call enters
|
||||
* `submitting` and later calls no-op until it settles. Success consumes the
|
||||
* open-time token segment (a false CAS answer is benign), closes, and
|
||||
* returns focus to the composer. Failure keeps the shell open with search,
|
||||
* highlight, and token intact, surfaces the error, and re-arms select as
|
||||
* the retry.
|
||||
* @param index - filtered-row index (callers pass the highlight or the clicked row).
|
||||
* @returns settled when the attempt has closed the shell or surfaced its failure.
|
||||
*/
|
||||
async select(index: number): Promise<void> {
|
||||
const binding = this.binding
|
||||
const s = this.state.getSnapshot()
|
||||
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
|
||||
const option = filterOptions(s.options, s.search)[index]
|
||||
if (option === undefined) return
|
||||
this.state.set({ ...s, submitting: true, error: null })
|
||||
try {
|
||||
await binding.spec.onSelect(option, binding.context)
|
||||
} catch (error) {
|
||||
console.error(`[ui-command] popupSelect onSelect failed for /${binding.command}:`, error)
|
||||
if (this.binding !== binding) return // dismissed/reopened/disposed while onSelect flew
|
||||
this.state.set({ ...this.state.getSnapshot(), submitting: false, error: errorText(error) })
|
||||
return
|
||||
}
|
||||
if (this.binding !== binding) return // late success: no state write, no consumption
|
||||
this.deps.consume(binding.segment)
|
||||
this.binding = null
|
||||
this.state.set(CLOSED)
|
||||
this.deps.focusComposer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the shell; aborts a flying options fetch and revokes settlement
|
||||
* rights. An outside pointer interaction dismisses plainly (the click's own
|
||||
* target takes focus); Escape passes focusComposer to return focus explicitly.
|
||||
* @param opts - focusComposer: also restore composer focus (Escape path).
|
||||
*/
|
||||
dismiss(opts?: { readonly focusComposer?: boolean }): void {
|
||||
if (this.binding === null) return
|
||||
this.binding.abort.abort()
|
||||
this.binding = null
|
||||
this.state.set(CLOSED)
|
||||
if (opts?.focusComposer === true) this.deps.focusComposer()
|
||||
}
|
||||
|
||||
/** Scope-teardown disposer: abort in-flight work and clear state (no focus side effect). */
|
||||
dispose(): void {
|
||||
this.binding?.abort.abort()
|
||||
this.binding = null
|
||||
this.state.set(CLOSED)
|
||||
}
|
||||
}
|
||||
293
packages/client/ui-command/src/client/service.ts
Normal file
293
packages/client/ui-command/src/client/service.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* CommandService (`ctx.command`): the '/' command source over the
|
||||
* session-keyed directory, the client-contribution registry, and the
|
||||
* per-session popupSelect controllers. Candidate synthesis merges the host
|
||||
* catalog with contributions by availability, then query/position filtering;
|
||||
* a host/contribution name collision fails loud. Every execute addresses the
|
||||
* session's agent by sessionId — sessions are always agent-backed.
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the notice route reads ctx.conversation.input — no runtime edge.
|
||||
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SlashServiceContract, SubmitOutcome,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandDescriptor } from './directory.ts'
|
||||
import { CommandDirectory } from './directory.ts'
|
||||
import { PopupSelectController } from './popup.ts'
|
||||
import type { TokenSegment } from './popup.ts'
|
||||
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
readonly contributions: Map<string, CommandContribution>
|
||||
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
|
||||
}
|
||||
|
||||
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
|
||||
export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (plugin fiber; the service registers
|
||||
* itself as `command` and follows that fiber's lifetime).
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'command')
|
||||
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
|
||||
this.directory = new CommandDirectory(async (sessionId) => {
|
||||
const { result } = await connection.api.commands.list({ sessionId })
|
||||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.commands
|
||||
})
|
||||
const slash = ctx.get('slash') as SlashServiceContract | undefined
|
||||
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
|
||||
ctx.effect(() => slash.registerSource({
|
||||
trigger: '/',
|
||||
name: 'command',
|
||||
candidates: (session, req) => this.candidates(session, req),
|
||||
onPick: pick => this.dispatch(pick),
|
||||
matchSpace: (session, token) => this.matchSpace(session, token),
|
||||
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
|
||||
ctx.on('connection/reset', () => { this.directory.resetConnected() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one client command contribution; effect disposer (rides the
|
||||
* caller's fiber). Duplicate names throw.
|
||||
* @param contribution - the contribution (descriptor + availability + popup spec).
|
||||
* @returns the disposer removing the registration.
|
||||
*/
|
||||
register(contribution: CommandContribution): () => void {
|
||||
return this.ctx.effect(() => {
|
||||
const { contributions } = this.live
|
||||
if (contributions.has(contribution.name)) {
|
||||
throw new Error(`ui-command: duplicate contribution for /${contribution.name}`)
|
||||
}
|
||||
contributions.set(contribution.name, contribution)
|
||||
return () => { contributions.delete(contribution.name) }
|
||||
}, 'command.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-session popup controller (lazy; dies with the session
|
||||
* scope). The controller's consume callback dispatches the scoped
|
||||
* consume-token event back to this session; focusComposer reaches the
|
||||
* composer through the overlay slot currency.
|
||||
* @param actx - session-scope ctx.
|
||||
* @returns the resident controller.
|
||||
*/
|
||||
popupFor(actx: ClientContext): PopupSelectController<ClientSessionContext> {
|
||||
const sessions = this.sessions()
|
||||
const id = sessions.scopeOf(actx)
|
||||
if (id === undefined) throw new Error('command.popupFor requires a session scope')
|
||||
const { popups } = this.live
|
||||
const existing = popups.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
const controller = new PopupSelectController<ClientSessionContext>({
|
||||
consume: segment => actx.bail(actx, 'slash/input-consume-token', {
|
||||
guard: segment.via === 'menu'
|
||||
? { kind: 'span', span: segment.span }
|
||||
: { kind: 'bare-token', token: segment.token },
|
||||
}) === true,
|
||||
focusComposer: () => { this.focusHooks.get(id)?.() },
|
||||
})
|
||||
popups.set(id, controller)
|
||||
actx.effect(() => () => {
|
||||
controller.dispose()
|
||||
popups.delete(id)
|
||||
this.focusHooks.delete(id)
|
||||
}, 'command: session popup')
|
||||
return controller
|
||||
}
|
||||
|
||||
/** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
|
||||
private readonly focusHooks = new Map<SessionId, () => void>()
|
||||
|
||||
/**
|
||||
* Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
|
||||
* @param id - session id.
|
||||
* @param focus - textarea focus callback.
|
||||
* @returns the unbind disposer.
|
||||
*/
|
||||
bindComposerFocus(id: SessionId, focus: () => void): () => void {
|
||||
this.focusHooks.set(id, focus)
|
||||
return () => {
|
||||
if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Menu candidates: host catalog + contribution availability, then query/position filtering. */
|
||||
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
|
||||
const list = await this.directory.ensureReady(session.sessionId, req.signal)
|
||||
const rows: SlashCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const c of list) {
|
||||
seen.add(c.name)
|
||||
rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) })
|
||||
}
|
||||
for (const contribution of this.live.contributions.values()) {
|
||||
if (!contribution.available(session)) continue
|
||||
if (seen.has(contribution.name)) {
|
||||
throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`)
|
||||
}
|
||||
rows.push({ name: contribution.name, description: contribution.description })
|
||||
}
|
||||
return rows
|
||||
.filter(c => c.name.startsWith(req.query))
|
||||
.filter(c => req.position === 'leading' || c.hint === undefined)
|
||||
}
|
||||
|
||||
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
|
||||
private dispatch(pick: SlashPick): PickOutcome {
|
||||
const name = pick.candidate.name
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(pick.session)) {
|
||||
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
|
||||
return 'handled'
|
||||
}
|
||||
const desc = this.directory.resolve(pick.session.sessionId, name)
|
||||
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
|
||||
// Menu-pick execute consumes the trigger span before the detached run
|
||||
// (scoped event; the input owns the CAS guard).
|
||||
this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
|
||||
this.runDetached(desc, pick.session, `/${name}`)
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
/** Decision table, space column: hot-key sync check; only host leadingInput claims. */
|
||||
private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
|
||||
if (!token.startsWith('/')) return undefined
|
||||
const name = token.slice(1)
|
||||
if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
|
||||
const desc = this.directory.resolve(session.sessionId, name)
|
||||
if (desc === undefined || desc.input === undefined) return undefined
|
||||
return { claim: this.leadingClaim(desc, session) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision table, enter column. Strong-waits the session's catalog (a
|
||||
* warmup failure rejects — never a silent downgrade). Contributions and
|
||||
* bare host commands act on the bare token only; leadingInput claims
|
||||
* args-tolerant.
|
||||
*/
|
||||
private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise<PickOutcome> {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('/')) return undefined
|
||||
const ws = trimmed.search(/\s/)
|
||||
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
|
||||
const bare = ws === -1
|
||||
const name = token.slice(1)
|
||||
if (name === '') return undefined
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(session)) {
|
||||
if (!bare) return undefined
|
||||
this.openPopup(contribution, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
await this.directory.ensureReady(session.sessionId, signal)
|
||||
const desc = this.directory.resolve(session.sessionId, name)
|
||||
if (desc === undefined) return undefined
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
|
||||
if (!bare) return undefined
|
||||
this.consumeVia(session.sessionId, { via: 'enter', token })
|
||||
this.runDetached(desc, session, trimmed)
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
/** Open the session's popup for one contribution (menu pick / bare enter). */
|
||||
private openPopup(
|
||||
contribution: CommandContribution,
|
||||
session: ClientSessionContext,
|
||||
segment: TokenSegment,
|
||||
): void {
|
||||
const actx = this.scopeFor(session.sessionId)
|
||||
if (actx === undefined) return
|
||||
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
|
||||
}
|
||||
|
||||
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
|
||||
private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
|
||||
const token = `/${desc.name} `
|
||||
return {
|
||||
token,
|
||||
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
|
||||
submit: (args, _actx) => this.execute(session, token + args),
|
||||
}
|
||||
}
|
||||
|
||||
/** The command.execute transaction, addressed to the session's agent. */
|
||||
private async execute(
|
||||
session: ClientSessionContext,
|
||||
line: string,
|
||||
): Promise<SubmitOutcome> {
|
||||
const connection = this.ctx.get('connection') as ConnectionHandle
|
||||
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
const detached = result.value.result
|
||||
return detached === undefined
|
||||
? { kind: 'success' }
|
||||
: { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget execute for the internal ('handled') paths. The detached
|
||||
* result surfaces as a notice routed to the triggering session's composer,
|
||||
* so a late result lands on its own session after a switch.
|
||||
*/
|
||||
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
|
||||
void this.execute(session, line).then(
|
||||
(outcome) => {
|
||||
if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`)
|
||||
else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text)
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Dispatch a consume-token event to one session (menu-pick / bare-enter execute paths). */
|
||||
private consumeVia(id: SessionId, segment: TokenSegment): void {
|
||||
const actx = this.scopeFor(id)
|
||||
if (actx === undefined) return
|
||||
actx.bail(actx, 'slash/input-consume-token', {
|
||||
guard: segment.via === 'menu'
|
||||
? { kind: 'span', span: segment.span }
|
||||
: { kind: 'bare-token', token: segment.token },
|
||||
})
|
||||
}
|
||||
|
||||
/** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */
|
||||
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
|
||||
const actx = this.scopeFor(id)
|
||||
if (actx === undefined) return
|
||||
const conversation = actx.get('conversation') as ConversationService | undefined
|
||||
if (conversation === undefined) return
|
||||
conversation.input.for(actx).notify(level, text)
|
||||
}
|
||||
|
||||
/** id → actx interchange (registered exchange point: this service coordinates for projection-only sources). */
|
||||
private scopeFor(id: SessionId): ClientContext | undefined {
|
||||
return this.sessions().scope(id)
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
6
packages/client/ui-command/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-command/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-command/src/index.ts
Normal file
10
packages/client/ui-command/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 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 {}
|
||||
31
packages/client/ui-command/src/invariant.ts
Normal file
31
packages/client/ui-command/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-command`.
|
||||
* @module @deepseek-ai/dsh-client-ui-command/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-command'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-command-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a browser-side source over the wire command
|
||||
* directory — it emits no cordis events and owns no cross-plugin mutable
|
||||
* state; dispatch and cache behavior are asserted by this package's specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
83
packages/client/ui-command/tests/browser-plugin.spec.ts
Normal file
83
packages/client/ui-command/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ui-command browser half on a real cordis Context with fake slash/slots
|
||||
* faces and real session scopes: the plugin body mounts CommandService as
|
||||
* `command`, the popupSelect shell registers into conversation.input.overlay
|
||||
* once the conversation seam is up with a per-session inject (sessionId →
|
||||
* scope → popupFor; unknown id fails loud), both fold up on fiber disposal
|
||||
* (HMR safety), and the service satisfies the frozen CommandServiceContract.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandServiceContract } from '../src/client/contract.ts'
|
||||
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
|
||||
import { apply, CommandService, inject } from '../src/client/index.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const sources = new Map<string, SlashSource>()
|
||||
const overlays = new Map<string, { inject: unknown }>()
|
||||
ctx.provide('slash', {
|
||||
registerSource(src: SlashSource) {
|
||||
sources.set(`${src.trigger} ${src.name}`, src)
|
||||
return () => { sources.delete(`${src.trigger} ${src.name}`) }
|
||||
},
|
||||
})
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } })
|
||||
ctx.provide('slots', {
|
||||
register(options: { name: string; id?: string; inject?: unknown }) {
|
||||
const key = `${options.name}#${options.id ?? ''}`
|
||||
overlays.set(key, { inject: options.inject })
|
||||
return () => { overlays.delete(key) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
const handle = createScope(ctx, sid(key))
|
||||
scopes.set(sid(key), handle.ctx)
|
||||
return handle
|
||||
}
|
||||
return { ctx, fiber, sources, overlays, mint }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection'])
|
||||
})
|
||||
|
||||
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
const { ctx, fiber, sources, overlays } = await bench()
|
||||
const command = ctx.get('command')
|
||||
expect(command).toBeInstanceOf(CommandService)
|
||||
// Frozen-contract conformance (compile-time check rides the assignment).
|
||||
const contract: CommandServiceContract = command as CommandService
|
||||
expect(contract.register).toBeTypeOf('function')
|
||||
expect(contract.popupFor).toBeTypeOf('function')
|
||||
expect([...sources.keys()]).toEqual(['/ command'])
|
||||
expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
|
||||
await fiber.dispose()
|
||||
expect(sources.size).toBe(0)
|
||||
expect(overlays.size).toBe(0)
|
||||
})
|
||||
|
||||
it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
|
||||
const { ctx, overlays, mint } = await bench()
|
||||
const command = ctx.get('command') as CommandService
|
||||
const scope = mint('s1')
|
||||
const entry = overlays.get('conversation.input.overlay#command-popup')!
|
||||
const injectEntry = entry.inject as (sessionId: SessionId) => PopupSelectInjected
|
||||
expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
|
||||
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
})
|
||||
293
packages/client/ui-command/tests/directory.spec.ts
Normal file
293
packages/client/ui-command/tests/directory.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* CommandDirectory unit tests over the session-key axis: per-key status
|
||||
* transitions and epoch guard, key isolation across sessions, soft
|
||||
* invalidation (invalidateAll), the reconnect hard reset (resetConnected:
|
||||
* every entry drops its snapshot and prewarms), the warm hook's cold/failed
|
||||
* gate, and the per-key ensureReady strong-wait policy.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandDirectory } from '../src/client/directory.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
const S1 = sid('s1')
|
||||
const S2 = sid('s2')
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const CMDS: CommandDescriptor[] = [
|
||||
{ name: 'plan', description: 'plan mode' },
|
||||
{ name: 'goal', description: 'set goal', input: { hint: 'goal text' } },
|
||||
]
|
||||
|
||||
const S2_CMDS: CommandDescriptor[] = [
|
||||
...CMDS,
|
||||
{ name: 'attach', description: 'attach a file', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
/** Directory over per-key pull queues: each fetch appends a hand-settled deferred. */
|
||||
function bench() {
|
||||
const pulls = new Map<SessionId, Array<ReturnType<typeof deferred<readonly CommandDescriptor[]>>>>()
|
||||
const calls: SessionId[] = []
|
||||
const dir = new CommandDirectory((key) => {
|
||||
calls.push(key)
|
||||
const d = deferred<readonly CommandDescriptor[]>()
|
||||
const queue = pulls.get(key) ?? []
|
||||
queue.push(d)
|
||||
pulls.set(key, queue)
|
||||
return d.promise
|
||||
})
|
||||
const pull = (key: SessionId, i: number) => {
|
||||
const d = pulls.get(key)?.[i]
|
||||
if (d === undefined) throw new Error(`no pull #${i} for ${key}`)
|
||||
return d
|
||||
}
|
||||
return { dir, pull, calls, countOf: (key: SessionId) => pulls.get(key)?.length ?? 0 }
|
||||
}
|
||||
|
||||
describe('status and resolve (per key)', () => {
|
||||
it('starts cold and resolves nothing', () => {
|
||||
const { dir } = bench()
|
||||
expect(dir.status(S1)).toBe('cold')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serves exact-name lookups once ready, undefined for unknown names', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const refreshed = dir.refresh(S1)
|
||||
expect(dir.status(S1)).toBe('pending')
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await refreshed
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S1, 'goal')).toEqual(CMDS[1])
|
||||
expect(dir.resolve(S1, 'nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the snapshot and records failure on a failed pull', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const refreshed = dir.refresh(S1)
|
||||
pull(S1, 0).reject(new Error('boom'))
|
||||
await refreshed
|
||||
expect(dir.status(S1)).toBe('failed')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keys are isolated: one session catalog landing leaves another cold', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const refreshed = dir.refresh(S1)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await refreshed
|
||||
expect(dir.status(S2)).toBe('cold')
|
||||
expect(dir.resolve(S2, 'plan')).toBeUndefined()
|
||||
|
||||
const other = dir.refresh(S2)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await other
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
expect(dir.resolve(S1, 'attach')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('epoch guard (per key)', () => {
|
||||
it('a superseded pull cannot overwrite the newer one (old resolves after new)', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const first = dir.refresh(S1)
|
||||
const second = dir.refresh(S1)
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await second
|
||||
expect(dir.resolve(S1, 'plan')).toBeDefined()
|
||||
pull(S1, 0).resolve([{ name: 'stale', description: 'old world' }])
|
||||
await first
|
||||
expect(dir.resolve(S1, 'stale')).toBeUndefined()
|
||||
expect(dir.resolve(S1, 'plan')).toBeDefined()
|
||||
})
|
||||
|
||||
it('a superseded failure cannot demote the newer success', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const first = dir.refresh(S1)
|
||||
const second = dir.refresh(S1)
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await second
|
||||
pull(S1, 0).reject(new Error('late failure'))
|
||||
await first
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S1, 'plan')).toBeDefined()
|
||||
})
|
||||
|
||||
it('epochs are per key: one session supersede leaves another session epoch alone', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const one = dir.refresh(S1)
|
||||
void dir.refresh(S2)
|
||||
void dir.refresh(S2) // supersedes the s2 pull only
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await one
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateAll (commands-changed soft)', () => {
|
||||
it('repulls every touched key in the background while ready snapshots keep serving', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const a = dir.refresh(S1)
|
||||
const b = dir.refresh(S2)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await Promise.all([a, b])
|
||||
|
||||
dir.invalidateAll()
|
||||
expect(countOf(S1)).toBe(2)
|
||||
expect(countOf(S2)).toBe(2)
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
|
||||
pull(S1, 1).resolve([{ name: 'fresh', description: 'new world' }])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.resolve(S1, 'fresh')).toBeDefined()
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an untouched directory invalidates to nothing (no keys, no pulls)', () => {
|
||||
const { dir, calls } = bench()
|
||||
dir.invalidateAll()
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetConnected (reconnect hard)', () => {
|
||||
it('every entry drops its snapshot immediately and prewarms', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const a = dir.refresh(S1)
|
||||
const b = dir.refresh(S2)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await Promise.all([a, b])
|
||||
|
||||
dir.resetConnected()
|
||||
// Hard: the agent world may have changed shape across the generation.
|
||||
expect(dir.status(S1)).toBe('pending')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
expect(dir.status(S2)).toBe('pending')
|
||||
expect(dir.resolve(S2, 'attach')).toBeUndefined()
|
||||
expect(countOf(S1)).toBe(2)
|
||||
expect(countOf(S2)).toBe(2)
|
||||
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
pull(S2, 1).resolve(S2_CMDS)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('warm', () => {
|
||||
it('launches a pull from cold, again after failure, and never over pending/ready', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
dir.warm(S1)
|
||||
expect(countOf(S1)).toBe(1)
|
||||
dir.warm(S1) // pending → no second pull
|
||||
expect(countOf(S1)).toBe(1)
|
||||
|
||||
pull(S1, 0).reject(new Error('boom'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.status(S1)).toBe('failed')
|
||||
dir.warm(S1) // failed → retry
|
||||
expect(countOf(S1)).toBe(2)
|
||||
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
dir.warm(S1) // ready → no-op
|
||||
expect(countOf(S1)).toBe(2)
|
||||
})
|
||||
|
||||
it('warms keys independently', () => {
|
||||
const { dir, countOf } = bench()
|
||||
dir.warm(S2)
|
||||
expect(countOf(S2)).toBe(1)
|
||||
expect(countOf(S1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureReady (per key)', () => {
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
it('returns the hot snapshot at once when ready', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const warm = dir.refresh(S1)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await warm
|
||||
await expect(dir.ensureReady(S1, signal())).resolves.toEqual(CMDS)
|
||||
expect(countOf(S1)).toBe(1)
|
||||
})
|
||||
|
||||
it('launches a pull from cold and resolves on arrival, without touching other keys', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const wait = dir.ensureReady(S2, signal())
|
||||
expect(dir.status(S2)).toBe('pending')
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await expect(wait).resolves.toEqual(S2_CMDS)
|
||||
expect(countOf(S1)).toBe(0)
|
||||
})
|
||||
|
||||
it('joins a flying pull instead of starting a second one', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
void dir.refresh(S1)
|
||||
const wait = dir.ensureReady(S1, signal())
|
||||
expect(countOf(S1)).toBe(1)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await expect(wait).resolves.toEqual(CMDS)
|
||||
})
|
||||
|
||||
it('rejects when the awaited pull fails (no silent downgrade)', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const wait = dir.ensureReady(S1, signal())
|
||||
pull(S1, 0).reject(new Error('warmup boom'))
|
||||
await expect(wait).rejects.toThrow('command directory warmup failed: warmup boom')
|
||||
})
|
||||
|
||||
it('retries from failed state with a fresh pull', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const first = dir.ensureReady(S1, signal())
|
||||
pull(S1, 0).reject(new Error('boom'))
|
||||
await expect(first).rejects.toThrow()
|
||||
const second = dir.ensureReady(S1, signal())
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await expect(second).resolves.toEqual(CMDS)
|
||||
})
|
||||
|
||||
it('rejects on abort while waiting', async () => {
|
||||
const { dir } = bench()
|
||||
const ac = new AbortController()
|
||||
const wait = dir.ensureReady(S1, ac.signal)
|
||||
ac.abort(new Error('attempt superseded'))
|
||||
await expect(wait).rejects.toThrow('attempt superseded')
|
||||
})
|
||||
|
||||
it('rejects immediately on an already-aborted signal', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const warm = dir.refresh(S1)
|
||||
pull(S1, 0).reject(new Error('irrelevant'))
|
||||
await warm
|
||||
const ac = new AbortController()
|
||||
ac.abort() // bare abort: the DOMException reason is itself an Error and travels as-is
|
||||
await expect(dir.ensureReady(S1, ac.signal)).rejects.toThrow(/aborted/)
|
||||
})
|
||||
|
||||
it('keeps waiting across a superseded pull and settles on the winner', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const wait = dir.ensureReady(S1, signal())
|
||||
void dir.refresh(S1) // supersedes pull #0 with pull #1
|
||||
pull(S1, 0).resolve([{ name: 'stale', description: 'loser' }])
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await expect(wait).resolves.toEqual(CMDS)
|
||||
})
|
||||
})
|
||||
174
packages/client/ui-command/tests/popup-view.spec.tsx
Normal file
174
packages/client/ui-command/tests/popup-view.spec.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* PopupSelectView interaction spec (design §10.2): the search input takes
|
||||
* focus on open and plain typing filters locally, ↑↓ move the filtered
|
||||
* highlight while ←→ stay native to the input, Enter selects single-flight,
|
||||
* Escape dismisses back through focusComposer, outside pointerdown dismisses
|
||||
* plainly, and the submitting/failed states render pending text and a
|
||||
* working retry button.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SelectOption } from '../src/client/contract.ts'
|
||||
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
|
||||
import { PopupSelectController } from '../src/client/popup.ts'
|
||||
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const OPTIONS: SelectOption[] = [
|
||||
{ id: 'dark', label: 'Dark' },
|
||||
{ id: 'light', label: 'Light', active: true },
|
||||
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
|
||||
]
|
||||
|
||||
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
|
||||
|
||||
function spec(overrides: Partial<PopupSpec<string>> = {}): PopupSpec<string> {
|
||||
return {
|
||||
options: () => Promise.resolve(OPTIONS),
|
||||
onSelect: () => undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResult = true) {
|
||||
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
|
||||
const focusComposer = vi.fn()
|
||||
const popup = new PopupSelectController<string>({ consume, focusComposer })
|
||||
const view = render(<PopupSelectView popup={popup} />)
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) }
|
||||
}
|
||||
|
||||
function rowLabels(): string[] {
|
||||
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!)
|
||||
}
|
||||
|
||||
describe('PopupSelectView', () => {
|
||||
it('renders null while closed, opens with focus in the search input', async () => {
|
||||
const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
|
||||
const view = render(<PopupSelectView popup={popup} />)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
const search = screen.getByRole('textbox', { name: 'Filter options' })
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
|
||||
})
|
||||
|
||||
it('typing filters rows locally and rebases the highlight', async () => {
|
||||
const options = vi.fn(() => Promise.resolve(OPTIONS))
|
||||
const { search } = await mountOpen({ options })
|
||||
act(() => { fireEvent.change(search, { target: { value: 'li' } }) })
|
||||
expect(rowLabels()).toEqual(['Light'])
|
||||
expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('true')
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
|
||||
expect(screen.queryByRole('option')).toBeNull()
|
||||
expect(screen.queryByText('No options')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
|
||||
const { search } = await mountOpen()
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
|
||||
let options = screen.getAllByRole('option')
|
||||
expect(options[1]!.getAttribute('aria-selected')).toBe('true')
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) })
|
||||
options = screen.getAllByRole('option')
|
||||
expect(options[0]!.getAttribute('aria-selected')).toBe('true')
|
||||
// fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted.
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
|
||||
})
|
||||
|
||||
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
|
||||
const seen: Array<{ option: SelectOption; context: string }> = []
|
||||
const { view, search, consume, focusComposer } = await mountOpen({
|
||||
onSelect: (option, context) => { seen.push({ option, context }) },
|
||||
})
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }])
|
||||
expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('click selects a row; mouseenter moves the highlight', async () => {
|
||||
const seen: SelectOption[] = []
|
||||
const { view } = await mountOpen({ onSelect: (option) => { seen.push(option) } })
|
||||
const options = screen.getAllByRole('option')
|
||||
act(() => { fireEvent.mouseEnter(options[2]!) })
|
||||
expect(screen.getAllByRole('option')[2]!.getAttribute('aria-selected')).toBe('true')
|
||||
await act(async () => { fireEvent.click(options[2]!) })
|
||||
expect(seen).toEqual([OPTIONS[2]])
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
|
||||
let release!: () => void
|
||||
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
|
||||
const { search, consume } = await mountOpen({ onSelect })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(screen.queryByText('Applying…')).not.toBeNull()
|
||||
expect((search as HTMLInputElement).readOnly).toBe(true)
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(search, { key: 'Enter' })
|
||||
fireEvent.click(screen.getAllByRole('option')[1]!)
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
release()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(consume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a failed options load shows the error with a Retry button that reloads', async () => {
|
||||
let attempts = 0
|
||||
await mountOpen({
|
||||
options: () => {
|
||||
attempts += 1
|
||||
return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toContain('directory down')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(attempts).toBe(2)
|
||||
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
|
||||
})
|
||||
|
||||
it('an onSelect failure keeps the shell open with the error strip and no retry button (re-select is the retry)', async () => {
|
||||
const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(screen.getByRole('alert').textContent).toContain('host rejected')
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
expect(consume).not.toHaveBeenCalled()
|
||||
expect(screen.getAllByRole('option').length).toBe(3)
|
||||
})
|
||||
|
||||
it('Escape dismisses and restores composer focus', async () => {
|
||||
const { view, search, focusComposer } = await mountOpen()
|
||||
act(() => { fireEvent.keyDown(search, { key: 'Escape' }) })
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('an outside pointerdown dismisses without focusComposer; an inside one does not dismiss', async () => {
|
||||
const { view, focusComposer } = await mountOpen()
|
||||
act(() => { fireEvent.pointerDown(screen.getAllByRole('option')[0]!) })
|
||||
expect(view.container.childElementCount).not.toBe(0)
|
||||
act(() => { fireEvent.pointerDown(document.body) })
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
expect(focusComposer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
356
packages/client/ui-command/tests/popup.spec.ts
Normal file
356
packages/client/ui-command/tests/popup.spec.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* PopupSelectController behavior (design §10.2/§10.3): one options load per
|
||||
* open with local search filtering, filtered highlight movement,
|
||||
* single-flight select with open-time context, consume-on-success (CAS miss
|
||||
* benign), failure-keeps-open retry semantics for both options and onSelect,
|
||||
* and binding-identity revocation of late settlements after
|
||||
* dismiss/reopen/dispose.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SelectOption } from '../src/client/contract.ts'
|
||||
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
|
||||
import { filterOptions, PopupSelectController } from '../src/client/popup.ts'
|
||||
|
||||
interface Ctx { readonly session: string }
|
||||
const CTX_A: Ctx = { session: 'A' }
|
||||
|
||||
const OPTIONS: SelectOption[] = [
|
||||
{ id: 'dark', label: 'Dark' },
|
||||
{ id: 'light', label: 'Light', active: true },
|
||||
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
|
||||
]
|
||||
|
||||
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
|
||||
|
||||
function spec(overrides: Partial<PopupSpec<Ctx>> = {}): PopupSpec<Ctx> {
|
||||
return {
|
||||
options: () => Promise.resolve(OPTIONS),
|
||||
onSelect: () => undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake session wiring: records consume/focus calls; consume answer is settable per test. */
|
||||
function makeDeps(consumeResult = true) {
|
||||
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
|
||||
const focusComposer = vi.fn()
|
||||
return { consume, focusComposer }
|
||||
}
|
||||
|
||||
async function readyPopup(overrides: Partial<PopupSpec<Ctx>> = {}, deps = makeDeps()) {
|
||||
const popup = new PopupSelectController<Ctx>(deps)
|
||||
popup.open('theme', spec(overrides), CTX_A, SEGMENT)
|
||||
await Promise.resolve()
|
||||
return { popup, deps }
|
||||
}
|
||||
|
||||
describe('filterOptions', () => {
|
||||
it('matches case-insensitively over label and detail; blank keeps all', () => {
|
||||
expect(filterOptions(OPTIONS, '')).toBe(OPTIONS)
|
||||
expect(filterOptions(OPTIONS, ' ')).toBe(OPTIONS)
|
||||
expect(filterOptions(OPTIONS, 'DARK')).toEqual([OPTIONS[0]])
|
||||
expect(filterOptions(OPTIONS, 'warm')).toEqual([OPTIONS[2]])
|
||||
expect(filterOptions(OPTIONS, 'nope')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('open and options load', () => {
|
||||
it('publishes pending immediately, ready when options land', async () => {
|
||||
const popup = new PopupSelectController<Ctx>(makeDeps())
|
||||
let release!: (options: readonly SelectOption[]) => void
|
||||
popup.open('theme', spec({ options: () => new Promise((resolve) => { release = resolve }) }), CTX_A, SEGMENT)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme', status: 'pending', search: '', submitting: false, error: null })
|
||||
release(OPTIONS)
|
||||
await Promise.resolve()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 0 })
|
||||
})
|
||||
|
||||
it('loads options exactly once: search filters locally without re-querying the provider', async () => {
|
||||
const options = vi.fn(() => Promise.resolve(OPTIONS))
|
||||
const { popup } = await readyPopup({ options })
|
||||
popup.setSearch('li')
|
||||
popup.setSearch('light')
|
||||
const s = popup.state.getSnapshot()
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
expect(s.options).toEqual(OPTIONS) // original array retained; filtering is view-side
|
||||
expect(s.search).toBe('light')
|
||||
expect(filterOptions(s.options, s.search)).toEqual([OPTIONS[1]])
|
||||
})
|
||||
|
||||
it('a reopen aborts the old load and drops its late arrival', async () => {
|
||||
const popup = new PopupSelectController<Ctx>(makeDeps())
|
||||
let firstSignal!: AbortSignal
|
||||
let releaseFirst!: (options: readonly SelectOption[]) => void
|
||||
popup.open('alpha', spec({
|
||||
options: (_ctx, signal) => {
|
||||
firstSignal = signal
|
||||
return new Promise((resolve) => { releaseFirst = resolve })
|
||||
},
|
||||
}), CTX_A, SEGMENT)
|
||||
popup.open('beta', spec(), CTX_A, SEGMENT)
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
releaseFirst([{ id: 'stale', label: 'stale' }])
|
||||
await Promise.resolve()
|
||||
const s = popup.state.getSnapshot()
|
||||
expect(s.command).toBe('beta')
|
||||
expect(s.options).toEqual(OPTIONS)
|
||||
})
|
||||
|
||||
it('dispose aborts the flying load, clears state, and drops the late arrival', async () => {
|
||||
const popup = new PopupSelectController<Ctx>(makeDeps())
|
||||
let signal!: AbortSignal
|
||||
let release!: (options: readonly SelectOption[]) => void
|
||||
popup.open('theme', spec({
|
||||
options: (_ctx, s) => {
|
||||
signal = s
|
||||
return new Promise((resolve) => { release = resolve })
|
||||
},
|
||||
}), CTX_A, SEGMENT)
|
||||
popup.dispose()
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
release(OPTIONS)
|
||||
await Promise.resolve()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an options failure keeps the shell open with search retained, surfaces the error, and retry reloads', async () => {
|
||||
let attempts = 0
|
||||
const { popup } = await readyPopup({
|
||||
options: () => {
|
||||
attempts += 1
|
||||
return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
popup.setSearch('da')
|
||||
// The failure landed before setSearch (readyPopup awaited); search must survive it and retry.
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, status: 'failed', error: 'directory down', search: 'da' })
|
||||
popup.retry()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ status: 'pending', error: null })
|
||||
await Promise.resolve()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, search: 'da' })
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('retry is a no-op unless the options load failed', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.retry()
|
||||
expect(popup.state.getSnapshot().status).toBe('ready')
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
closed.retry()
|
||||
expect(closed.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search / move / highlight over the filtered list', () => {
|
||||
it('setSearch rebases the highlight to 0 and ignores closed shells and identical text', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.setSearch('s')
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ search: 's', active: 0 })
|
||||
const before = popup.state.getSnapshot()
|
||||
popup.setSearch('s')
|
||||
expect(popup.state.getSnapshot()).toBe(before)
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
closed.setSearch('x')
|
||||
expect(closed.state.getSnapshot().search).toBe('')
|
||||
})
|
||||
|
||||
it('move wraps across the FILTERED rows', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.setSearch('a') // Dark, Sepia (detail 'warm' also matches 'a'? label match: Dark, Sepia)
|
||||
const rows = filterOptions(popup.state.getSnapshot().options, 'a')
|
||||
expect(rows.length).toBe(2)
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(0)
|
||||
popup.move(-1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
})
|
||||
|
||||
it('move is a no-op while pending, closed, or when the filter matches nothing', async () => {
|
||||
const pending = new PopupSelectController<Ctx>(makeDeps())
|
||||
pending.open('theme', spec({ options: () => new Promise(() => {}) }), CTX_A, SEGMENT)
|
||||
pending.move(1)
|
||||
expect(pending.state.getSnapshot().active).toBe(0)
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
closed.move(1)
|
||||
expect(closed.state.getSnapshot().active).toBe(0)
|
||||
const { popup } = await readyPopup()
|
||||
popup.setSearch('nope')
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(0)
|
||||
})
|
||||
|
||||
it('highlight sets the active filtered row and ignores out-of-range or same-index calls', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.highlight(99)
|
||||
popup.highlight(-1)
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.setSearch('dark') // one filtered row → index 1 now out of range
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
|
||||
const seen: Array<{ option: SelectOption; context: Ctx }> = []
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: (option, context) => { seen.push({ option, context }) },
|
||||
}, deps)
|
||||
popup.setSearch('light')
|
||||
await popup.select(0)
|
||||
expect(seen).toEqual([{ option: OPTIONS[1], context: CTX_A }])
|
||||
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(deps.focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('is single-flight: the first call enters submitting, later Enter/click calls no-op', async () => {
|
||||
let release!: () => void
|
||||
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({ onSelect }, deps)
|
||||
const first = popup.select(0)
|
||||
expect(popup.state.getSnapshot().submitting).toBe(true)
|
||||
await popup.select(0)
|
||||
await popup.select(1)
|
||||
popup.setSearch('x') // locked while submitting
|
||||
popup.move(1)
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 0 })
|
||||
release()
|
||||
await first
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
expect(deps.consume).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a consume CAS miss is benign: no retry, still closes and refocuses', async () => {
|
||||
const deps = makeDeps(false)
|
||||
const { popup } = await readyPopup({}, deps)
|
||||
await popup.select(0)
|
||||
expect(deps.consume).toHaveBeenCalledTimes(1)
|
||||
expect(deps.focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an onSelect failure keeps the shell open with search/highlight/token intact, no consumption, and select re-arms', async () => {
|
||||
let attempts = 0
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('host rejected')
|
||||
return undefined
|
||||
},
|
||||
}, deps)
|
||||
popup.setSearch('a')
|
||||
popup.move(1)
|
||||
await popup.select(1)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({
|
||||
open: true, status: 'ready', submitting: false, error: 'host rejected', search: 'a', active: 1,
|
||||
})
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
await popup.select(1) // retry = selecting again
|
||||
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores selects while closed, pending, failed, or out of filtered range', async () => {
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
await closed.select(0)
|
||||
expect(closed.state.getSnapshot().open).toBe(false)
|
||||
const failedDeps = makeDeps()
|
||||
const { popup: failed } = await readyPopup({ options: () => Promise.reject(new Error('x')) }, failedDeps)
|
||||
await failed.select(0)
|
||||
expect(failedDeps.consume).not.toHaveBeenCalled()
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({}, deps)
|
||||
popup.setSearch('dark')
|
||||
await popup.select(1) // only one filtered row
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
expect(popup.state.getSnapshot().open).toBe(true)
|
||||
})
|
||||
|
||||
it('a dismiss racing a succeeding onSelect revokes it: no consume, no focus, state stays closed', async () => {
|
||||
let release!: () => void
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => new Promise<void>((resolve) => { release = resolve }),
|
||||
}, deps)
|
||||
const selecting = popup.select(0)
|
||||
popup.dismiss()
|
||||
release()
|
||||
await selecting
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
expect(deps.focusComposer).not.toHaveBeenCalled()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a dispose racing a failing onSelect revokes its error write', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => new Promise<void>((_resolve, rej) => { reject = rej }),
|
||||
}, deps)
|
||||
const selecting = popup.select(0)
|
||||
popup.dispose()
|
||||
reject(new Error('late'))
|
||||
await selecting
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: false, error: null })
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a reopen racing a succeeding onSelect keeps the new shell: no consume of the old segment', async () => {
|
||||
let release!: () => void
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => new Promise<void>((resolve) => { release = resolve }),
|
||||
}, deps)
|
||||
const selecting = popup.select(0)
|
||||
popup.open('other', spec(), CTX_A, { via: 'enter', token: '/other' })
|
||||
release()
|
||||
await selecting
|
||||
await Promise.resolve()
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'other' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismiss / dispose', () => {
|
||||
it('dismiss closes, aborts the flying fetch, and is a no-op when already closed', async () => {
|
||||
const deps = makeDeps()
|
||||
const popup = new PopupSelectController<Ctx>(deps)
|
||||
let signal!: AbortSignal
|
||||
popup.open('theme', spec({
|
||||
options: (_ctx, s) => {
|
||||
signal = s
|
||||
return new Promise(() => {})
|
||||
},
|
||||
}), CTX_A, SEGMENT)
|
||||
popup.dismiss()
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
expect(deps.focusComposer).not.toHaveBeenCalled() // outside-pointer path: the click's target takes focus
|
||||
popup.dismiss()
|
||||
popup.dispose()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('the Escape path restores composer focus explicitly', async () => {
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({}, deps)
|
||||
popup.dismiss({ focusComposer: true })
|
||||
expect(deps.focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
548
packages/client/ui-command/tests/service.spec.ts
Normal file
548
packages/client/ui-command/tests/service.spec.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* CommandService tests on a real cordis Context with fake slash/connection
|
||||
* faces and real session scopes (createScope): session-keyed candidate
|
||||
* synthesis (host catalog by sessionId + contributions by availability,
|
||||
* collision fail-loud), the dispatch decision table cell by cell, matchSpace
|
||||
* hot-key policy, matchEnter strong-wait / reject, the sessionId execute
|
||||
* payload, the scoped consume-token dispatch, per-session popupFor
|
||||
* lifecycle, and the directory invalidation event subscriptions.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandService } from '../src/client/service.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
/** The agent-backed session projection (single state; identity only). */
|
||||
const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
|
||||
|
||||
const S1_CMDS: CommandDescriptor[] = [
|
||||
{ name: 'plan', description: 'bare kind' },
|
||||
{ name: 'goal', description: 'leadingInput kind', input: { hint: 'goal text' } },
|
||||
]
|
||||
|
||||
const S2_CMDS: CommandDescriptor[] = [
|
||||
...S1_CMDS,
|
||||
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } }
|
||||
|
||||
interface BenchOptions {
|
||||
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
||||
commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
|
||||
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
|
||||
}
|
||||
|
||||
async function bench(opts: BenchOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const registered = new Map<string, SlashSource>()
|
||||
const listCalls: Array<{ sessionId: SessionId }> = []
|
||||
const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
|
||||
const api = {
|
||||
commands: {
|
||||
list: async (payload: { sessionId: SessionId }) => {
|
||||
listCalls.push(payload)
|
||||
const value = await (opts.commands ?? (p => Promise.resolve({
|
||||
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
|
||||
})))(payload)
|
||||
return { result: { ok: true as const, value } }
|
||||
},
|
||||
execute: async (payload: { sessionId: SessionId; line: string }) => {
|
||||
executeCalls.push(payload)
|
||||
const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload)
|
||||
return { result: { ok: true as const, value } }
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx.provide('slash', {
|
||||
registerSource(src: SlashSource) {
|
||||
const key = `${src.trigger} ${src.name}`
|
||||
registered.set(key, src)
|
||||
return () => { registered.delete(key) }
|
||||
},
|
||||
})
|
||||
// Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads).
|
||||
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id)?.ctx,
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
input: {
|
||||
for: (actx: Context) => ({
|
||||
notify: (level: 'info' | 'error', text: string) => {
|
||||
notices.push({ scope: scopeOf(actx), level, text })
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
const fiber = ctx.plugin(CommandService)
|
||||
await fiber.await()
|
||||
const command = ctx.get('command') as CommandService
|
||||
const source = registered.get('/ command')
|
||||
if (source === undefined) throw new Error('command source not registered')
|
||||
const mint = (key: string) => {
|
||||
const handle = createScope(ctx, sid(key))
|
||||
scopes.set(sid(key), handle)
|
||||
return handle
|
||||
}
|
||||
/** Warm one session's catalog through the source's own candidate pull. */
|
||||
const warm = async (session: ClientSessionContext) => {
|
||||
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
|
||||
}
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
|
||||
}
|
||||
|
||||
function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
|
||||
const pick: SlashPick = {
|
||||
candidate: { name },
|
||||
session,
|
||||
position: 'leading',
|
||||
via: 'menu',
|
||||
span: { start: 0, end: end ?? name.length + 1, draftRev: 3 },
|
||||
}
|
||||
return source.onPick(pick)
|
||||
}
|
||||
|
||||
const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
|
||||
kind: 'popupSelect',
|
||||
options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
|
||||
onSelect: () => undefined,
|
||||
...over,
|
||||
})
|
||||
|
||||
const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
|
||||
name: 'theme',
|
||||
description: 'client popup kind',
|
||||
available: () => true,
|
||||
ui: themeUi(),
|
||||
...over,
|
||||
})
|
||||
|
||||
const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
|
||||
({ query, position, signal: new AbortController().signal })
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
|
||||
const { registered, source, fiber } = await bench()
|
||||
expect(source.matchSpace).toBeTypeOf('function')
|
||||
expect(source.matchEnter).toBeTypeOf('function')
|
||||
expect(source.warm).toBeTypeOf('function')
|
||||
expect([...registered.keys()]).toEqual(['/ command'])
|
||||
await fiber.dispose()
|
||||
expect(registered.size).toBe(0)
|
||||
})
|
||||
|
||||
it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
source.warm!(proj('s1'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
|
||||
source.warm!(proj('s2'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
|
||||
source.warm!(proj('s1')) // s1 already pending → no duplicate pull
|
||||
expect(listCalls).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('candidates', () => {
|
||||
it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
const list = await source.candidates(proj('s1'), req('g'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
|
||||
expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
|
||||
})
|
||||
|
||||
it('catalogs are per session: another session pulls its own key', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s2') }])
|
||||
expect(names).toEqual(['plan', 'goal', 'attach'])
|
||||
})
|
||||
|
||||
it('hides leadingInput commands at inline position', async () => {
|
||||
const { source } = await bench()
|
||||
const names = (await source.candidates(proj('s1'), req('', 'inline'))).map(c => c.name)
|
||||
expect(names).toEqual(['plan'])
|
||||
})
|
||||
|
||||
it('merges available contributions and filters unavailable ones with the per-call projection', async () => {
|
||||
const { command, source } = await bench()
|
||||
const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
|
||||
command.register(themeContribution({ available }))
|
||||
const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
|
||||
expect(s1Names).toEqual(['plan', 'goal', 'theme'])
|
||||
expect(available).toHaveBeenLastCalledWith(proj('s1'))
|
||||
const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
|
||||
expect(s2Names).not.toContain('theme')
|
||||
})
|
||||
|
||||
it('contribution rows ride the same query prefix filter', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.register(themeContribution())
|
||||
const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name)
|
||||
expect(names).toEqual(['theme'])
|
||||
})
|
||||
|
||||
it('a contribution/host name collision fails loud', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.register(themeContribution({ name: 'plan' }))
|
||||
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch (menu column)', () => {
|
||||
it('contribution → opens the session popup with the open-time projection, no execute', async () => {
|
||||
const { command, source, mint, warm, executeCalls } = await bench()
|
||||
const options = vi.fn((_s: ClientSessionContext) => Promise.resolve([{ id: 'dark', label: 'Dark' }]))
|
||||
command.register(themeContribution({ ui: themeUi({ options }) }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'theme', proj('s1'))).toBe('handled')
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme' })
|
||||
expect(options).toHaveBeenCalledExactlyOnceWith(proj('s1'), expect.any(AbortSignal))
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('an unavailable contribution falls through to the host catalog', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.register(themeContribution({ available: () => false }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'theme', proj('s1'))).toBeUndefined() // no host 'theme' either
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('host leadingInput → {claim} with token "/name " and hint; claiming never executes', async () => {
|
||||
const { source, warm, executeCalls } = await bench()
|
||||
await warm(proj('s1'))
|
||||
const outcome = menuPick(source, 'goal', proj('s1'))
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
expect(outcome.claim.hint).toBe('goal text')
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('host bare → consume-token span guard on the session scope + detached execute', async () => {
|
||||
const { source, mint, warm, executeCalls } = await bench()
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
|
||||
await Promise.resolve()
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
})
|
||||
|
||||
it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'gone', proj('s1'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchSpace (space column)', () => {
|
||||
it('answers undefined from a not-ready key (no waiting, no RPC)', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
expect(listCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('hot leadingInput exact token → {claim}; the key axis is the session', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s2'))
|
||||
const outcome = source.matchSpace!(proj('s2'), '/attach')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/attach ')
|
||||
// s1's key is still cold: the same token answers undefined there.
|
||||
expect(source.matchSpace!(proj('s1'), '/attach')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bare kind and contribution names stay plain text', async () => {
|
||||
const { command, source, warm } = await bench()
|
||||
command.register(themeContribution())
|
||||
await warm(proj('s1'))
|
||||
expect(source.matchSpace!(proj('s1'), '/plan')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/theme')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('unknown token / non-slash token → undefined', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
expect(source.matchSpace!(proj('s1'), '/nope')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), 'plan')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchEnter (enter column)', () => {
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
it('strong-waits a cold key before adjudicating', async () => {
|
||||
let release!: (value: { commands: CommandDescriptor[] }) => void
|
||||
const { source } = await bench({
|
||||
commands: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const wait = source.matchEnter!(proj('s1'), '/goal args', signal())
|
||||
release({ commands: S1_CMDS })
|
||||
const outcome = await wait
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('rejects when warmup fails (never a silent downgrade)', async () => {
|
||||
const { source } = await bench({
|
||||
commands: () => Promise.reject(new Error('warmup boom')),
|
||||
})
|
||||
await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
|
||||
})
|
||||
|
||||
it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
for (const line of ['/goal', '/goal refactor the loop']) {
|
||||
const outcome = await source.matchEnter!(proj('s1'), line, signal())
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
}
|
||||
})
|
||||
|
||||
it('bare host command executes detached with the bare-token consume guard', async () => {
|
||||
const { source, mint, warm, executeCalls } = await bench()
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
|
||||
await Promise.resolve()
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
})
|
||||
|
||||
it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
|
||||
const { source, warm, executeCalls } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined()
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('contribution: bare token opens the popup without touching the directory; args → undefined', async () => {
|
||||
const { command, source, mint, listCalls } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
|
||||
expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('unknown name, bare "/", and non-slash lines → undefined', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute payload', () => {
|
||||
it('claim.submit addresses the session and maps the detached result', async () => {
|
||||
const { source, warm, executeCalls } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
const settled = await outcome.claim.submit('ship it', new Context())
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
|
||||
expect(settled).toEqual({ kind: 'success', text: 'goal set' })
|
||||
})
|
||||
|
||||
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
|
||||
const claimOf = async (opts: BenchOptions) => {
|
||||
const b = await bench(opts)
|
||||
await b.warm(proj('s1'))
|
||||
const outcome = b.source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
return outcome.claim
|
||||
}
|
||||
const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
|
||||
const bad = await first.submit('x', new Context())
|
||||
expect(bad.kind).toBe('error')
|
||||
const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
|
||||
await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detached result notices', () => {
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
it('success text → info; error result → error; rejection → error, all on the triggering session', async () => {
|
||||
let mode: 'info' | 'error' | 'reject' = 'info'
|
||||
const { source, mint, warm, notices } = await bench({
|
||||
execute: () => {
|
||||
if (mode === 'reject') return Promise.reject(new Error('network down'))
|
||||
return Promise.resolve({
|
||||
matched: true,
|
||||
result: mode === 'info'
|
||||
? { kind: 'success' as const, text: 'compacted 12 messages' }
|
||||
: { kind: 'error' as const, text: 'plan mode refused' },
|
||||
})
|
||||
},
|
||||
})
|
||||
mint('s1')
|
||||
await warm(proj('s1'))
|
||||
menuPick(source, 'plan', proj('s1'))
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }])
|
||||
|
||||
notices.length = 0
|
||||
mode = 'error'
|
||||
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }])
|
||||
|
||||
notices.length = 0
|
||||
mode = 'reject'
|
||||
menuPick(source, 'plan', proj('s1'))
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
|
||||
})
|
||||
|
||||
it('success without text stays silent; a torn-down scope drops the notice', async () => {
|
||||
const { source, warm, notices } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }),
|
||||
})
|
||||
await warm(proj('ghost')) // never minted: scopeFor misses
|
||||
menuPick(source, 'plan', proj('ghost'))
|
||||
await flush()
|
||||
expect(notices).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('register (contribution face)', () => {
|
||||
it('duplicate registration throws; the disposer frees the name', async () => {
|
||||
const { command } = await bench()
|
||||
const dispose = command.register(themeContribution())
|
||||
expect(() => command.register(themeContribution())).toThrow('duplicate contribution')
|
||||
dispose()
|
||||
command.register(themeContribution())()
|
||||
})
|
||||
})
|
||||
|
||||
describe('popupFor', () => {
|
||||
it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
|
||||
const { ctx, command, mint } = await bench()
|
||||
const a = mint('s1')
|
||||
const first = command.popupFor(a.ctx)
|
||||
expect(command.popupFor(a.ctx)).toBe(first)
|
||||
expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
|
||||
expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
|
||||
})
|
||||
|
||||
it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => {
|
||||
const { command, source, mint } = await bench()
|
||||
const onSelect = vi.fn()
|
||||
command.register(themeContribution({ ui: themeUi({ onSelect }) }))
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
const focus = vi.fn()
|
||||
command.bindComposerFocus(sid('s1'), focus)
|
||||
|
||||
expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
await Promise.resolve() // options land
|
||||
await popup.select(0)
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
|
||||
expect(focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the enter path opens with the bare-token guard', async () => {
|
||||
const { command, source, mint } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
await Promise.resolve()
|
||||
await popup.select(0)
|
||||
expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/theme' } }])
|
||||
})
|
||||
|
||||
it('the scope disposer disposes the controller and a re-mint resolves fresh', async () => {
|
||||
const { command, source, mint } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
expect(popup.state.getSnapshot().open).toBe(true)
|
||||
|
||||
await scope.fiber.dispose()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
expect(command.popupFor(mint('s1').ctx)).not.toBe(popup)
|
||||
})
|
||||
})
|
||||
|
||||
describe('directory invalidation events', () => {
|
||||
it('commands/changed repulls in the background while the old snapshot serves', async () => {
|
||||
let round = 0
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: () => {
|
||||
round += 1
|
||||
return Promise.resolve({
|
||||
commands: round === 1
|
||||
? S1_CMDS
|
||||
: [{ name: 'fresh', description: '', input: { hint: 'h' } }],
|
||||
})
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
ctx.emit('commands/changed')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('connection/reset hard-drops every session key until its rewarm lands', async () => {
|
||||
let block = false
|
||||
let release!: (value: { commands: CommandDescriptor[] }) => void
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: () => (block
|
||||
? new Promise((resolve) => { release = resolve })
|
||||
: Promise.resolve({ commands: S2_CMDS })),
|
||||
})
|
||||
await warm(proj('s2'))
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
|
||||
block = true
|
||||
ctx.emit('connection/reset')
|
||||
// Hard reset: silent until the rewarm lands.
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
|
||||
release({ commands: S2_CMDS })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
36
packages/client/ui-command/tsconfig.json
Normal file
36
packages/client/ui-command/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-command/tsdown.config.ts
Normal file
3
packages/client/ui-command/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-command', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user