Files
deepseek-harness/packages/client/ui-conversation/src/client/input/facade.ts
T
imccyu d3d01cb49c fix(ui-slash): make the reference lexicon reactive end to end
The decoration scan read a mutable lexicon() aggregation during render
with no subscription, so a catalog settling or a child spawning after
prewarm left drafted tokens undecorated until an unrelated re-render.
The controller now publishes the aggregation as a snapshot store fed by
a new optional SlashSource.subscribeLexicon hook (ui-skill notifies on
settle/invalidate, ui-subagent forwards the session-list feed), the
composer keyboard face exposes it as an observable, and InputBar
subscribes through uSES. Sources registered after scope birth now warm
and join live controllers via a service broadcast.
2026-07-28 12:09:48 +08:00

450 lines
17 KiB
TypeScript

/**
* SessionInput shell over the pure input machine: the sole machine caller
* and effect executor. Owns the InputState store (machine state + the queue
* overlay), the notice channel, and the submit transaction plumbing
* (adjudicate via the session's SlashController; claim.submit; default
* sink). Package-private; the hub alone constructs it and wires the scoped
* event listeners onto it.
*/
import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SlashController, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type {
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
} from './contract.ts'
import { InputMachine } from './machine.ts'
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
export interface PopupDismissFace {
dismiss(): void
}
/**
* Construction seams of one facade. The slash/popup faces are THUNKS: the
* shell is created inside the sessions provide materialization (before the
* scope record is queryable), where `slash.sessionOf`/`command.popupFor`
* cannot resolve yet — resolution defers to first interactive use.
*/
export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */
actx: ClientContext
/** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */
slash?: (() => SlashController | undefined) | undefined
/** PopupSelect shell face resolver (dismissal on submit lock / escape). */
popup?: (() => PopupDismissFace | undefined) | undefined
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer'): void
}
/** Guard tier from the machine phase. */
function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' {
switch (phase) {
case 'plain': return 'plain'
case 'claimed': return 'claimed'
default: return 'frozen' // adjudicating / submitting
}
}
const EMPTY_QUEUE: readonly QueuedMessage[] = []
/** No-pipeline lexicon: zero text-ref decorations. */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/**
* The per-session input facade: scoped-event application verbs +
* setDraft/submit + the published InputState store.
*/
export class SessionInputShell implements SessionInput {
/** Published machine state + queue overlay (the InputZone currency source). */
readonly state: SnapshotStore<InputState>
/** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */
readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
/** The public provide-channel action face (one stable identity per session — decision 20). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
submit: (mode) => { this.submit(mode) },
}
// Real wall clock: the typing-run merge window must actually expire in
// production (the machine's no-clock default is a constant for pure tests).
private readonly core = new InputMachine({ now: () => Date.now() })
private noticeSeq = 0
private lastDraft = ''
private disposed = false
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
private mirrorFn: ((text: string) => void) | undefined
constructor(private readonly deps: SessionInputDeps) {
this.state = createSnapshotStore<InputState>(this.compose())
deps.queue?.subscribe(() => { this.publish() })
}
// ---- SessionInput face ----
/**
* Single draft write path (all mutation rides machine events).
* @param text - the full next draft.
* @param editRange - the DOM-observed edit shape, when the caller knows it
* (narrows the machine's occurrence math; absent → diff scan).
*/
setDraft(text: string, editRange?: EditRange): void {
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
}
/**
* Clear the draft as a successful-send commit: no undo unit is recorded and
* the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
* (the command path gets the same discipline from submit-settled success).
*/
commitSend(): void {
this.run(this.core.dispatch({ type: 'send-committed' }))
}
/**
* Insert a newline at the selection as one machine transaction (the
* execCommand path is gone — a second undo history would fork).
* @param selection - current DOM selection in draft coordinates.
*/
newline(selection: EditSelection): void {
this.run(this.core.dispatch({ type: 'newline', selection }))
}
/** Undo the latest transaction (InputBar intercepts the platform chord). */
undo(): void {
this.run(this.core.dispatch({ type: 'undo' }))
}
/** Redo the latest undone transaction. */
redo(): void {
this.run(this.core.dispatch({ type: 'redo' }))
}
/**
* Paste text over the selection in one transaction, with any hot-snapshot
* sync matches componentized inside it.
* @param text - pasted plain text.
* @param selection - replaced selection in draft coordinates.
* @param components - sync-matched reference components (disjoint, inside `text`).
* @param generation - projection generation for late async-upgrade guards.
*/
pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void {
this.run(this.core.dispatch({
type: 'paste-begin', text, selection,
...(components !== undefined ? { components } : {}),
...(generation !== undefined ? { generation } : {}),
}))
}
/** End the live paste-match attempt (caret/selection ops and Slash updates the machine cannot see). */
invalidatePaste(): void {
this.run(this.core.dispatch({ type: 'invalidate-paste' }))
}
/**
* Enter adjudication + submit transaction + default sink. Effects fan out
* from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
* @param mode - default-sink mode (queue appends; steer interrupts).
*/
submit(mode: 'queue' | 'steer' = 'queue'): void {
this.run(this.core.dispatch({ type: 'enter', mode }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
this.deps.slash?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev)
}
}
/**
* Feed a draft/caret change through trigger detection (guard derived from
* the machine phase).
* @param draft - live draft text.
* @param caret - caret position in draft coordinates.
*/
track(draft: string, caret: number): void {
this.deps.slash?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev)
}
/**
* Keyboard arbitration while the menu is open.
* @param key - the intercepted key.
* @param composing - IME composition guard state.
* @returns the menu's verdict; 'pass' when no pipeline is mounted.
*/
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome {
return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass'
}
/**
* Space adjudication over the controller's hot state.
* @returns true = a claim/insert was applied — the caller preventDefaults.
*/
space(): boolean {
const slash = this.deps.slash?.()
if (slash === undefined) return false
const consumed = slash.onSpace()
// Machine-driven draft replacement never passes through onChange, so
// re-track: the caret lands after the token, where detection sees
// whitespace and closes the menu.
if (consumed) {
const next = this.snapshot
slash.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev)
}
return consumed
}
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void {
this.deps.popup?.()?.dismiss()
}
/**
* Hot plain-text reference lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
}
/**
* Apply one command claim (scoped begin-command event listener body).
* @param claim - the command claim from the pick path.
* @param span - pick-time span snapshot.
* @returns whether the machine accepted (phase + span CAS passed and the draft mutated).
*/
beginCommand(claim: CommandClaim, span: TokenSpan): boolean {
const before = this.core.state.draftRev
this.run(this.core.dispatch({ type: 'begin-command', claim, span }))
return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before
}
/**
* Apply one reference insertion (scoped insert-reference event listener body).
* @param ref - the reference insertion from the pick path.
* @param span - pick-time span snapshot.
* @returns whether the machine accepted.
*/
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean {
const before = this.core.state.draftRev
this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span }))
return this.core.state.draftRev !== before
}
/**
* Consume one command token after business success (scoped consume-token
* event listener body). Span guard: revision CAS then splice; bare-token
* guard: trimmed-draft equality then clear.
* @param guard - exact span or bare-token guard.
* @returns whether the token was consumed.
*/
consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
const snapshot = this.core.state
if (guard.kind === 'span') {
if (guard.span.draftRev !== snapshot.draftRev) return false
const draft = snapshot.draft
this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end))
return true
}
if (snapshot.draft.trim() !== guard.token) return false
this.setDraft('')
return true
}
/**
* Insert plain reference text over the pick-time span (scoped insert-text
* event listener body, decision 21). Same CAS-then-splice shape as the
* consume-token span branch: the machine sees an ordinary draft-changed
* transaction (one undo step), no occurrence is minted — the chip look is
* a scan-derived decoration, never state.
* @param text - the plain reference text to splice in (e.g. `/name `).
* @param span - pick-time span snapshot (draftRev CAS).
* @returns whether the text was applied.
*/
insertText(text: string, span: TokenSpan): boolean {
const snapshot = this.core.state
if (span.draftRev !== snapshot.draftRev) return false
const draft = snapshot.draft
this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end))
return true
}
/**
* Surface a notice from outside the machine (detached command results).
* @param level - severity tier.
* @param text - notice body.
*/
notify(level: 'info' | 'error', text: string): void {
this.noticeSeq += 1
this.notices.set({ level, text, seq: this.noticeSeq })
}
// ---- wiring-layer extras (not on the frozen SessionInput face) ----
/** Teardown: abort any in-flight attempt and stop accepting async settlements. */
dispose(): void {
this.disposed = true
this.run(this.core.dispatch({ type: 'release' }))
}
/** Read the live machine state (guard derivation reads here). */
get snapshot(): InputState {
return this.state.getSnapshot()
}
/**
* Bind the draft persistence mirror (chat store write). Adopt-on-bind: the
* store draft may hold a persisted value from a previous mount; the caller
* seeds it via setDraft BEFORE binding, and afterwards every machine-adopted
* draft mirrors out.
* @param write - store draft write.
* @returns the unbind disposer.
*/
bindMirror(write: (text: string) => void): () => void {
this.mirrorFn = write
return () => {
if (this.mirrorFn === write) this.mirrorFn = undefined
}
}
// ---- effect executor ----
private run(effects: readonly InputEffect[]): void {
for (const fx of effects) this.execute(fx)
this.publish()
}
private execute(fx: InputEffect): void {
switch (fx.type) {
case 'notice': {
this.noticeSeq += 1
this.notices.set({ level: fx.level, text: fx.text, seq: this.noticeSeq })
return
}
case 'adjudicate': {
this.adjudicate(fx.attempt, fx.draft)
return
}
case 'begin-submit': {
this.beginSubmit(fx.attempt, fx.claim, fx.args)
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
return
}
default:
return // machine-internal effects (mirror rides publish)
}
}
/**
* Prompt serialization before the sink (design §3.12): expand each
* placeholder to its owner's model form via the session controller's
* codec routing. Owner missing / serialize failure / disposal blocks the
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode)
return
}
const slash = this.deps.slash?.()
const controller = new AbortController()
void Promise.all(occurrences.map(async (o) => {
if (slash === undefined) throw new Error(`no serializer for reference source "${o.source}"`)
return { offset: o.offset, text: await slash.serializeReference(o.source, o.ref, controller.signal) }
})).then(
(parts) => {
if (this.disposed) return
// Splice model forms over their placeholders (offsets are draft-time;
// parts arrive offset-sorted since the table is).
let out = ''
let cursor = 0
for (const part of parts) {
out += draft.slice(cursor, part.offset) + part.text
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode)
},
(error: unknown) => {
controller.abort()
if (this.disposed) return
const message = error instanceof Error ? error.message : String(error)
this.notify('error', message)
},
)
}
/** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
private adjudicate(attempt: SubmitAttempt, draft: string): void {
const slash = this.deps.slash?.()
if (slash === undefined) {
// No pipeline mounted: the '/' line is an ordinary message.
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
return
}
slash.adjudicate(draft.trim(), attempt.signal).then(
(outcome: PickOutcome) => {
if (this.dead(attempt)) return
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome }))
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message }))
},
)
}
/** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */
private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void {
Promise.resolve()
.then(() => claim.submit(args, this.deps.actx))
.then(
(outcome) => {
if (this.dead(attempt)) return
this.run(this.core.dispatch({
type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome,
}))
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message }))
},
)
}
/** Late-settlement guard: superseded attempts and disposed facades drop silently. */
private dead(attempt: SubmitAttempt): boolean {
return this.disposed || attempt.signal.aborted
}
private compose(): InputState {
const core = this.core.state
return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
}
private publish(): void {
const next = this.compose()
this.state.set(next)
if (next.draft !== this.lastDraft) {
this.lastDraft = next.draft
this.mirrorFn?.(next.draft)
}
}
}