Unify the /plan claimed hint with the plan placeholder through a locale namespace, localize slash menu group titles, replace the PermissionSelect native select with the Menu primitive, add a goal pause verb chain, clamp anchored popups to the viewport with scroll-into-view and outside-dismiss, and fix onPasteUpgrade insertedRange to account for the chip trailing gap.
580 lines
23 KiB
TypeScript
580 lines
23 KiB
TypeScript
/**
|
||
* InputMachine: the pure per-session input state machine (design §9.1, eng.
|
||
* plan §3.9-3.12). Events in, effects out; zero React / DOM / cordis / ambient
|
||
* clock. Package-private — the SessionInput shell is the only caller and the
|
||
* sole executor of the returned effects.
|
||
*
|
||
* Draft truth: the draft string holds one U+FFFC placeholder per chip; the
|
||
* occurrence table carries identity and the owner's cached projections. Every
|
||
* draft mutation is one transaction — draft edit, occurrence reconciliation,
|
||
* and undo-log push are atomic inside dispatch() — and bumps draftRev, which
|
||
* is what lets span CAS reduce to a revision-equality check: equal rev ⟹
|
||
* identical draft ⟹ identical span content. Callers observe mutation success
|
||
* as a draftRev advance (begin-command / insert-ref / consume-token /
|
||
* paste-upgrade all answer their bail events this way).
|
||
*/
|
||
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||
import type {
|
||
ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
|
||
InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
|
||
} from './contract.ts'
|
||
|
||
/** The object-replacement character backing every chip occurrence in the draft. */
|
||
export const PLACEHOLDER = ''
|
||
|
||
/** The machine never writes the queue; the wiring layer overlays the T9 store projection. */
|
||
const EMPTY_QUEUE: InputState['queue'] = []
|
||
|
||
/** Undo ring depth (design §9.1: bounded self-managed transaction log). */
|
||
const LOG_LIMIT = 100
|
||
|
||
/** Exhaustiveness backstop for the closed InputEvent / guard unions. */
|
||
function unreachable(value: never): never {
|
||
throw new Error(`unreachable input event: ${JSON.stringify(value)}`)
|
||
}
|
||
|
||
/**
|
||
* Strip the claim token off a draft to yield submit args. Leading whitespace
|
||
* (incl. newlines — leading-trigger trim) is tolerated; a bare `/name`
|
||
* missing the token's trailing separator yields empty args. Exactly one
|
||
* separator char is consumed; the remainder — newlines included — stays
|
||
* verbatim (`/goal x\ny` → `x\ny`).
|
||
*/
|
||
function argsAfter(draft: string, token: string): string {
|
||
const s = draft.trimStart()
|
||
if (s.startsWith(token)) return s.slice(token.length)
|
||
const base = token.trimEnd()
|
||
if (s.startsWith(base)) {
|
||
const rest = s.slice(base.length)
|
||
return /^\s/.test(rest) ? rest.slice(1) : rest
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* Prefix/suffix common-scan recovering the edit range between two drafts
|
||
* (used when the wiring layer cannot supply one from the DOM event).
|
||
*/
|
||
function diffEdit(prev: string, next: string): EditRange {
|
||
let p = 0
|
||
const maxCommon = Math.min(prev.length, next.length)
|
||
while (p < maxCommon && prev[p] === next[p]) p += 1
|
||
let s = 0
|
||
const maxSuffix = maxCommon - p
|
||
while (s < maxSuffix && prev[prev.length - 1 - s] === next[next.length - 1 - s]) s += 1
|
||
return { start: p, end: prev.length - s, insertedLength: next.length - s - p }
|
||
}
|
||
|
||
/**
|
||
* Expand the draft's placeholders into their occurrences' clipboard text
|
||
* (decision 16: the persistence mirror and clipboard both write this
|
||
* projection — U+FFFC never leaves the machine). Table order is offset
|
||
* order, so one linear walk pairs placeholders with entries.
|
||
* @param state - published input state.
|
||
* @returns the plain-text projection of the draft.
|
||
*/
|
||
export function projectClipboard(state: Pick<InputState, 'draft' | 'occurrences'>): string {
|
||
const { draft, occurrences } = state
|
||
if (occurrences.length === 0) return draft
|
||
let out = ''
|
||
let cursor = 0
|
||
for (const o of occurrences) {
|
||
out += draft.slice(cursor, o.offset) + o.clipboardText
|
||
cursor = o.offset + 1
|
||
}
|
||
return out + draft.slice(cursor)
|
||
}
|
||
|
||
/** One undo unit: snapshots taken before the transaction applied. */
|
||
interface Transaction {
|
||
readonly draftBefore: string
|
||
readonly occurrencesBefore: readonly Occurrence[]
|
||
/** Pre-edit selection when the triggering event carried one (shell caret restore on undo). */
|
||
readonly selectionBefore?: EditSelection
|
||
}
|
||
|
||
/**
|
||
* Pure input machine, one instance per session (per-session isolation is by
|
||
* construction). The machine constructs one AbortController per SubmitAttempt
|
||
* at enter time and aborts it itself on release; the shell never aborts, it
|
||
* only observes attempt.signal on its adjudicate/submit promises. Stale
|
||
* attempts (any adjudicated / adjudication-failed / submit-settled whose seq
|
||
* is not the in-flight one) are dropped: same state, zero effects.
|
||
*/
|
||
export class InputMachine {
|
||
private draft = ''
|
||
private draftRev = 0
|
||
private phase: InputState['phase'] = 'plain'
|
||
private claim: CommandClaim | undefined
|
||
private occurrences: readonly Occurrence[] = []
|
||
private occurrenceSeq = 0
|
||
private seq = 0
|
||
private inflight: {
|
||
readonly attempt: SubmitAttempt
|
||
readonly controller: AbortController
|
||
readonly mode: 'queue' | 'steer'
|
||
} | undefined
|
||
private log: Transaction[] = []
|
||
private redoStack: Transaction[] = []
|
||
/** Open single-char typing run: the next contiguous char within the window coalesces. */
|
||
private typingRun: { readonly end: number; readonly at: number } | undefined
|
||
private paste: PasteAttemptState | undefined
|
||
private pasteSeq = 0
|
||
private readonly mergeWindowMs: number
|
||
private readonly now: () => number
|
||
|
||
constructor(options: InputMachineOptions = {}) {
|
||
this.mergeWindowMs = options.mergeWindowMs ?? 1000
|
||
this.now = options.now ?? (() => 0)
|
||
}
|
||
|
||
/** Read-only snapshot of the machine state (queue always empty at this tier). */
|
||
get state(): InputState {
|
||
const c = this.claim
|
||
return {
|
||
draft: this.draft,
|
||
draftRev: this.draftRev,
|
||
phase: this.phase,
|
||
...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}),
|
||
occurrences: this.occurrences,
|
||
...(this.paste !== undefined ? { paste: this.paste } : {}),
|
||
queue: EMPTY_QUEUE,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Feed one event through the machine.
|
||
* @param ev - Input event; the single write path for all input state.
|
||
* @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events.
|
||
*/
|
||
dispatch(ev: InputEvent): readonly InputEffect[] {
|
||
switch (ev.type) {
|
||
case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange)
|
||
case 'newline': return this.onNewline(ev.selection)
|
||
case 'begin-command': return this.onBeginCommand(ev.claim, ev.span)
|
||
case 'insert-ref': return this.onInsertRef(ev.reference, ev.span)
|
||
case 'consume-token': return this.onConsumeToken(ev.guard)
|
||
case 'set-invalid': return this.onSetInvalid(ev.invalidIds)
|
||
case 'undo': return this.onUndo()
|
||
case 'redo': return this.onRedo()
|
||
case 'paste-begin': return this.onPasteBegin(ev.text, ev.selection, ev.components, ev.generation)
|
||
case 'paste-upgrade': return this.onPasteUpgrade(ev.attemptId, ev.span, ev.reference)
|
||
case 'invalidate-paste': {
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
case 'enter': return this.onEnter(ev.mode)
|
||
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
|
||
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
|
||
case 'submit-settled': return this.onSubmitSettled(ev)
|
||
case 'send-committed': return this.onSendCommitted()
|
||
case 'release': return this.onRelease()
|
||
default: return unreachable(ev)
|
||
}
|
||
}
|
||
|
||
// ---- transaction plumbing ----
|
||
|
||
/** Adopt a new draft: bump the revision (the span-CAS invalidation point). */
|
||
private adopt(draft: string): void {
|
||
this.draft = draft
|
||
this.draftRev += 1
|
||
}
|
||
|
||
/** Push one undo unit (before-state), trim the ring, and cut the redo chain. */
|
||
private pushTxn(selectionBefore?: EditSelection): void {
|
||
this.log.push({
|
||
draftBefore: this.draft,
|
||
occurrencesBefore: this.occurrences,
|
||
...(selectionBefore !== undefined ? { selectionBefore } : {}),
|
||
})
|
||
if (this.log.length > LOG_LIMIT) this.log.shift()
|
||
this.redoStack = []
|
||
}
|
||
|
||
/**
|
||
* Reconcile the occurrence table with one edit (old-draft coordinates):
|
||
* entries past the range shift by the length delta; entries whose
|
||
* placeholder sits inside the replaced range go away whole (design §9.1: a
|
||
* deletion/replacement intersecting a placeholder acts on the whole chip).
|
||
*/
|
||
private reconcile(range: EditRange): void {
|
||
const delta = range.insertedLength - (range.end - range.start)
|
||
const kept: Occurrence[] = []
|
||
for (const o of this.occurrences) {
|
||
if (o.offset < range.start) kept.push(o)
|
||
else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta })
|
||
}
|
||
this.occurrences = kept
|
||
}
|
||
|
||
/** Claimed integrity watch: any mutation that breaks the token prefix releases the claim. */
|
||
private watchClaim(): void {
|
||
if (this.phase === 'claimed' && this.claim !== undefined && !this.draft.startsWith(this.claim.token)) {
|
||
this.phase = 'plain'
|
||
this.claim = undefined
|
||
}
|
||
}
|
||
|
||
/** Mint one occurrence at a draft offset. */
|
||
private mint(reference: ReferenceInsert, offset: number): Occurrence {
|
||
this.occurrenceSeq += 1
|
||
return {
|
||
occurrenceId: this.occurrenceSeq,
|
||
source: reference.source,
|
||
ref: reference.ref,
|
||
offset,
|
||
label: reference.label,
|
||
clipboardText: reference.clipboardText,
|
||
}
|
||
}
|
||
|
||
/** Splice minted entries into the offset-sorted table. */
|
||
private withMinted(minted: readonly Occurrence[]): void {
|
||
if (minted.length === 0) return
|
||
this.occurrences = [...this.occurrences, ...minted].sort((a, b) => a.offset - b.offset)
|
||
}
|
||
|
||
// ---- draft transactions ----
|
||
|
||
private onDraftChanged(draft: string, editRange?: EditRange): InputEffect[] {
|
||
if (draft === this.draft) return []
|
||
const range = editRange ?? diffEdit(this.draft, draft)
|
||
// Single-char typing coalesces into the open run while contiguous and
|
||
// inside the merge window; anything else opens its own transaction.
|
||
const typing = range.start === range.end && range.insertedLength === 1
|
||
const at = this.now()
|
||
const run = this.typingRun
|
||
const merges = typing && run !== undefined && run.end === range.start && at - run.at <= this.mergeWindowMs
|
||
if (!merges) this.pushTxn({ start: range.start, end: range.end })
|
||
this.typingRun = typing ? { end: range.start + 1, at } : undefined
|
||
this.reconcile(range)
|
||
this.adopt(draft)
|
||
this.watchClaim()
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
/** F1: caret newline as an ordinary machine transaction (execCommand path removed). */
|
||
private onNewline(selection: EditSelection): InputEffect[] {
|
||
const { start, end } = selection
|
||
if (start < 0 || start > end || end > this.draft.length) return []
|
||
this.pushTxn(selection)
|
||
this.typingRun = undefined
|
||
this.reconcile({ start, end, insertedLength: 1 })
|
||
this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end))
|
||
this.watchClaim()
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
/** Span CAS: revision equality (content identity follows) plus bounds sanity. */
|
||
private casOk(span: TokenSpan): boolean {
|
||
return span.draftRev === this.draftRev
|
||
&& span.start >= 0 && span.start <= span.end && span.end <= this.draft.length
|
||
}
|
||
|
||
private onBeginCommand(claim: CommandClaim, span: TokenSpan): InputEffect[] {
|
||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||
// Leading-trigger contract: only whitespace may precede the span; the
|
||
// whitespace prefix is dropped so the claimed watch (startsWith) holds.
|
||
if (!this.casOk(span) || this.draft.slice(0, span.start).trim() !== '') return []
|
||
this.pushTxn()
|
||
this.typingRun = undefined
|
||
this.reconcile({ start: 0, end: span.end, insertedLength: claim.token.length })
|
||
this.adopt(claim.token + this.draft.slice(span.end))
|
||
this.claim = claim
|
||
this.phase = 'claimed'
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
private onInsertRef(reference: ReferenceInsert, span: TokenSpan): InputEffect[] {
|
||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||
if (!this.casOk(span)) return []
|
||
this.replaceSpanWithChip(reference, span)
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
/**
|
||
* Shared chip-insertion transaction: replace [span) with one placeholder
|
||
* occurrence (insert-ref and paste-upgrade both land here). A separating
|
||
* space follows the chip unless one is already next.
|
||
* @returns the inserted length (placeholder plus optional gap).
|
||
*/
|
||
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number {
|
||
this.pushTxn()
|
||
this.typingRun = undefined
|
||
const tail = this.draft.slice(span.end)
|
||
const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : ''
|
||
const inserted = PLACEHOLDER + gap
|
||
this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
|
||
this.withMinted([this.mint(reference, span.start)])
|
||
this.adopt(this.draft.slice(0, span.start) + inserted + tail)
|
||
this.watchClaim()
|
||
return inserted.length
|
||
}
|
||
|
||
/**
|
||
* Guarded token deletion after business success (popup settle / menu-pick
|
||
* execute). No effect signals success: the caller reads the draftRev
|
||
* advance off the published state (same currency as the other bail verbs).
|
||
*/
|
||
private onConsumeToken(guard: ConsumeTokenGuard): InputEffect[] {
|
||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||
switch (guard.kind) {
|
||
case 'span': {
|
||
const span = guard.span
|
||
if (!this.casOk(span) || span.start === span.end) return []
|
||
this.pushTxn()
|
||
this.typingRun = undefined
|
||
this.reconcile({ start: span.start, end: span.end, insertedLength: 0 })
|
||
this.adopt(this.draft.slice(0, span.start) + this.draft.slice(span.end))
|
||
this.watchClaim()
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
case 'bare-token': {
|
||
if (guard.token === '' || this.draft.trim() !== guard.token) return []
|
||
this.pushTxn()
|
||
this.typingRun = undefined
|
||
this.occurrences = []
|
||
this.adopt('')
|
||
this.watchClaim()
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
default: return unreachable(guard)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Owner-resolution style bits: exactly the listed occurrences render
|
||
* invalid. Not a transaction — the draft, revision, and undo log are
|
||
* untouched (design §9.1: invalidation never deletes or rewrites chips).
|
||
*/
|
||
private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
|
||
const ids = new Set(invalidIds)
|
||
if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return []
|
||
this.occurrences = this.occurrences.map((o) => {
|
||
const invalid = ids.has(o.occurrenceId)
|
||
if ((o.invalid === true) === invalid) return o
|
||
const { invalid: _drop, ...rest } = o
|
||
return invalid ? { ...rest, invalid: true } : rest
|
||
})
|
||
return []
|
||
}
|
||
|
||
// ---- undo / redo ----
|
||
|
||
private onUndo(): InputEffect[] {
|
||
const entry = this.log.pop()
|
||
if (entry === undefined) return []
|
||
this.redoStack.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences })
|
||
this.occurrences = entry.occurrencesBefore
|
||
this.adopt(entry.draftBefore)
|
||
this.watchClaim()
|
||
this.typingRun = undefined
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
private onRedo(): InputEffect[] {
|
||
const entry = this.redoStack.pop()
|
||
if (entry === undefined) return []
|
||
// Manual log push: pushTxn would cut the redo chain being walked.
|
||
this.log.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences })
|
||
if (this.log.length > LOG_LIMIT) this.log.shift()
|
||
this.occurrences = entry.occurrencesBefore
|
||
this.adopt(entry.draftBefore)
|
||
this.watchClaim()
|
||
this.typingRun = undefined
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
// ---- paste plane ----
|
||
|
||
/**
|
||
* Paste as one transaction: the text (U+FFFC-sanitized) replaces the
|
||
* selection; hot-snapshot sync matches componentize inside the SAME
|
||
* transaction (one undo returns to pre-paste); a match attempt opens for
|
||
* the async remainder while the phase still accepts reference mutations.
|
||
*/
|
||
private onPasteBegin(
|
||
rawText: string, selection: EditSelection,
|
||
components: readonly PasteComponent[] = [], generation = 0,
|
||
): InputEffect[] {
|
||
const { start, end } = selection
|
||
if (start < 0 || start > end || end > this.draft.length) return []
|
||
const text = rawText.split(PLACEHOLDER).join('')
|
||
this.pushTxn(selection)
|
||
this.typingRun = undefined
|
||
// Componentize: replace each matched token range (paste-text coordinates,
|
||
// disjoint by contract) with a placeholder while assembling the insert.
|
||
const sorted = [...components].sort((a, b) => a.start - b.start)
|
||
const minted: Occurrence[] = []
|
||
let inserted = ''
|
||
let cursor = 0
|
||
for (const c of sorted) {
|
||
inserted += text.slice(cursor, c.start)
|
||
minted.push(this.mint(c.reference, start + inserted.length))
|
||
inserted += PLACEHOLDER
|
||
cursor = c.end
|
||
}
|
||
inserted += text.slice(cursor)
|
||
this.reconcile({ start, end, insertedLength: inserted.length })
|
||
this.withMinted(minted)
|
||
this.adopt(this.draft.slice(0, start) + inserted + this.draft.slice(end))
|
||
this.watchClaim()
|
||
if (this.phase === 'plain' || this.phase === 'claimed') {
|
||
this.pasteSeq += 1
|
||
this.paste = {
|
||
attemptId: this.pasteSeq,
|
||
insertedRange: { start, end: start + inserted.length },
|
||
generation,
|
||
}
|
||
} else {
|
||
this.paste = undefined
|
||
}
|
||
return []
|
||
}
|
||
|
||
/**
|
||
* Async match landed: upgrade one pasted token to a chip as an INDEPENDENT
|
||
* transaction (undo #1 → the token text, undo #2 → pre-paste). The attempt
|
||
* stays current — later tokens re-CAS against the advanced draftRev.
|
||
*/
|
||
private onPasteUpgrade(attemptId: number, span: TokenSpan, reference: ReferenceInsert): InputEffect[] {
|
||
const attempt = this.paste
|
||
if (attempt === undefined || attempt.attemptId !== attemptId) return []
|
||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||
if (!this.casOk(span) || span.start === span.end) return []
|
||
const insertedLength = this.replaceSpanWithChip(reference, span)
|
||
this.paste = {
|
||
...attempt,
|
||
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) },
|
||
}
|
||
return []
|
||
}
|
||
|
||
// ---- submit plane ----
|
||
|
||
/** Mint the next SubmitAttempt and take the in-flight slot. */
|
||
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
|
||
const controller = new AbortController()
|
||
this.seq += 1
|
||
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
|
||
this.inflight = { attempt, controller, mode }
|
||
return attempt
|
||
}
|
||
|
||
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
|
||
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
|
||
if (this.phase === 'claimed' && this.claim !== undefined) {
|
||
const attempt = this.beginAttempt(mode)
|
||
this.phase = 'submitting'
|
||
this.paste = undefined
|
||
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
|
||
}
|
||
const trimmed = this.draft.trim()
|
||
if (trimmed === '') return []
|
||
this.paste = undefined
|
||
if (trimmed.startsWith('/')) {
|
||
const attempt = this.beginAttempt(mode)
|
||
this.phase = 'adjudicating'
|
||
return [{ type: 'adjudicate', attempt, draft: this.draft }]
|
||
}
|
||
return [{ type: 'default-sink', draft: this.draft, mode }]
|
||
}
|
||
|
||
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
|
||
const flight = this.inflight
|
||
if (this.phase !== 'adjudicating' || flight === undefined || flight.attempt.seq !== attempt.seq) return []
|
||
if (outcome !== undefined && outcome !== 'handled' && 'claim' in outcome) {
|
||
this.claim = outcome.claim
|
||
this.phase = 'submitting'
|
||
return [{
|
||
type: 'begin-submit',
|
||
attempt,
|
||
claim: outcome.claim,
|
||
args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
|
||
}]
|
||
}
|
||
// 'handled' (source dealt internally), {insert} (no enter-time span
|
||
// semantics), or a miss: all land plain; only the miss flows to the sink.
|
||
this.inflight = undefined
|
||
this.phase = 'plain'
|
||
return outcome === undefined
|
||
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
|
||
: []
|
||
}
|
||
|
||
private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] {
|
||
if (this.phase !== 'adjudicating' || this.inflight?.attempt.seq !== attempt.seq) return []
|
||
this.inflight = undefined
|
||
this.phase = 'plain'
|
||
// Draft retained: warmup failure never silently downgrades to a prompt.
|
||
return [{ type: 'notice', level: 'error', text: message }]
|
||
}
|
||
|
||
private onSubmitSettled(ev: Extract<InputEvent, { type: 'submit-settled' }>): InputEffect[] {
|
||
const flight = this.inflight
|
||
if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return []
|
||
this.inflight = undefined
|
||
if (ev.ok) {
|
||
this.phase = 'plain'
|
||
this.claim = undefined
|
||
this.occurrences = []
|
||
this.adopt('')
|
||
// Committed content is gone for good: undo must not resurrect a sent draft.
|
||
this.log = []
|
||
this.redoStack = []
|
||
this.typingRun = undefined
|
||
this.paste = undefined
|
||
return ev.outcome?.text !== undefined
|
||
? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }]
|
||
: []
|
||
}
|
||
const text = ev.message ?? ev.outcome?.text ?? 'command failed'
|
||
// Drift guard: keep the enter-time draft (same claim) only while the
|
||
// live draft still equals it; user input typed during flight wins.
|
||
// Claimed re-entry additionally requires the watch to hold — an
|
||
// enter-path snapshot may carry leading whitespace the token never had.
|
||
if (this.draft === flight.attempt.draftSnapshot
|
||
&& this.claim !== undefined && this.draft.startsWith(this.claim.token)) {
|
||
this.phase = 'claimed'
|
||
return [{ type: 'notice', level: 'error', text }]
|
||
}
|
||
this.phase = 'plain'
|
||
this.claim = undefined
|
||
return [{ type: 'notice', level: 'error', text }]
|
||
}
|
||
|
||
/** Ordinary send accepted: clear as a commit (no undo unit; sent content
|
||
* must not be resurrectable — same discipline as submit-settled success). */
|
||
private onSendCommitted(): InputEffect[] {
|
||
this.claim = undefined
|
||
this.occurrences = []
|
||
this.adopt('')
|
||
this.log = []
|
||
this.redoStack = []
|
||
this.typingRun = undefined
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
|
||
private onRelease(): InputEffect[] {
|
||
if (this.inflight !== undefined) {
|
||
this.inflight.controller.abort()
|
||
this.inflight = undefined
|
||
}
|
||
this.phase = 'plain'
|
||
this.claim = undefined
|
||
this.typingRun = undefined
|
||
this.paste = undefined
|
||
return []
|
||
}
|
||
}
|