/** * Frozen input-machine contract (design §9.1, eng. plan §3.9-3.12). Types * only. Three-tier visibility: business packages see InputState via the * InputZone currency; the scoped input events carry the mutation verbs; the * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { Branded } from '@deepseek-ai/dsh-brand' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { QueueRow } from '../contract/queue.ts' /** Browser-runtime identity of one unsent image draft. */ export type DraftAttachmentId = Branded<'DraftAttachmentId'> /** * The scoped-event application verbs: the hub's bail listeners call these, * and the boolean answer IS the event's bail value (true ⟺ the machine * accepted after phase and span/bare-token guards). */ export interface InputTarget { /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */ beginCommand(claim: CommandClaim, span: TokenSpan): boolean /** Replace the trigger span with one reference occurrence (span-CAS'd). */ insertReference(ref: ReferenceInsert, span: TokenSpan): boolean } /** Per-session input facade owned by the conversation wiring layer. */ export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void /** * Append ordered browser-owned draft attachment ids. * @returns whether the ids were appended; busy admission phases refuse, and * the caller keeps ownership of refused ids (release or retry them). */ addImages(ids: readonly DraftAttachmentId[]): boolean /** Remove one browser-owned draft attachment id. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ pruneImages(ids: readonly DraftAttachmentId[]): void /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ submit(): void /** * Surface a notice outside the machine's own effect stream: detached * command results and business notifications render through here. * Session-routed — resolving the facade via InputService.for(actx) lands * the notice on that session's composer, so a result arriving after a * session switch still reaches its own session. * @param level - severity tier. * @param text - notice body. */ notify(level: 'info' | 'error', text: string): void /** Input state store (InputZone currency + decorations read here). */ readonly state: SnapshotStore } /** Session-addressed access to the per-session input facade. */ export interface InputService { /** Resolve the facade for one session-scope ctx. */ for(actx: ClientContext): SessionInput } /** * The public input action face provided to every session-scope slot * component (decision 20): stable-identity void callbacks, mirroring the * useStore+actions convention. Command-style handles (track/arbitrate/space/ * undo/paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void /** * Append ordered browser-owned draft attachment ids. * @returns whether the ids were appended (busy admission phases refuse). */ addImages(ids: readonly DraftAttachmentId[]): boolean /** Remove one browser-owned draft attachment id. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ pruneImages(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(): void } /** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */ export interface InputNotice { readonly level: 'info' | 'error' readonly text: string readonly seq: number } /** * The InputBar-exclusive keyboard/DOM command face (decision 20): synchronous * returns and event-handler semantics that must not enter the public provide * channel. Handed to the composer-bar entry through its own inject — * package-internal, never across a plugin boundary. The session shell * satisfies it structurally. */ export interface ComposerKeyboard { /** Live machine state for event-handler reads (render reads go through useInput). */ readonly snapshot: InputState /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ setDraft(text: string, editRange?: EditRange): void /** Newline at the selection as a machine transaction (Ctrl+Enter path). */ newline(selection: EditSelection): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void /** Caret/selection gestures the machine cannot observe end the paste attempt. */ invalidatePaste(): void /** Feed a draft/caret change through trigger detection (guard derived from phase). */ track(draft: string, caret: number): void /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */ arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome /** Space adjudication; true = the input applied a claim — caller preventDefaults. */ space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void } /** One independently addressable row projected from the transient queue snapshot. */ export type QueuedMessage = QueueRow /** Guard union of the scoped consume-token event, checked by the machine. */ export type ConsumeTokenGuard = ConsumeTokenRequest['guard'] /** Half-open [start, end) range/selection in draft character coordinates. */ export interface EditSelection { readonly start: number readonly end: number } /** * One edit applied to the previous draft: [start, end) in the PREVIOUS * draft's coordinates was replaced by insertedLength characters. Supplied by * the wiring layer when the DOM event exposes the edit shape; absent, the * machine recovers it with a prefix/suffix common-scan diff. */ export interface EditRange extends EditSelection { readonly insertedLength: number } /** * One reference chip occurrence, backing exactly one U+FFFC placeholder in * the draft (design §9.1 底层表示). Identity is occurrenceId — same-named * references stay independently addressable. label/clipboardText are the * owner's insert-time projections, cached so the chip survives owner loss * (invalid flips instead of dropping the occurrence). */ export interface Occurrence { /** Machine-minted stable identity (monotonic per machine). */ readonly occurrenceId: number /** Owning source name (serializer routing key). */ readonly source: string /** Owner-scoped reference id. */ readonly ref: string /** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */ readonly offset: number /** Chip display label (insert-time cache). */ readonly label: string /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */ readonly clipboardText: string /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ readonly invalid?: boolean } /** One sync-matched paste component; start/end are relative to the pasted text. */ export interface PasteComponent extends EditSelection { readonly reference: ReferenceInsert } /** * Live paste-match attempt published while async matching may still upgrade * pasted tokens (design §9.1 剪贴板 round-trip). Any non-paste transaction, * submit start, invalidate-paste, or release ends it; a paste-upgrade keeps * it current (later tokens re-CAS against the advanced draftRev). */ export interface PasteAttemptState { /** Machine-minted attempt identity (paste-upgrade must match it). */ readonly attemptId: number /** Pasted range in the draft as of the paste transaction. */ readonly insertedRange: EditSelection /** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */ readonly generation: number } /** * InputMachine construction knobs. The machine never reads an ambient clock: * `now` is the only time source, injected by the shell (tests inject a * fake). The default clock is constant, i.e. consecutive single-char typing * always coalesces until a non-typing transaction intervenes. */ export interface InputMachineOptions { /** Single-char typing undo-merge window in ms (default 1000). */ readonly mergeWindowMs?: number /** Monotonic clock for typing-merge decisions (default: constant 0). */ readonly now?: () => number } /** Published input state (the currency; per-session). */ export interface InputState { readonly draft: string /** Ordered runtime-only image ids; bytes and object URLs stay in ConversationService. */ readonly imageIds: readonly DraftAttachmentId[] /** Monotonic draft revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ readonly claim?: { readonly token: string; readonly hint?: string } /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState /** Read-only queue projection (session/queued frames + connect snapshot). */ readonly queue: readonly QueuedMessage[] } /** * One in-flight submission attempt: the ONLY id concept in the submit plane. * Created on enter; carried by adjudicated/submit-settled events; stale * attempts are dropped (anti-backwash). release/session teardown aborts the * current attempt, keeping the promise bounded. */ export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal /** Draft at enter time; rollback restores it only while the live draft still equals it. */ readonly draftSnapshot: string } /** * InputMachine input events (the machine's single write path). Every draft * mutation is one transaction: draft edit, occurrence reconciliation, and * undo-log push are atomic inside dispatch(). Events carrying `at` stamp the * injected clock reading; only single-char typing coalescing reads it. */ export type InputEvent = /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } /** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */ | { readonly type: 'newline'; readonly selection: EditSelection } | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } /** Delete a settled command token; success is observable as a draftRev advance. */ | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } /** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */ | { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] } | { readonly type: 'undo' } | { readonly type: 'redo' } /** * Paste text replacing the selection, one transaction. Hot-snapshot sync * matches ride in as components (chips minted inside the SAME transaction: * one undo returns to pre-paste); a PasteMatchAttempt opens for the async * remainder. Component ranges must be disjoint and inside the pasted text. */ | { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number } /** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */ | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ | { readonly type: 'invalidate-paste' } | { readonly type: 'enter' } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } /** * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — * undo must not resurrect sent content (mirrors submit-settled's success arm). */ | { readonly type: 'send-committed' } | { readonly type: 'release' } /** * InputMachine output effects (executed by the SessionInput shell; the * machine stays pure). Draft/occurrence mutations carry no effect — the * shell publishes the state store after every dispatch. */ export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } | { readonly type: 'default-sink'; readonly draft: string } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }