Selaa lähdekoodia

feat(ui-conversation): lexical composer replaces the textarea stack

The editor (shell-owned, per-session) is the draft + chip truth; the
machine slims to the submit plane. Chips are atomic decorator nodes with
NodeKey identity; TokenSpan coordinates ride the detect projection (chip =
one U+FFFC), persistence and InputState.draft ride the clipboard
projection. The mirror/backdrop layers, Safari soft-wrap repair, manual
undo log, boundary occurrence deletion, and clipboard expansion all
retire; the producerless paste-attempt and set-invalid planes go with
them.
Yichen Jiang 1 kuukausi sitten
vanhempi
sitoutus
b519cb87b0
25 muutettua tiedostoa jossa 1827 lisäystä ja 2704 poistoa
  1. 61 110
      packages/client/ui-conversation/src/client/input/contract.ts
  2. 6 59
      packages/client/ui-conversation/src/client/input/decorations.ts
  3. 50 0
      packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx
  4. 41 0
      packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx
  5. 1 1
      packages/client/ui-conversation/src/client/input/editor/chip-node.tsx
  6. 69 0
      packages/client/ui-conversation/src/client/input/editor/claim-decor.ts
  7. 11 0
      packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css
  8. 149 0
      packages/client/ui-conversation/src/client/input/editor/keymap.ts
  9. 32 4
      packages/client/ui-conversation/src/client/input/editor/projection.ts
  10. 10 0
      packages/client/ui-conversation/src/client/input/editor/span-map.ts
  11. 138 0
      packages/client/ui-conversation/src/client/input/editor/text-ref.ts
  12. 318 138
      packages/client/ui-conversation/src/client/input/facade.ts
  13. 48 426
      packages/client/ui-conversation/src/client/input/machine.ts
  14. 31 156
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  15. 159 426
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  16. 28 16
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  17. 194 376
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  18. 0 927
      packages/client/ui-conversation/tests/input-machine.client.spec.ts
  19. 32 23
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  20. 15 12
      packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts
  21. 22 13
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  22. 32 0
      packages/client/ui-conversation/tests/keydown-probe.client.spec.tsx
  23. 1 1
      packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx
  24. 23 16
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  25. 356 0
      packages/client/ui-conversation/tests/submit-machine.client.spec.ts

+ 61 - 110
packages/client/ui-conversation/src/client/input/contract.ts

@@ -2,11 +2,13 @@
  * Frozen input-machine contract. 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.
+ * conversation wiring layer alone sees the full SessionInput. The draft text
+ * and its reference chips live in the shell's Lexical editor; the machine
+ * here is the submit plane (phase, claim, attempt) alone.
  */
 import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { LexicalEditor } from 'lexical'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -19,19 +21,19 @@ 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).
+ * and the boolean answer IS the event's bail value (true ⟺ the editor
+ * applied the edit after phase and span 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). */
+  /** Replace the trigger span with one reference chip (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). */
+  /** Replace the whole draft (persisted-draft seed and programmatic writes). */
   setDraft(text: string): void
   /** Append ordered browser-owned image ids; busy admission phases refuse. */
   addImages(ids: readonly DraftAttachmentId[]): boolean
@@ -66,12 +68,12 @@ export interface SessionInputResolver {
 
 /**
  * The public input action face provided to every session-scope slot
- * component: two 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.
+ * component: stable-identity void callbacks, mirroring the
+ * useStore+actions convention. Command-style handles (arbitrate/space/
+ * 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). */
+  /** Replace the whole draft (persisted-draft seed and programmatic writes). */
   setDraft(text: string): void
   /** Append ordered browser-owned image ids; busy admission phases refuse. */
   addImages(ids: readonly DraftAttachmentId[]): boolean
@@ -95,13 +97,15 @@ export interface InputNotice {
  * 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.
+ * satisfies it structurally. Text editing itself rides the shell's Lexical
+ * editor (exposed here for the contenteditable binding); the members below
+ * are the submit-plane and trigger-pipeline verbs the editor does not own.
  */
 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
+  /** The shell-owned Lexical editor the composer binds its contenteditable to. */
+  readonly editor: LexicalEditor
   /** Submit with an explicit delivery mode resolved by the keyboard policy. */
   submit(mode: InputSubmitMode): void
   /**
@@ -110,14 +114,14 @@ export interface ComposerKeyboard {
    * button is the same operation applied to the whole queue).
    */
   steerQueue(): 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
+  /** Insert pasted plain text over the current editor selection (reference-placeholder-sanitized). */
+  paste(text: string): void
+  /**
+   * The live selection as a detect-coordinate span (menu-launcher synthetic
+   * hits replace it on pick); an absent selection answers a collapsed span at
+   * the document end.
+   */
+  caretSpan(): EditSelection
   /** 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. */
@@ -129,42 +133,33 @@ export interface ComposerKeyboard {
 /** 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. */
+/** Guard union of the scoped consume-token event, checked by the shell. */
 export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
 
-/** Half-open [start, end) range/selection in draft character coordinates. */
+/** Half-open [start, end) range/selection in detect-projection 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 occurrence backed by its complete inline display text in the
- * draft. Identity is occurrenceId — same-named
- * references stay independently addressable. label/appearance/clipboardText are the
- * owner's insert-time projections, cached so the chip survives owner loss
- * (invalid flips instead of dropping the occurrence).
+ * One reference occurrence projected from the editor's chip nodes, in
+ * clipboard-text coordinates. Identity is occurrenceId — a stable per-shell
+ * assignment per chip NodeKey, so same-named references stay independently
+ * addressable and survive undo. label/appearance/clipboardText are the
+ * owner's insert-time projections cached on the node (invalid flips instead
+ * of dropping the occurrence).
  */
 export interface Occurrence {
-  /** Machine-minted stable identity (monotonic per machine). */
+  /** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */
   readonly occurrenceId: number
   /** Owning source name (serializer routing key). */
   readonly source: string
   /** Owner-scoped reference id. */
   readonly ref: string
-  /** Display-text offset in the draft. */
+  /** Offset in the clipboard-text projection. */
   readonly offset: number
-  /** Display-text length; the occurrence occupies exactly [offset, offset+length). */
+  /** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */
   readonly length: number
   /** Inline display label (insert-time cache). */
   readonly label: string
@@ -176,53 +171,19 @@ export interface Occurrence {
   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 (the clipboard 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 {
+  /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */
   readonly draft: string
   /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */
   readonly imageIds: readonly DraftAttachmentId[]
-  /** Monotonic draft revision (span CAS compares against this). */
+  /** Monotonic editor 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; readonly images?: boolean }
-  /** Reference occurrence table, sorted by offset. */
+  /** Reference occurrence view of the editor's chips, sorted by offset. */
   readonly occurrences: readonly Occurrence[]
-  /** Live paste-match attempt (absent when no paste is matchable). */
-  readonly paste?: PasteAttemptState
   /** Read-only transient inbox projection (`session/queue`, including pending steering). */
   readonly queue: readonly QueuedMessage[]
 }
@@ -236,56 +197,46 @@ export interface InputState {
 export interface SubmitAttempt {
   readonly seq: number
   readonly signal: AbortSignal
-  /** Draft at enter time; settlement clears it only after acceptance. */
+  /** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */
   readonly draftSnapshot: string
   /** Default-message delivery intent retained while slash adjudication is pending. */
   readonly mode: InputSubmitMode
 }
 
 /**
- * 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.
+ * Submit-machine input events (the machine's single write path). Text
+ * mutation lives in the editor; the machine only observes the draft through
+ * event payloads (claim integrity, enter snapshots, settlement decisions).
  */
 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 }
-  | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
-  /** Place one inline reference 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 mode: InputSubmitMode }
+  /** Clipboard projection changed: the claimed integrity watch runs (zero effects). */
+  | { readonly type: 'draft-changed'; readonly draft: string }
+  /** The editor applied a claim-token replacement: enter claimed. */
+  | { readonly type: 'claim'; readonly claim: CommandClaim }
+  /** Enter submission with the current clipboard projection. */
+  | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string }
   | { 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 }
+  /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
+  | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string }
   /** Commit an image-only send whose empty draft did not need an attempt. */
   | { 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.
+ * Submit-machine output effects (executed by the SessionInput shell; the
+ * machine stays pure).
  */
 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 attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
+  /**
+   * Clear the committed draft in the editor and cut undo history. A string
+   * snapshot keeps a pure suffix typed during the Host round-trip (content
+   * appended after the sent snapshot survives; interleaved edits cannot be
+   * separated and clear whole); null clears unconditionally (image-only
+   * sends have no draft to retain).
+   */
+  | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null }

+ 6 - 59
packages/client/ui-conversation/src/client/input/decorations.ts

@@ -1,33 +1,11 @@
 /**
- * Draft decoration pure core (references render from occurrence ranges; the
- * claim token renders as a mirror-layer
- * highlight, the claim hint as ghost text). Zero React — the skeleton renders
- * the instructions; tests drive this directly.
+ * Plain-text reference scan (the plain-text-reference decision;
+ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
+ * a `/name` or `@name` token whose name is on the trigger's lexicon, and
+ * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's
+ * text-ref entity transform consumes these ranges; editing the text out of
+ * match shape simply drops the range next scan.
  */
-import type { InputState } from './contract.ts'
-
-/** The claim-token highlight range (always draft-leading while the watch holds). */
-export interface TokenRange {
-  readonly start: number
-  readonly end: number
-}
-
-/** One structured inline-reference render instruction. */
-export interface ChipRender {
-  /** Stable render key (same-labeled chips stay independent). */
-  readonly occurrenceId: number
-  /** Display-text offset in the draft. */
-  readonly offset: number
-  /** Display-text length in the draft. */
-  readonly length: number
-  /** Exact inline text whose native glyph metrics determine layout. */
-  readonly text: string
-  readonly label: string
-  /** Optional domain glyph beside the label. */
-  readonly appearance?: 'session' | 'file' | 'folder'
-  /** Owner-resolution failure styling bit. */
-  readonly invalid: boolean
-}
 
 /**
  * One plain-text reference range (the plain-text-reference decision;
@@ -98,34 +76,3 @@ export function scanTextRefs(
   }
   return out.sort((left, right) => left.start - right.start)
 }
-
-/** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */
-const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
-
-/**
- * Derive the mirror-layer decorations from the input state.
- * @param state - published input state.
- * @param lexicon - optional per-trigger reference lexicons (plain-text-reference scan).
- * @returns token range, chip instructions, text-ref ranges, and the ghost hint.
- */
-export function deriveDecorations(
-  state: InputState, lexicon: ReadonlyMap<'/' | '@', readonly string[]> = EMPTY_LEXICON,
-): DraftDecorations {
-  const { draft, claim, phase, occurrences } = state
-  const claimActive = (phase === 'claimed' || phase === 'submitting')
-    && claim !== undefined && draft.startsWith(claim.token)
-  const token: TokenRange | null = claimActive ? { start: 0, end: claim.token.length } : null
-  const chips = occurrences.map(o => ({
-    occurrenceId: o.occurrenceId,
-    offset: o.offset,
-    length: o.length,
-    text: draft.slice(o.offset, o.offset + o.length),
-    label: o.label,
-    ...o.appearance === undefined ? {} : { appearance: o.appearance },
-    invalid: o.invalid === true,
-  }))
-  const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === ''
-    ? claim.hint
-    : null
-  return { token, chips, textRefs: scanTextRefs(draft, lexicon), hint }
-}

+ 50 - 0
packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx

@@ -0,0 +1,50 @@
+/**
+ * The composer's contenteditable host: binds one shell-owned Lexical editor
+ * to a resident div. Session-maybe by design — a null editor renders the
+ * same DOM inert (the no-session Workspace-trigger state), so switching
+ * between the two never swaps the element tree. Editability has ONE writer:
+ * this component reflects the `editable` prop onto the editor; nothing else
+ * calls setEditable.
+ */
+import { useLayoutEffect, useRef } from 'react'
+import type { HTMLAttributes, ReactNode } from 'react'
+import type { LexicalEditor } from 'lexical'
+
+/** Host props: the editor binding plus the div passthroughs the bar owns. */
+export interface ComposerContentEditableProps extends HTMLAttributes<HTMLDivElement> {
+  /** The shell-owned editor; null renders the same div unbound and inert. */
+  readonly editor: LexicalEditor | null
+  /** Whether the user may edit (readOnly/disabled states fold in here). */
+  readonly editable: boolean
+}
+
+/**
+ * Render the composer's editable surface.
+ * @param props - editor binding, editability, and div passthroughs.
+ * @returns the resident contenteditable div.
+ */
+export function ComposerContentEditable({ editor, editable, ...rest }: ComposerContentEditableProps): ReactNode {
+  const ref = useRef<HTMLDivElement | null>(null)
+  useLayoutEffect(() => {
+    const el = ref.current
+    if (editor === null || el === null) return
+    editor.setRootElement(el)
+    return () => { editor.setRootElement(null) }
+  }, [editor])
+  useLayoutEffect(() => {
+    if (editor !== null) editor.setEditable(editable)
+  }, [editor, editable])
+  return (
+    <div
+      ref={ref}
+      // Lexical's setRootElement never touches contenteditable; the binding
+      // renders it, and setEditable above keeps the editor's own gate in step.
+      contentEditable={editor !== null && editable}
+      suppressContentEditableWarning
+      role="textbox"
+      aria-multiline="true"
+      data-composer-input
+      {...rest}
+    />
+  )
+}

+ 41 - 0
packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx

@@ -0,0 +1,41 @@
+/**
+ * Decorator render loop: portals every decorator node's React face into its
+ * host element (what @lexical/react's composer does internally, scoped to
+ * this composer's needs). Chip DOM identity rides the NodeKey — text edits
+ * around a chip never remount its portal.
+ */
+import * as React from 'react'
+import { createPortal } from 'react-dom'
+import type { ReactNode } from 'react'
+import type { LexicalEditor, NodeKey } from 'lexical'
+
+/** Portal-loop props. */
+export interface DecoratorPortalsProps {
+  /** The bound editor; null (no-session) renders nothing. */
+  readonly editor: LexicalEditor | null
+}
+
+/**
+ * Render every decorator's React face into its editor host element.
+ * @param props - the editor to observe.
+ * @returns the live portal set.
+ */
+export function DecoratorPortals({ editor }: DecoratorPortalsProps): ReactNode {
+  const [decorators, setDecorators] = React.useState<Record<NodeKey, React.JSX.Element>>(
+    () => editor === null ? {} : editor.getDecorators<React.JSX.Element>(),
+  )
+  React.useLayoutEffect(() => {
+    if (editor === null) return
+    setDecorators(editor.getDecorators<React.JSX.Element>())
+    return editor.registerDecoratorListener<React.JSX.Element>((next) => { setDecorators(next) })
+  }, [editor])
+  if (editor === null) return null
+  return (
+    <>
+      {Object.entries(decorators).map(([key, jsx]) => {
+        const el = editor.getElementByKey(key)
+        return el === null ? null : createPortal(jsx, el, key)
+      })}
+    </>
+  )
+}

+ 1 - 1
packages/client/ui-conversation/src/client/input/editor/chip-node.tsx

@@ -119,7 +119,7 @@ export class ReferenceChipNode extends DecoratorNode<JSX.Element> {
   override createDOM(_config: EditorConfig): HTMLElement {
     const el = document.createElement('span')
     el.setAttribute('data-composer-chip', this.__source)
-    el.contentEditable = 'false'
+    el.setAttribute('contenteditable', 'false')
     return el
   }
 

+ 69 - 0
packages/client/ui-conversation/src/client/input/editor/claim-decor.ts

@@ -0,0 +1,69 @@
+/**
+ * Claim-token highlight: while a command claim holds, the draft's leading
+ * token renders in the warn color. A TextNode transform keeps the token in
+ * its own styled node (splitting when typing merges text into it), and the
+ * shell nudges the first leaf dirty when the claim flips so entering and
+ * leaving claimed restyles without a text edit.
+ */
+import type { LexicalEditor, TextNode as TextNodeType } from 'lexical'
+import { $getRoot, $isElementNode, $isTextNode, TextNode } from 'lexical'
+
+/** Inline style carried by the claim-token node (the old backdrop's hlToken color). */
+const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)'
+
+/** The document's first text leaf, or null (empty document / leading chip). */
+function firstTextLeaf(): TextNodeType | null {
+  const block = $getRoot().getFirstChild()
+  if (!$isElementNode(block)) return null
+  const leaf = block.getFirstChild()
+  return $isTextNode(leaf) ? leaf : null
+}
+
+/**
+ * Register the claim-token styling transform.
+ * @param editor - the shell-owned editor.
+ * @param activeToken - live claim token accessor; null while unclaimed.
+ * @returns the unregister disposer.
+ */
+export function registerClaimDecoration(editor: LexicalEditor, activeToken: () => string | null): () => void {
+  return editor.registerNodeTransform(TextNode, (node) => {
+    const first = firstTextLeaf()
+    if (first === null || node.getKey() !== first.getKey()) {
+      // Off the token seat: clear a stale token style (a node can move here
+      // by paragraph merges).
+      if (node.getStyle() === TOKEN_STYLE && (first === null || node.getKey() !== first.getKey())) {
+        node.setStyle('')
+      }
+      return
+    }
+    const token = activeToken()
+    const text = node.getTextContent()
+    if (token === null || !text.startsWith(token)) {
+      if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
+      return
+    }
+    if (text.length > token.length) {
+      // Typing at the token boundary lands in the styled node; split the
+      // overflow back out so only the token itself carries the color.
+      const [tokenNode] = node.splitText(token.length)
+      if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE)
+      return
+    }
+    if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE)
+  })
+}
+
+/**
+ * Nudge the token seat dirty so the transform restyles after a claim flip
+ * (claims change phase without a text edit; transforms only run on dirty
+ * nodes).
+ * @param editor - the shell-owned editor.
+ */
+export function refreshClaimDecoration(editor: LexicalEditor): void {
+  // Not discrete: a refresh can fire from inside an update listener, where a
+  // synchronous nested commit would recurse; the queued update lands on the
+  // next flush.
+  editor.update(() => {
+    firstTextLeaf()?.markDirty()
+  })
+}

+ 11 - 0
packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css

@@ -0,0 +1,11 @@
+/* Editor-internal decoration styles: nodes Lexical mounts inside the
+   contenteditable (chip hosts get their look from ReferenceChip.module.css;
+   this sheet covers text-level decorations). */
+
+/* Plain-text reference: chip family colors over the draft's own glyphs.
+   clone keeps rounded ends on soft-wrap fragments. */
+.textRef {
+  color: var(--dsw-alias-state-business-primary);
+  box-decoration-break: clone;
+  -webkit-box-decoration-break: clone;
+}

+ 149 - 0
packages/client/ui-conversation/src/client/input/editor/keymap.ts

@@ -0,0 +1,149 @@
+/**
+ * Composer keymap over the Lexical command layer: menu arbitration
+ * (arrows/escape/enter), space adjudication, the Enter submit gesture, and
+ * paste routing. Registered at CRITICAL priority so it decides before
+ * @lexical/plain-text's own Enter/paste defaults; a handler returning false
+ * falls through to those defaults (Shift+Enter's line break, ordinary
+ * spaces, text paste the bar routes itself).
+ *
+ * IME guard: a composition-closing Enter/Space must not submit or adjudicate.
+ * KeyboardEvent.isComposing covers most engines; Safari delivers the closing
+ * keydown AFTER compositionend, so a root-element composition watch holds the
+ * guard for 10ms more (the old textarea's proven window); keyCode
+ * 229 is the legacy signal engines emit without isComposing.
+ */
+import type { LexicalEditor } from 'lexical'
+import {
+  COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND,
+  KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, PASTE_COMMAND,
+} from 'lexical'
+import { mergeRegister } from '@lexical/utils'
+import type { ArbitrateKey, ArbitrateOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+
+/** The bar-supplied behavior behind each intercepted gesture. */
+export interface ComposerKeymapHandlers {
+  /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
+  arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
+  /** Space adjudication; true = a claim was applied — the keystroke is consumed. */
+  space(): boolean
+  /** Dismiss the popupSelect shell (Escape layering: an open overlay closes first). */
+  dismissPopup(): void
+  /** Whether Enter may submit right now (locked/busy states refuse). */
+  canSubmit(): boolean
+  /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */
+  submit(accelerated: boolean): void
+  /** Pasted files (image intake). */
+  intakeFiles(files: readonly File[]): void
+  /** Pasted plain text (sanitized insertion through the shell). */
+  pasteText(text: string): void
+}
+
+/** Composition state a keydown can trust (see the module doc's Safari note). */
+function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean): boolean {
+  // keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
+  return event.isComposing || event.keyCode === 229 || recentlyComposing()
+}
+
+/**
+ * Register the composer keymap on one editor.
+ * @param editor - the shell-owned editor.
+ * @param handlers - bar-supplied behavior.
+ * @returns the unregister disposer.
+ */
+export function registerComposerKeymap(editor: LexicalEditor, handlers: ComposerKeymapHandlers): () => void {
+  // Composition watch: true through composition and for one tick after
+  // compositionend (Safari's late closing keydown). The listener rides the
+  // root element and re-arms on root swaps.
+  let composing = false
+  let composingUntil = 0
+  const onCompositionStart = (): void => {
+    composing = true
+  }
+  const onCompositionEnd = (): void => {
+    composing = false
+    composingUntil = Date.now() + 10
+  }
+  const recentlyComposing = (): boolean => composing || Date.now() < composingUntil
+
+  const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => {
+    const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
+    if (handlers.arbitrate(key, inComposition) === 'consumed') {
+      event?.preventDefault()
+      return true
+    }
+    return false
+  }
+
+  return mergeRegister(
+    editor.registerRootListener((root, prevRoot) => {
+      prevRoot?.removeEventListener('compositionstart', onCompositionStart)
+      prevRoot?.removeEventListener('compositionend', onCompositionEnd)
+      root?.addEventListener('compositionstart', onCompositionStart)
+      root?.addEventListener('compositionend', onCompositionEnd)
+    }),
+    editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
+      // Escape layering: an open overlay closes; claimed without an overlay
+      // does NOT release (backspacing the token is the only exit gesture).
+      handlers.dismissPopup()
+      const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
+      if (handlers.arbitrate('escape', inComposition) === 'consumed') {
+        event?.preventDefault()
+        return true
+      }
+      return false
+    }, COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_SPACE_COMMAND, (event) => {
+      if (isComposingEvent(event, recentlyComposing)) return false
+      const consumed = handlers.space()
+      console.log('[probe] space() ->', consumed)
+      if (consumed) {
+        event.preventDefault() // claim token already carries the trailing separator
+        return true
+      }
+      return false
+    }, COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
+      // Shift+Enter is the native line break UNCONDITIONALLY — decided before
+      // the IME guard so a composition-closing Shift+Enter still breaks the line.
+      if (event?.shiftKey === true) return false
+      if (event !== null && isComposingEvent(event, recentlyComposing)) {
+        // The IME consumes this Enter (candidate pick); neither submit nor
+        // break the line. No preventDefault: the browser owns the gesture.
+        return true
+      }
+      // Menu-open Enter picks the highlight through arbitration; a
+      // no-highlight menu passes down to the submit gesture.
+      if (handlers.arbitrate('enter', false) !== 'pass') {
+        event?.preventDefault()
+        return true
+      }
+      event?.preventDefault()
+      if (event?.repeat === true) return true // held-down Enter must not machine-gun sends
+      if (!handlers.canSubmit()) return true
+      handlers.submit(event?.ctrlKey === true || event?.metaKey === true)
+      return true
+    }, COMMAND_PRIORITY_CRITICAL),
+    editor.registerCommand(PASTE_COMMAND, (event) => {
+      // Duck-typed: the payload union includes InputEvent, and test engines
+      // deliver clipboardData on plain events.
+      const clipboardData = (event as ClipboardEvent).clipboardData ?? null
+      if (clipboardData === null) return false
+      const files = Array.from(clipboardData.items)
+        .filter(item => item.kind === 'file')
+        .map(item => item.getAsFile())
+        .filter((file): file is File => file !== null)
+      if (files.length > 0) handlers.intakeFiles(files)
+      const text = clipboardData.getData('text/plain')
+      if (text === '') {
+        if (files.length === 0) return false
+        event.preventDefault()
+        return true
+      }
+      event.preventDefault()
+      handlers.pasteText(text)
+      return true
+    }, COMMAND_PRIORITY_CRITICAL),
+  )
+}

+ 32 - 4
packages/client/ui-conversation/src/client/input/editor/projection.ts

@@ -130,6 +130,26 @@ export function $composerLayout(): ComposerLayout {
   }
 }
 
+/**
+ * Fold one clipboard-projection offset to its detect-projection twin.
+ * Offsets inside a chip's clipboard expansion snap to the chip's trailing
+ * edge; callers only pass boundaries that were once a document end (submit
+ * snapshots), which never split a chip.
+ * @param layout - the current walk product.
+ * @param clipboardOffset - offset into the clipboard projection.
+ * @returns the detect offset covering the same document position.
+ */
+export function detectOffsetOfClipboardOffset(layout: ComposerLayout, clipboardOffset: number): number {
+  for (const segment of layout.segments) {
+    const end = segment.clipboardStart + segment.clipboardLength
+    if (clipboardOffset > end) continue
+    if (clipboardOffset === end) return segment.detectStart + segment.detectLength
+    if (segment.kind === 'chip') return segment.detectStart + segment.detectLength
+    return segment.detectStart + (clipboardOffset - segment.clipboardStart)
+  }
+  return layout.detectLength
+}
+
 /** The published projection product consumed by the shell every update. */
 export interface EditorProjection {
   /** Trigger/TokenSpan coordinate text (chip = one U+FFFC). */
@@ -138,6 +158,8 @@ export interface EditorProjection {
   readonly clipboardText: string
   /** InputState-compatible occurrence view (clipboardText coordinates). */
   readonly occurrences: readonly Occurrence[]
+  /** Range selection in detect coordinates (ordered); null while absent or non-range. */
+  readonly selection: { readonly start: number; readonly end: number } | null
   /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */
   readonly caret: number | null
 }
@@ -190,13 +212,19 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti
     })
   }
   const selection = $getSelection()
-  const caret = $isRangeSelection(selection) && selection.isCollapsed()
-    ? $detectOffsetOfPoint(layout, selection.anchor)
-    : null
+  let range: { start: number; end: number } | null = null
+  if ($isRangeSelection(selection)) {
+    const anchor = $detectOffsetOfPoint(layout, selection.anchor)
+    const focus = $detectOffsetOfPoint(layout, selection.focus)
+    if (anchor !== null && focus !== null) {
+      range = { start: Math.min(anchor, focus), end: Math.max(anchor, focus) }
+    }
+  }
   return {
     detectText: layout.detectText,
     clipboardText: layout.clipboardText,
     occurrences,
-    caret,
+    selection: range,
+    caret: range !== null && range.start === range.end ? range.start : null,
   }
 }

+ 10 - 0
packages/client/ui-conversation/src/client/input/editor/span-map.ts

@@ -79,6 +79,16 @@ function selectSpan(layout: ComposerLayout, span: DetectSpan): RangeSelection |
   return selection
 }
 
+/**
+ * Select one detect span (collapsed spans place the caret). Exposed for the
+ * shell's caret placement and tests; the replace helpers below build on it.
+ * @param span - detect span to select.
+ * @returns whether both endpoints mapped.
+ */
+export function $selectDetectSpan(span: DetectSpan): boolean {
+  return selectSpan($composerLayout(), span) !== null
+}
+
 /**
  * Replace one detect span with plain text (empty text deletes the span).
  * The caret lands after the insertion.

+ 138 - 0
packages/client/ui-conversation/src/client/input/editor/text-ref.ts

@@ -0,0 +1,138 @@
+/**
+ * Plain-text reference decoration (the plain-text-reference decision;
+ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
+ * a `/name` or `@name` token whose name is on the trigger's lexicon, and
+ * syntax-recognizable `@dir/` folder tokens, render in the chip family
+ * colors. Pure derivation as before — the entity transform converts matching
+ * text into TextRefNode and back as edits move it in and out of match shape;
+ * no occurrence identity exists.
+ */
+import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical'
+import { TextNode } from 'lexical'
+import { registerLexicalTextEntity } from '@lexical/text'
+import { mergeRegister } from '@lexical/utils'
+import { $getRoot } from 'lexical'
+import { scanTextRefs } from '../decorations.ts'
+import css from './composer-editor.module.css'
+
+/** JSON form of one text-ref node. */
+export type SerializedTextRefNode = Spread<{
+  appearance?: 'folder'
+}, SerializedTextNode>
+
+/** One matched plain-text reference as a styled, fully editable text node. */
+export class TextRefNode extends TextNode {
+  /** Optional icon domain for syntax-recognizable plain references. */
+  __appearance: 'folder' | undefined
+
+  /** Lexical node registry type tag. */
+  static override getType(): string {
+    return 'composer-text-ref'
+  }
+
+  /**
+   * Clone with identity (Lexical writable-copy contract).
+   * @param node - node to clone.
+   * @returns a copy carrying the same NodeKey.
+   */
+  static override clone(node: TextRefNode): TextRefNode {
+    return new TextRefNode(node.__text, node.__appearance, node.__key)
+  }
+
+  /**
+   * Rebuild one text-ref from its JSON form.
+   * @param json - serialized node.
+   * @returns a fresh node.
+   */
+  static override importJSON(json: SerializedTextRefNode): TextRefNode {
+    const node = new TextRefNode(json.text, json.appearance)
+    node.setFormat(json.format)
+    node.setDetail(json.detail)
+    node.setMode(json.mode)
+    node.setStyle(json.style)
+    return node
+  }
+
+  /**
+   * @param text - the matched token text.
+   * @param appearance - optional icon domain (folder tokens).
+   * @param key - Lexical clone-path key; absent for fresh nodes.
+   */
+  constructor(text: string, appearance?: 'folder', key?: NodeKey) {
+    super(text, key)
+    this.__appearance = appearance
+  }
+
+  /** Serialize to the JSON node form. */
+  override exportJSON(): SerializedTextRefNode {
+    return {
+      ...super.exportJSON(),
+      type: 'composer-text-ref',
+      ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }),
+    }
+  }
+
+  /** Style the span the base TextNode mounts. */
+  override createDOM(config: EditorConfig): HTMLElement {
+    const el = super.createDOM(config)
+    el.classList.add(css.textRef ?? 'textRef')
+    el.setAttribute('data-composer-text-ref', '')
+    if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance)
+    return el
+  }
+
+  /** Entity nodes never merge with plain siblings (the transform owns their bounds). */
+  override isTextEntity(): true {
+    return true
+  }
+
+  /** Editing continues inside; the transform re-evaluates match shape per edit. */
+  override canInsertTextBefore(): boolean {
+    return true
+  }
+}
+
+/**
+ * Folder-shape probe for one matched token (the appearance bit).
+ * @param token - matched token text.
+ * @returns 'folder' for `@dir/` shapes; undefined otherwise.
+ */
+function appearanceOf(token: string): 'folder' | undefined {
+  return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined
+}
+
+/**
+ * Register the plain-text reference entity transform.
+ * @param editor - the shell-owned editor.
+ * @param lexiconOf - live per-trigger name-roll accessor (the controller's aggregated store).
+ * @returns the unregister disposer.
+ */
+export function registerTextRefDecoration(
+  editor: LexicalEditor,
+  lexiconOf: () => ReadonlyMap<'/' | '@', readonly string[]>,
+): () => void {
+  const getMatch = (text: string): { start: number; end: number } | null => {
+    const first = scanTextRefs(text, lexiconOf())[0]
+    return first === undefined ? null : { start: first.start, end: first.end }
+  }
+  return mergeRegister(
+    ...registerLexicalTextEntity(
+      editor,
+      getMatch,
+      TextRefNode,
+      node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())),
+    ),
+  )
+}
+
+/**
+ * Force a re-scan of the whole document (transforms only visit dirty nodes;
+ * a lexicon roll change dirties nothing on its own). Queued, not discrete —
+ * the caller may sit inside an update listener.
+ * @param editor - the shell-owned editor.
+ */
+export function rescanTextRefs(editor: LexicalEditor): void {
+  editor.update(() => {
+    for (const node of $getRoot().getAllTextNodes()) node.markDirty()
+  })
+}

+ 318 - 138
packages/client/ui-conversation/src/client/input/facade.ts

@@ -1,23 +1,39 @@
 /**
- * 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
+ * SessionInput shell: owns the per-session Lexical editor (text + chip
+ * truth) and the pure SubmitMachine (phase/claim/attempt), and choreographs
+ * everything between them — projections and InputState publication, the
+ * scoped-event application verbs, the submit transaction plumbing
  * (adjudicate via the session's InputTriggerController; claim.submit; default
- * sink). Package-private; the hub alone constructs it and wires the scoped
- * event listeners onto it.
+ * sink), the notice channel, and the draft persistence mirror.
+ * 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 { LexicalEditor, NodeKey } from 'lexical'
+import {
+  $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
+  CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG,
+} from 'lexical'
+import { registerPlainText } from '@lexical/plain-text'
+import { createEmptyHistoryState, registerHistory } from '@lexical/history'
+import { mergeRegister } from '@lexical/utils'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type {
-  DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
-  PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
+  DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState,
+  QueuedMessage, SessionInput, SubmitAttempt,
 } from './contract.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
-import { InputMachine, projectClipboard } from './machine.ts'
+import { SubmitMachine } from './machine.ts'
+import { ReferenceChipNode, $createReferenceChipNode } from './editor/chip-node.tsx'
+import { refreshClaimDecoration, registerClaimDecoration } from './editor/claim-decor.ts'
+import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './editor/text-ref.ts'
+import type { EditorProjection } from './editor/projection.ts'
+import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './editor/projection.ts'
+import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './editor/span-map.ts'
 
 /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
 export interface PopupDismissFace {
@@ -76,15 +92,29 @@ const EMPTY_QUEUE: readonly QueuedMessage[] = []
 /** No-pipeline lexicon: zero text-ref decorations. */
 const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
 
+/**
+ * Detect-projection and legacy reference placeholders stripped from every
+ * external text entering the document (paste, persisted-draft seed): a chip
+ * is the only legitimate source of U+FFFC in the detect projection, so a
+ * literal one in text would forge chip positions.
+ */
+const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
+
+/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
+const HISTORY_MERGE_DELAY_MS = 1000
+
 /**
  * The per-session input facade: scoped-event application verbs +
- * setDraft/submit + the published InputState store.
+ * setDraft/submit + the published InputState store, over a shell-owned
+ * Lexical editor.
  */
 export class SessionInputShell implements SessionInput {
-  /** Published machine state + queue overlay (the InputZone currency source). */
+  /** Published editor projection + submit-plane state + queue overlay (the InputZone currency source). */
   readonly state: SnapshotStore<InputState>
   /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */
   readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
+  /** The shell-owned editor (text + chip truth); the composer binds its contenteditable to it. */
+  readonly editor: LexicalEditor
   /** The public provide-channel action face (one stable identity per session). */
   readonly actions: InputActions = {
     setDraft: (text) => { this.setDraft(text) },
@@ -94,33 +124,119 @@ export class SessionInputShell implements SessionInput {
     submit: () => { this.submit('queue') },
   }
 
-  // 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 readonly core = new SubmitMachine()
+  private projection: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
+  private rev = 0
+  /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
+  private readonly occurrenceIds = new Map<NodeKey, number>()
+  private occurrenceSeq = 0
+  private readonly unregister: () => void
   private noticeSeq = 0
   private lastMirroredDraft = ''
   private imageIds: readonly DraftAttachmentId[] = []
   /** One image-only send at a time: Enter during the Host round-trip is a no-op. */
   private imageSendInFlight = false
   private disposed = false
-  /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */
+  /** Draft persistence mirror (chat store write; receives the clipboard projection). */
   private mirrorFn: ((text: string) => void) | undefined
+  /** Live lexicon subscription disposer; undefined until the controller resolves. */
+  private lexiconOff: (() => void) | undefined
 
   constructor(private readonly deps: SessionInputDeps) {
+    this.editor = createEditor({
+      namespace: 'dsh-composer',
+      nodes: [ReferenceChipNode, TextRefNode],
+      onError: (error) => { throw error },
+    })
+    this.unregister = mergeRegister(
+      registerPlainText(this.editor),
+      registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
+      this.editor.registerUpdateListener(() => { this.onEditorUpdate() }),
+      registerClaimDecoration(this.editor, () => this.activeClaimToken()),
+      registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot()),
+      () => { this.lexiconOff?.() },
+    )
     this.state = createSnapshotStore<InputState>(this.compose())
     deps.queue?.subscribe(() => { this.publish() })
   }
 
+  // ---- editor plumbing ----
+
+  /**
+   * Run one editor edit whose result is observable on return. At the top
+   * level this is a discrete update. Inside this editor's own update —
+   * command handlers land here synchronously (space/enter picks, paste) —
+   * $-functions are already legal, and wrapping them in update() would DEFER
+   * them past the synchronous bail answer (and a nested discrete throws);
+   * the body runs directly and the outer update commits it.
+   * @param fn - the $-edit body.
+   */
+  private applyEdit(fn: () => void): void {
+    if (this.editor._updating) {
+      fn()
+      return
+    }
+    this.editor.update(fn, { discrete: true })
+  }
+
+
+  /**
+   * Subscribe the text-ref re-scan to the controller's lexicon once the
+   * controller resolves. The deps thunk cannot resolve at construction (the
+   * shell is created inside the sessions provide materialization), so the
+   * first interactive updates retry until it can.
+   */
+  private ensureLexiconSubscription(): void {
+    if (this.lexiconOff !== undefined) return
+    const controller = this.deps.inputTriggers?.()
+    if (controller === undefined) return
+    this.lexiconOff = controller.lexicon.subscribe(() => { rescanTextRefs(this.editor) })
+  }
+
+  /** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */
+  private onEditorUpdate(): void {
+    this.ensureLexiconSubscription()
+    this.rev += 1
+    this.projection = this.editor.getEditorState().read(() =>
+      $projectComposer(key => this.occurrenceIdOf(key)))
+    this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText }))
+    const caret = this.projection.caret
+    if (caret !== null) {
+      this.deps.inputTriggers?.()?.track(
+        this.projection.detectText, caret, { tier: guardOf(this.core.state.phase) }, this.rev,
+      )
+    }
+  }
+
+  private occurrenceIdOf(key: NodeKey): number {
+    const existing = this.occurrenceIds.get(key)
+    if (existing !== undefined) return existing
+    this.occurrenceSeq += 1
+    this.occurrenceIds.set(key, this.occurrenceSeq)
+    return this.occurrenceSeq
+  }
+
   // ---- SessionInput face ----
 
   /**
-   * Single draft write path (all mutation rides machine events).
+   * Replace the whole draft (persisted-draft seed and programmatic writes).
+   * Placeholder-sanitized; newlines split paragraphs; the caret lands at the
+   * end. Merged into history so a seed is not an undoable step of its own.
    * @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 } : {}) }))
+  setDraft(text: string): void {
+    const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
+    if (clean === this.projection.clipboardText) return
+    this.editor.update(() => {
+      const root = $getRoot()
+      root.clear()
+      for (const line of clean.split('\n')) {
+        const paragraph = $createParagraphNode()
+        if (line !== '') paragraph.append($createTextNode(line))
+        root.append(paragraph)
+      }
+      root.selectEnd()
+    }, { discrete: true, tag: HISTORY_MERGE_TAG })
   }
 
   /** Append ordered image ids unless an admission transaction is locked. */
@@ -158,46 +274,38 @@ export class SessionInputShell implements SessionInput {
   }
 
   /**
-   * 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).
+   * Clear the draft as a successful-send commit: the editor empties (no undo
+   * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent
+   * content (the command path gets the same discipline from submit-settled).
    * @param imageIds - admitted image ids to remove from this draft.
    */
   commitSend(imageIds: readonly DraftAttachmentId[]): void {
     const submitted = new Set(imageIds)
     this.imageIds = this.imageIds.filter(id => !submitted.has(id))
-    this.run(this.core.dispatch({ type: 'send-committed' }))
-  }
-
-  /** 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' }))
+    this.dispatchRun(({ type: 'send-committed' }))
   }
 
   /**
-   * Paste text over the selection in one transaction, with any hot-snapshot
-   * sync matches componentized inside it.
+   * Insert pasted plain text over the current editor selection
+   * (placeholder-sanitized). The paste event's own default is suppressed by
+   * the caller; history groups the paste as one undoable step.
    * @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' }))
+  paste(text: string): void {
+    const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
+    if (clean === '') return
+    this.applyEdit(() => {
+      const selection = $getSelection()
+      if ($isRangeSelection(selection)) {
+        selection.insertText(clean)
+        return
+      }
+      // No selection yet (never-focused surface): land at the document end,
+      // growing the first paragraph when the tree is empty.
+      const root = $getRoot()
+      if (root.getChildrenSize() === 0) root.append($createParagraphNode())
+      root.selectEnd().insertText(clean)
+    })
   }
 
   /**
@@ -232,24 +340,14 @@ export class SessionInputShell implements SessionInput {
       this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft))
       return
     }
-    this.run(this.core.dispatch({ type: 'enter', mode }))
+    this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText }))
     const phase = this.snapshot.phase
     if (phase === 'adjudicating' || phase === 'submitting') {
       this.deps.popup?.()?.dismiss()
-      this.deps.inputTriggers?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev)
+      this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev)
     }
   }
 
-  /**
-   * 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.inputTriggers?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev)
-  }
-
   /**
    * Keyboard arbitration while the menu is open.
    * @param key - the intercepted key.
@@ -277,15 +375,9 @@ export class SessionInputShell implements SessionInput {
   space(): boolean {
     const inputTriggers = this.deps.inputTriggers?.()
     if (inputTriggers === undefined) return false
-    const consumed = inputTriggers.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
-      inputTriggers.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev)
-    }
-    return consumed
+    return inputTriggers.onSpace()
+    // No re-track here: applying the claim/insert mutates the editor, and the
+    // update listener re-tracks at the settled caret on its own.
   }
 
   /** Dismiss the popupSelect shell (any interaction outside the box). */
@@ -294,9 +386,18 @@ export class SessionInputShell implements SessionInput {
   }
 
   /**
-   * Hot plain-text reference lexicon source for the decoration scan
-   * (the plain-text-reference decision;
-   * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
+   * The live selection as a detect-coordinate span (menu-launcher synthetic
+   * hits replace it on pick); an absent selection answers a collapsed span at
+   * the document end.
+   */
+  caretSpan(): { start: number; end: number } {
+    if (this.projection.selection !== null) return this.projection.selection
+    const at = this.projection.detectText.length
+    return { start: at, end: at }
+  }
+
+  /**
+   * Hot plain-text reference lexicon source for the decoration scan:
    * delegates to the controller's aggregated store. Stable
    * identity per shell; without a pipeline the snapshot is the empty Map and
    * subscribers never fire.
@@ -306,28 +407,53 @@ export class SessionInputShell implements SessionInput {
     subscribe: fn => this.deps.inputTriggers?.()?.lexicon.subscribe(fn) ?? (() => {}),
   }
 
+  // ---- scoped-event application verbs ----
+
   /**
-   * Apply one command claim (scoped begin-command event listener body).
+   * Apply one command claim (scoped begin-command event listener body): the
+   * editor replaces [0, span.end) with the claim token, then the machine
+   * enters claimed.
    * @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).
+   * @param span - pick-time span snapshot (detect coordinates).
+   * @returns whether the edit applied (phase, span CAS, and leading guard passed).
    */
   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
+    const phase = this.core.state.phase
+    if (phase !== 'plain' && phase !== 'claimed') return false
+    if (span.draftRev !== this.rev) return false
+    // Leading-trigger contract: only whitespace may precede the span; the
+    // whitespace prefix is dropped so the claimed watch (startsWith) holds.
+    if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
+    let applied = false
+    this.applyEdit(() => {
+      applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token)
+    })
+    if (!applied) return false
+    this.dispatchRun(({ type: 'claim', claim }))
+    return true
   }
 
   /**
-   * Apply one reference insertion (scoped insert-reference event listener body).
+   * Apply one reference insertion (scoped insert-reference event listener
+   * body): the editor replaces the span with one chip node, followed by a
+   * separating space unless one is already next.
    * @param ref - the reference insertion from the pick path.
-   * @param span - pick-time span snapshot.
-   * @returns whether the machine accepted.
+   * @param span - pick-time span snapshot (detect coordinates).
+   * @returns whether the edit applied.
    */
   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
+    const phase = this.core.state.phase
+    if (phase !== 'plain' && phase !== 'claimed') return false
+    if (span.draftRev !== this.rev) return false
+    const tail = this.projection.detectText.slice(span.end, span.end + 1)
+    let applied = false
+    this.applyEdit(() => {
+      const nodes = tail === ' '
+        ? [$createReferenceChipNode(ref)]
+        : [$createReferenceChipNode(ref), $createTextNode(' ')]
+      applied = $replaceDetectSpanWithNodes(span, nodes)
+    })
+    return applied
   }
 
   /**
@@ -338,43 +464,40 @@ export class SessionInputShell implements SessionInput {
    * @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 (guard.span.draftRev !== this.rev || guard.span.start === guard.span.end) return false
+      let applied = false
+      this.applyEdit(() => {
+        applied = $replaceDetectSpanWithText(guard.span, '')
+      })
+      return applied
     }
-    if (snapshot.draft.trim() !== guard.token) return false
+    if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false
     this.setDraft('')
     return true
   }
 
   /**
    * Insert plain reference text over the pick-time span (scoped insert-text
-   * event listener body; plain-text-reference decision, web-input-machine
-   * note). 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.
+   * event listener body; the plain-text reference path). The editor gains
+   * ordinary characters — no chip node; 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).
-   * @param keepCompleting - re-track at the caret after the splice so an open
-   * token (a directory pick's trailing slash) reopens the menu.
+   * @param span - pick-time span snapshot (detect coordinates).
+   * @param keepCompleting - contract passenger; completion re-opening is
+   * automatic here (the update listener re-tracks at the settled caret, so an
+   * open token — a directory pick's trailing slash — reopens the menu without
+   * an explicit re-track).
    * @returns whether the text was applied.
    */
   insertText(text: string, span: TokenSpan, keepCompleting = false): 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))
-    if (keepCompleting) {
-      // Machine-driven draft replacement never passes through onChange, so
-      // re-track at the caret inside the still-open token (see space()).
-      const next = this.snapshot
-      this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev)
-    }
-    return true
+    void keepCompleting
+    if (span.draftRev !== this.rev) return false
+    let applied = false
+    this.applyEdit(() => {
+      applied = $replaceDetectSpanWithText(span, text)
+    })
+    return applied
   }
 
   /**
@@ -389,13 +512,15 @@ export class SessionInputShell implements SessionInput {
 
   // ---- wiring-layer extras (not on the frozen SessionInput face) ----
 
-  /** Teardown: abort any in-flight attempt and stop accepting async settlements. */
+  /** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */
   dispose(): void {
     this.disposed = true
-    this.run(this.core.dispatch({ type: 'release' }))
+    this.dispatchRun(({ type: 'release' }))
+    this.unregister()
+    this.editor.setRootElement(null)
   }
 
-  /** Read the live machine state (guard derivation reads here). */
+  /** Read the live input state (guard derivation reads here). */
   get snapshot(): InputState {
     return this.state.getSnapshot()
   }
@@ -403,7 +528,7 @@ export class SessionInputShell implements SessionInput {
   /**
    * 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
+   * seeds it via setDraft BEFORE binding, and afterwards every editor-adopted
    * draft mirrors out.
    * @param write - store draft write.
    * @returns the unbind disposer.
@@ -417,6 +542,21 @@ export class SessionInputShell implements SessionInput {
 
   // ---- effect executor ----
 
+  /** The claim token the decoration transform styles; null while unclaimed. */
+  private activeClaimToken(): string | null {
+    const core = this.core.state
+    return (core.phase === 'claimed' || core.phase === 'submitting') && core.claim !== undefined
+      ? core.claim.token
+      : null
+  }
+
+  /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
+  private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
+    const beforeToken = this.activeClaimToken()
+    this.run(this.core.dispatch(ev))
+    if (this.activeClaimToken() !== beforeToken) refreshClaimDecoration(this.editor)
+  }
+
   private run(effects: readonly InputEffect[]): void {
     for (const fx of effects) this.execute(fx)
     this.publish()
@@ -441,21 +581,45 @@ export class SessionInputShell implements SessionInput {
         this.sinkSerialized(fx.attempt, fx.draft, fx.mode)
         return
       }
-      default:
-        return // machine-internal effects (mirror rides publish)
+      case 'commit-draft': {
+        this.commitDraft(fx.retainSuffixOf)
+        return
+      }
     }
   }
 
   /**
-   * Prompt serialization before the sink: expand each
-   * inline reference range 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.
+   * Execute the commit-draft effect: clear the committed content (retaining
+   * a pure typed-during-flight suffix when the snapshot allows) and cut the
+   * undo history so sent content cannot resurrect.
+   */
+  private commitDraft(retainSuffixOf: string | null): void {
+    this.editor.update(() => {
+      const layout = $composerLayout()
+      const clip = layout.clipboardText
+      if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) {
+        $replaceDetectSpanWithText(
+          { start: 0, end: detectOffsetOfClipboardOffset(layout, retainSuffixOf.length) }, '',
+        )
+        return
+      }
+      const root = $getRoot()
+      root.clear()
+      root.selectEnd()
+    }, { discrete: true, tag: HISTORY_MERGE_TAG })
+    this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
+  }
+
+  /**
+   * Prompt serialization before the sink: expand each chip occurrence 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(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void {
     const imageIds = [...this.imageIds]
-    const occurrences = this.core.state.occurrences
+    const occurrences = this.projection.occurrences
     if (occurrences.length === 0) {
       this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds)
       return
@@ -472,8 +636,9 @@ export class SessionInputShell implements SessionInput {
     })).then(
       (parts) => {
         if (this.disposed) return
-        // Splice model forms over their display ranges (offsets are draft-time;
-        // parts arrive offset-sorted since the table is).
+        // Splice model forms over their clipboard ranges (offsets are
+        // clipboard-projection; parts arrive offset-sorted since chips walk in
+        // document order).
         let out = ''
         let cursor = 0
         for (const part of parts) {
@@ -487,7 +652,9 @@ export class SessionInputShell implements SessionInput {
         controller.abort()
         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 }))
+        this.dispatchRun(({
+          type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message,
+        }))
       },
     )
   }
@@ -505,19 +672,21 @@ export class SessionInputShell implements SessionInput {
           const submitted = new Set(imageIds)
           this.imageIds = this.imageIds.filter(id => !submitted.has(id))
         }
-        this.run(this.core.dispatch({
+        this.dispatchRun(({
           type: 'submit-settled',
           attempt,
           ok: outcome.kind === 'success',
+          draft: this.projection.clipboardText,
           outcome,
         }))
       },
       (error: unknown) => {
         if (this.dead(attempt)) return
-        this.run(this.core.dispatch({
+        this.dispatchRun(({
           type: 'submit-settled',
           attempt,
           ok: false,
+          draft: this.projection.clipboardText,
           message: error instanceof Error ? error.message : String(error),
         }))
       },
@@ -529,18 +698,18 @@ export class SessionInputShell implements SessionInput {
     const inputTriggers = this.deps.inputTriggers?.()
     if (inputTriggers === undefined) {
       // No pipeline mounted: the '/' line is an ordinary message.
-      this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
+      this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined }))
       return
     }
     inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then(
       (outcome: PickOutcome) => {
         if (this.dead(attempt)) return
-        this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome }))
+        this.dispatchRun(({ 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 }))
+        this.dispatchRun(({ type: 'adjudication-failed', attempt, message }))
       },
     )
   }
@@ -570,15 +739,19 @@ export class SessionInputShell implements SessionInput {
             this.imageIds = this.imageIds.filter(id => !submitted.has(id))
             this.deps.commandImages.release(imageIds)
           }
-          this.run(this.core.dispatch({
-            type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome,
+          this.dispatchRun(({
+            type: 'submit-settled', attempt, ok: outcome.kind === 'success',
+            draft: this.projection.clipboardText, outcome,
             ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}),
           }))
         },
         (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 }))
+          this.dispatchRun(({
+            type: 'submit-settled', attempt, ok: false,
+            draft: this.projection.clipboardText, message,
+          }))
         },
       )
   }
@@ -590,16 +763,23 @@ export class SessionInputShell implements SessionInput {
 
   private compose(): InputState {
     const core = this.core.state
-    return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
+    return {
+      draft: this.projection.clipboardText,
+      imageIds: this.imageIds,
+      draftRev: this.rev,
+      phase: core.phase,
+      ...(core.claim !== undefined ? { claim: core.claim } : {}),
+      occurrences: this.projection.occurrences,
+      queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE,
+    }
   }
 
   private publish(): void {
     const next = this.compose()
     this.state.set(next)
-    const mirroredDraft = projectClipboard(next)
-    if (mirroredDraft !== this.lastMirroredDraft) {
-      this.lastMirroredDraft = mirroredDraft
-      this.mirrorFn?.(mirroredDraft)
+    if (next.draft !== this.lastMirroredDraft) {
+      this.lastMirroredDraft = next.draft
+      this.mirrorFn?.(next.draft)
     }
   }
 }

+ 48 - 426
packages/client/ui-conversation/src/client/input/machine.ts

@@ -1,47 +1,19 @@
 /**
- * InputMachine: the pure per-session input state machine.
- * 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.
+ * SubmitMachine: the pure per-session submit-plane state machine.
+ * Events in, effects out; zero React / DOM / cordis. Package-private — the
+ * SessionInput shell is the only caller and the sole executor of the
+ * returned effects.
  *
- * Draft truth: the draft string holds each reference's complete inline display
- * text; the occurrence table carries identity, range, 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).
+ * The machine owns phase, claim, and the in-flight SubmitAttempt; it never
+ * holds the draft. Text truth lives in the shell's Lexical editor, and every
+ * decision that needs the draft reads it from the event payload (claim
+ * integrity watch, enter snapshots, settlement suffix/re-entry decisions).
  */
-import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import type { CommandClaim } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
-import type {
-  ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
-  InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
-} from './contract.ts'
+import type { InputEffect, InputEvent, InputState, SubmitAttempt } from './contract.ts'
 
-/** Legacy fixed-width object replacement character rejected from pasted text. */
-export const PLACEHOLDER = ''
-
-const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
-
-/**
- * Build the inline draft text whose leading marker is decorated as the
- * reference icon in the backdrop.
- * @param reference - reference insertion with its cached display projection.
- * @returns display text with one marker glyph followed by the complete label.
- */
-export function referenceDraftText(reference: Pick<ReferenceInsert, 'label'>): string {
-  return `@${reference.label}`
-}
-
-/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */
-const EMPTY_QUEUE: InputState['queue'] = []
-
-/** Undo ring depth (bounded self-managed transaction log). */
-const LOG_LIMIT = 100
-
-/** Exhaustiveness backstop for the closed InputEvent / guard unions. */
+/** Exhaustiveness backstop for the closed InputEvent union. */
 function unreachable(value: never): never {
   throw new Error(`unreachable input event: ${JSON.stringify(value)}`)
 }
@@ -64,88 +36,33 @@ function argsAfter(draft: string, token: string): string {
   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 reference ranges into their occurrences' clipboard text
- * for persistence and clipboard projection. Table order is offset order, so
- * one linear walk pairs ranges 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 + o.length
-  }
-  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
+/** The submit-plane slice of the published InputState. */
+export interface SubmitSnapshot {
+  readonly phase: InputState['phase']
+  readonly claim?: InputState['claim']
 }
 
 /**
- * Pure input machine, one instance per session (per-session isolation is by
+ * Pure submit 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
+export class SubmitMachine {
   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
   } | 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 {
+  /** Read-only snapshot of the submit-plane state. */
+  get state(): SubmitSnapshot {
     const c = this.claim
     return {
-      draft: this.draft,
-      imageIds: [],
-      draftRev: this.draftRev,
       phase: this.phase,
       ...(c
         ? {
@@ -156,33 +73,19 @@ export class InputMachine {
           },
         }
         : {}),
-      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.
+   * @param ev - Input event; the single write path for all submit-plane 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 '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 'draft-changed': return this.onDraftChanged(ev.draft)
+      case 'claim': return this.onClaim(ev.claim)
+      case 'enter': return this.onEnter(ev.mode, ev.draft)
       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)
@@ -192,313 +95,51 @@ export class InputMachine {
     }
   }
 
-  // ---- 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; an edit that intersects
-   * a reference range removes its structured occurrence and leaves the edited
-   * characters as ordinary draft text.
-   */
-  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 + o.length <= 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)) {
+  /** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */
+  private onDraftChanged(draft: string): InputEffect[] {
+    if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) {
       this.phase = 'plain'
       this.claim = undefined
     }
-  }
-
-  /** Mint one occurrence at a draft offset. */
-  private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence {
-    this.occurrenceSeq += 1
-    return {
-      occurrenceId: this.occurrenceSeq,
-      source: reference.source,
-      ref: reference.ref,
-      offset,
-      length,
-      label: reference.label,
-      ...reference.appearance === undefined ? {} : { appearance: reference.appearance },
-      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 []
   }
 
-  /** 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[] {
+  /** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */
+  private onClaim(claim: CommandClaim): 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 reference-insertion transaction: replace [span) with one inline
-   * occurrence (insert-ref and paste-upgrade both land here). A separating
-   * space follows the reference unless one is already next.
-   * @returns the inserted length (display text 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 displayText = referenceDraftText(reference)
-    const inserted = displayText + gap
-    this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
-    this.withMinted([this.mint(reference, span.start, displayText.length)])
-    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 (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 (reference-placeholder-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.replace(REFERENCE_PLACEHOLDER_RE, '')
-    this.pushTxn(selection)
-    this.typingRun = undefined
-    // Componentize: replace each matched token range (paste-text coordinates,
-    // disjoint by contract) with inline display text 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)
-      const displayText = referenceDraftText(c.reference)
-      minted.push(this.mint(c.reference, start + inserted.length, displayText.length))
-      inserted += displayText
-      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: InputSubmitMode): SubmitAttempt {
+  private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
     const controller = new AbortController()
     this.seq += 1
-    const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode }
+    const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode }
     this.inflight = { attempt, controller }
     return attempt
   }
 
-  private onEnter(mode: InputSubmitMode): InputEffect[] {
+  private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] {
     if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
     if (this.phase === 'claimed' && this.claim !== undefined) {
-      const attempt = this.beginAttempt(mode)
+      const attempt = this.beginAttempt(mode, draft)
       this.phase = 'submitting'
-      this.paste = undefined
-      return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
+      return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
     }
-    const trimmed = this.draft.trim()
+    const trimmed = draft.trim()
     if (trimmed === '') return []
-    this.paste = undefined
     if (trimmed.startsWith('/')) {
-      const attempt = this.beginAttempt(mode)
+      const attempt = this.beginAttempt(mode, draft)
       this.phase = 'adjudicating'
-      return [{ type: 'adjudicate', attempt, draft: this.draft }]
+      return [{ type: 'adjudicate', attempt, draft }]
     }
-    const attempt = this.beginAttempt(mode)
+    const attempt = this.beginAttempt(mode, draft)
     this.phase = 'submitting'
-    return [{ type: 'default-sink', attempt, draft: this.draft, mode }]
+    return [{ type: 'default-sink', attempt, draft, mode }]
   }
 
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -514,7 +155,7 @@ export class InputMachine {
         args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
       }]
     }
-    // 'handled' (source dealt internally), {insert} (no enter-time span
+    // 'handled' (source dealt internally), {insert}/{text} (no enter-time span
     // semantics), or a miss: all land plain; only the miss flows to the sink.
     if (outcome === undefined) {
       this.phase = 'submitting'
@@ -545,30 +186,19 @@ export class InputMachine {
     if (ev.ok) {
       this.phase = 'plain'
       this.claim = undefined
-      this.occurrences = []
-      // Text appended after the sent snapshot during the Host round-trip
-      // survives the commit; edits interleaved with committed content cannot
-      // be separated from it, so only a pure suffix is retained.
-      const snapshot = flight.attempt.draftSnapshot
-      this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot)
-        ? this.draft.slice(snapshot.length)
-        : '')
-      // 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 effects: InputEffect[] = [{ type: 'commit-draft', retainSuffixOf: flight.attempt.draftSnapshot }]
+      if (ev.outcome?.text !== undefined) {
+        effects.push({ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text })
+      }
+      return effects
     }
     const text = ev.message ?? ev.outcome?.text
     // Keep the same command claim only while the live draft still equals the
     // enter-time draft; 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)) {
+    if (ev.draft === flight.attempt.draftSnapshot
+      && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) {
       this.phase = 'claimed'
       return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
     }
@@ -577,17 +207,11 @@ export class InputMachine {
     return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
   }
 
-  /** Cut undo state after an accepted image-only send. */
+  /** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */
   private onSendCommitted(): InputEffect[] {
     if (this.phase !== 'plain') return []
     this.claim = undefined
-    this.occurrences = []
-    this.adopt('')
-    this.log = []
-    this.redoStack = []
-    this.typingRun = undefined
-    this.paste = undefined
-    return []
+    return [{ type: 'commit-draft', retainSuffixOf: null }]
   }
 
   private onRelease(): InputEffect[] {
@@ -597,8 +221,6 @@ export class InputMachine {
     }
     this.phase = 'plain'
     this.claim = undefined
-    this.typingRun = undefined
-    this.paste = undefined
     return []
   }
 }

+ 31 - 156
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -116,57 +116,20 @@
   height: 0;
 }
 
-/* The draft's scrollport, and the ONLY scrolling box in the composer: the
-   caret is the textarea's and every visible glyph is the backdrop's, so the two
-   layers stay together only by riding one offset the browser applies to both at
-   once. Scrolling one box and assigning the offset to the other cannot hold —
-   a wheel gesture is composited off the main thread, so the assignment lands
-   frames late and the words visibly trail the caret. The 14-line cap lives here
+/* The draft's scrollport, and the ONLY scrolling box in the composer. The
+   contenteditable grows with its content inside; the 14-line cap lives here
    because this is the box the cap describes. */
 .scroll {
   max-height: var(--dsh-composer-text-max-height);
   overflow-y: auto;
 }
 
-/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
-   height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
-   as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
-   MUST share font, line-height, padding and wrapping rules or heights diverge. */
+/* Auto-grow anchor: the contenteditable is in normal flow and sets the
+   draft's height; the placeholder rides it absolutely. */
 .grow {
   position: relative;
 }
 
-/* Decoration backdrop: same metrics as the transparent-text textarea. It owns
-   every visible glyph plus the range colors and ghost hint; the textarea above
-   retains the native selection and caret. */
-.backdrop {
-  position: absolute;
-  inset: 0;
-  overflow: hidden;
-  color: var(--dsw-alias-label-primary);
-  pointer-events: none;
-}
-
-.backdropDisabled,
-.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) {
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.hlToken {
-  background-color: transparent;
-  color: var(--dsw-alias-state-warn-label);
-}
-
-.hlSegment {
-  border-radius: 4px;
-  background-color: transparent;
-  color: transparent;
-}
-
-.hint {
-  color: var(--dsw-alias-label-caption);
-}
-
 /* Machine pending dot (adjudicating / submitting). */
 .pending {
   width: 8px;
@@ -181,70 +144,48 @@
   to { opacity: 1; }
 }
 
+/* The contenteditable draft surface (grows with its content; .scroll caps
+   and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
 .input {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  resize: none;
-  /* Never a scroller of its own: it is as tall as the draft, so it has no
-     scrollable overflow to hold an offset that could differ from the glyphs'.
-     The browser still reveals the caret — the scroll-into-view walks up to
-     .scroll and moves both layers together. */
-  overflow: hidden;
-  border: none;
-  outline: none;
-  background: transparent;
-  color: transparent;
-  -webkit-text-fill-color: transparent;
-  /* Business blue, not brand-primary: that token resolves to ink in this sheet. */
-  caret-color: var(--dsw-alias-state-business-primary);
-}
-
-.input,
-.mirror,
-.backdrop {
-  /* Textareas default to content-box (unlike buttons/inputs): without this the
-     width:100% textarea gains its padding OUTSIDE the card and text runs past
-     the right padding — and wraps 28px later than the mirror/backdrop layers. */
   box-sizing: border-box;
-  /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
-     metrics or the highlight ranges drift off the glyphs. */
   padding: 4px 12px 0 16px;
   font-family: var(--dsw-font-family);
   font-size: inherit;
-  /* Three consumers, not two: the mirror sizes the stack, the layers must break
-     lines identically, and the caret reveal parses this value to step one line
-     down for a caret that sits after a newline. That parse needs a length, so a
-     theme resolving this to `normal` would make the reveal a silent no-op. */
   line-height: inherit;
   white-space: pre-wrap;
   word-break: break-word;
   overflow-wrap: anywhere;
-  /* These three MUST wrap at one width: the mirror decides the box height
-     the other two are laid out in, and a glyph layer that breaks lines
-     elsewhere than the textarea puts the words under the wrong caret. They do
-     so by construction now that all three sit INSIDE .scroll — a scrollbar
-     that consumes layout space narrows the scrollport, which is their shared
-     containing block, so it costs all three the same width on every engine.
-     A textarea that scrolls itself would break this, and no property fixes
-     it: WebKit reserves gutter space for an overflow-y:auto textarea and not
-     for the overflow:hidden layers beside it, leaving them 8px apart
-     (768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
+  outline: none;
+  color: var(--dsw-alias-label-primary);
+  /* Business blue, not brand-primary: that token resolves to ink in this sheet. */
+  caret-color: var(--dsw-alias-state-business-primary);
+}
+
+/* Lexical paragraphs are <p> blocks: strip the UA margins so the surface
+   keeps the textarea's line rhythm. */
+.input p {
+  margin: 0;
+}
+
+/* Claim ghost hint as generated content after the last paragraph: the bar
+   sets --dsh-composer-hint (a quoted string) while the claim's args are
+   blank; without the variable the declaration is invalid and nothing shows. */
+.input p:last-child::after {
+  content: var(--dsh-composer-hint);
+  color: var(--dsw-alias-label-caption);
 }
 
 /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
-.input::placeholder {
+.placeholder {
+  position: absolute;
+  inset: 4px 12px auto 16px;
   color: var(--dsw-alias-label-caption);
-  -webkit-text-fill-color: var(--dsw-alias-label-caption);
+  pointer-events: none;
   user-select: none;
 }
 
-/* The backdrop owns disabled draft color; the textarea remains caret-only so
-   its marker glyphs cannot cover the reference icons beneath it. */
-.input:disabled {
-  color: transparent;
-  -webkit-text-fill-color: transparent;
+.inputDisabled {
+  color: var(--dsw-alias-label-tertiary);
   cursor: not-allowed;
 }
 
@@ -252,14 +193,9 @@
   cursor: pointer;
 }
 
-.mirror {
-  visibility: hidden;
-  pointer-events: none;
-}
-
 /* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
    line + 4pt); the docked composer collapses to the content height. */
-.hero .mirror {
+.hero .input {
   min-height: 52px;
 }
 
@@ -410,64 +346,3 @@
   font-size: 12px;
   cursor: pointer;
 }
-
-/* Plain-text reference highlight: a pure range mark over the
-   draft's own glyphs — advance untouched, so the two layers cannot drift.
-   Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
-.textRef {
-  background-color: transparent;
-  color: var(--dsw-alias-state-business-primary);
-  box-decoration-break: clone;
-  -webkit-box-decoration-break: clone;
-}
-.textRef:after {
-  display: none;
-}
-
-.textRefTrigger {
-  position: relative;
-}
-
-.textRefTriggerGlyph {
-  color: transparent;
-}
-
-.textRefIcon {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-}
-
-/* Structured references use the same inline-backdrop technique as /skill:
-   their complete display text remains in the textarea, so wrapping and caret
-   geometry come from the browser's native glyph metrics. The leading marker
-   reserves the icon's advance while the backdrop paints the domain glyph. */
-.chip {
-  position: relative;
-  color: var(--dsw-alias-state-business-primary);
-  background: transparent;
-  -webkit-box-decoration-break: clone;
-  box-decoration-break: clone;
-}
-
-.chipTrigger {
-  position: relative;
-}
-
-.chipTriggerGlyph {
-  color: transparent;
-}
-
-.chipIcon {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-}
-
-.chipInvalid {
-  text-decoration: line-through;
-  opacity: 0.7;
-  color: var(--dsw-alias-state-error-primary);
-}

+ 159 - 426
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -4,10 +4,17 @@
  * through this entry's own inject, whose hooks compartment binds
  * useNotices/useLexicon; layout-phase inputs (variant, placeholder,
  * region-slot content) ride the owner props. Session facts
- * (running/removed/promptError) are self-selected via useSession. */
+ * (running/removed/promptError) are self-selected via useSession.
+ *
+ * The text surface is the shell-owned Lexical editor bound here through
+ * ComposerContentEditable; chips render as decorator portals, and the
+ * keymap registers submit/menu/paste gestures on the editor command layer.
+ * The no-session state renders the SAME div inert as the Workspace-picker
+ * trigger instead of a parallel tree.
+ */
 
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import {
   IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
@@ -22,18 +29,14 @@ import type {} from '@deepseek-ai/dsh-goal/client'
 // api-remotes import already places it in every client program.
 import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ComposerBarProps } from '../contract/slots.ts'
-import { deriveDecorations } from '../input/decorations.ts'
-import type { DraftDecorations } from '../input/decorations.ts'
+import { ComposerContentEditable } from '../input/editor/ComposerContentEditable.tsx'
+import { DecoratorPortals } from '../input/editor/DecoratorPortals.tsx'
+import { registerComposerKeymap } from '../input/editor/keymap.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
-import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
 import { ContextMeter } from './ContextMeter.tsx'
 import { PermissionSelect } from './PermissionSelect.tsx'
-import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts'
 import css from './InputBar.module.css'
 
-/** Decoration product of the no-session state (no machine, empty draft). */
-const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
-
 export type InputBarProps = ComposerBarProps
 
 export function InputBar({
@@ -46,13 +49,13 @@ export function InputBar({
 }: InputBarProps) {
   const input = useInput(s => s)
   const notice = useNotices(s => s)
-  const lexicon = useLexicon(s => s)
+  void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms
   const commandMenuOpen = useMenuLauncher(source => source === 'command')
   const promptError = useSession(s => s.promptError) ?? null
   const running = useSession(s => s.running) ?? false
   const subagent = useSession(s => s.subagent) ?? null
   const removed = useSession(s => s.removed) ?? false
-  // Plan mode swaps the textarea placeholder (the projection is the folded
+  // Plan mode swaps the composer placeholder (the projection is the folded
   // host value; owner-prop placeholders — hero, session-unavailable — win).
   const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
   // Absent (undefined: no frame yet) and cleared (null) both mean no goal.
@@ -61,6 +64,7 @@ export function InputBar({
   // current; the bar renders the same DOM inert instead of a parallel tree.
   const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
   const draft = input?.draft ?? ''
+  const editor = keyboard?.editor ?? null
   const attachments = useMemo(
     () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
     [draftImages, input?.imageIds],
@@ -95,23 +99,8 @@ export function InputBar({
   useEffect(() => {
     if (notice?.level === 'error') showToast(notice.text)
   }, [notice, showToast])
-  const inputRef = useRef<HTMLTextAreaElement | null>(null)
   const cardRef = useRef<HTMLDivElement | null>(null)
   const scrollRef = useRef<HTMLDivElement | null>(null)
-  const mirrorRef = useRef<HTMLDivElement | null>(null)
-  const safari = useMemo(() => isSafariBrowser(navigator), [])
-  const safariNativeShrinkRef = useRef(false)
-  // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
-  // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
-  const composingRef = useRef(false)
-  const onCompositionStart = (): void => {
-    composingRef.current = true
-  }
-  const onCompositionEnd = (): void => {
-    setTimeout(() => {
-      composingRef.current = false
-    }, 10)
-  }
 
   // The Access seat's data: the host-computed permissions projection
   // (undefined = capability absent → the chip renders nothing).
@@ -133,12 +122,13 @@ export function InputBar({
   // be disabled do lock it — there is no session to choose a model for.
   const modelSeatLocked = removed || inert || !live
   const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
-  // The no-workspace textarea remains the resident DOM node but acts as the
+  // The no-workspace surface remains the resident DOM node but acts as the
   // existing picker trigger. Message controls stay locked until a Session
   // exists; the trigger itself is read-only rather than disabled so pointer
   // and keyboard users can reach the recovery action.
   const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined
-  const textareaDisabled = removed || (locked && !workspaceTrigger)
+  const editorDisabled = removed || (locked && !workspaceTrigger)
+  const editable = live && !locked && !machineBusy
   const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
     && input.queue.some(row => row.placement === 'queued')
 
@@ -149,77 +139,43 @@ export function InputBar({
     }
   }, [attachments, input?.imageIds, inputActions])
 
-  // A native Safari edit that shortens the draft may leave the previous
-  // soft-wrap layout behind after the mirror shrinks. The native-change signal
-  // keeps ordinary typing and programmatic draft updates from reading layout;
-  // the helper then repairs only measured overflow before paint while
-  // preserving native editing state. See
-  // .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md.
-  useLayoutEffect(() => {
-    const nativeShrink = safariNativeShrinkRef.current
-    safariNativeShrinkRef.current = false
-    if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current)
-  }, [draft, safari])
-  // Scroll the draft scrollport the minimum that brings `caret` into view — the
-  // browser's own behavior for typing, performed for the paths where it does
-  // not act.
-  //
-  // The mirror is the caret's ruler: it renders the same draft at the same
-  // metrics and the same wrap width in the same stack (that is what makes it
-  // the height authority), so a Range collapsed at the caret's index reports
-  // where the caret is without a caret API.
-  const revealCaret = (caret: number): void => {
+  // Scroll the draft scrollport the minimum that brings the selection focus
+  // into view — the browser's own behavior for typing, performed for the
+  // paths where it does not act (programmatic focus with preventScroll, and
+  // session switches that land the caret off screen). The live DOM selection
+  // is the ruler; no mirror layer exists to consult.
+  const revealSelection = (): void => {
     const scrollEl = scrollRef.current
-    const mirrorEl = mirrorRef.current
-    const text = mirrorEl?.firstChild
-    if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return
-    // A box that cannot scroll has nothing to reveal: the draft fits, so every
-    // caret is already in view and the assignment below would clamp to itself.
-    if (scrollEl.scrollHeight <= scrollEl.clientHeight) return
-    const at = Math.min(caret, text.data.length)
-    // A caret straight after a newline sits on a line with nothing on it to
-    // measure — the shape a trailing-newline draft ends in — and the engines
-    // disagree there: chromium returns NO client rects at all (an all-zero box,
-    // which would scroll the wrong way), firefox reports the line above, WebKit
-    // the right one. Measure the newline itself instead, which is the line the
-    // caret just left, and step one line down; that they all agree on.
-    const afterNewline = at > 0 && text.data[at - 1] === '\n'
-    const range = document.createRange()
-    range.setStart(text, afterNewline ? at - 1 : at)
-    if (afterNewline) range.setEnd(text, at)
-    else range.collapse(true)
-    const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0
-    const rect = range.getBoundingClientRect()
+    if (scrollEl === null || scrollEl.scrollHeight <= scrollEl.clientHeight) return
+    const selection = window.getSelection()
+    if (selection === null || selection.rangeCount === 0) return
+    const range = selection.getRangeAt(0)
+    let rect = range.getBoundingClientRect()
+    if (rect.height === 0 && rect.width === 0) {
+      // A collapsed caret at an empty line reports a zero rect in some
+      // engines; the anchor's element box is the line the caret sits on.
+      const anchor = selection.anchorNode
+      const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement
+      if (el === undefined || el === null) return
+      rect = el.getBoundingClientRect()
+    }
     const box = scrollEl.getBoundingClientRect()
-    if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom
-    else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
-  }
-
-  // Reveal the focus end of the current selection. Today's entry paths leave a
-  // collapsed selection, but honoring direction keeps a future range-preserving
-  // path from revealing its anchor instead of its focus.
-  const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
-    // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
-    const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
-    // oxlint-disable-next-line typescript/no-unnecessary-condition
-    revealCaret(caret ?? el.value.length)
+    if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom
+    else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top
   }
 
   // Unlock (mount / session switch) returns focus to the box, and owns the
-  // reveal that comes with it. `preventScroll` because this focus is ours, not
-  // a gesture: the textarea is as tall as the draft, so the browser's reveal
-  // would walk up to the conversation scrollport and move the transcript under
-  // a user who only switched session. That leaves the caret to us — the DOM is
-  // reused across sessions, so switching to a longer draft keeps the previous
-  // offset while the value swap puts the caret at the new draft's end, which is
-  // off screen (measured on all three engines: offset 0 with the caret 940px
-  // down). Suppress the walk, then reveal in our own box.
+  // reveal that comes with it. Lexical's focus() suppresses the browser's
+  // scroll walk (preventScroll inside), so the reveal in our own scrollport
+  // is ours to perform — switching to a longer draft otherwise leaves the
+  // caret (restored at the draft's end) off screen.
   useEffect(() => {
-    const el = inputRef.current
-    if (locked || el === null) return
-    el.focus({ preventScroll: true })
-    revealSelectionFocus(el)
-  }, [locked, sessionId])
+    if (locked || editor === null) return
+    // Lexical's focus() restores the editor selection but never calls the DOM
+    // focus itself; preventScroll keeps the conversation scrollport still.
+    editor.getRootElement()?.focus({ preventScroll: true })
+    editor.focus(() => { revealSelection() })
+  }, [locked, sessionId, editor])
 
   // A persisted draft arrives AFTER the unlock effect: ConversationSession
   // adopts it in its own mount effect, and a parent's mount effect runs after
@@ -228,25 +184,10 @@ export function InputBar({
   // not focus: send-clear, failed-send restore, and first-character transitions
   // must not steal focus from another control the user moved to.
   useEffect(() => {
-    const el = inputRef.current
-    if (locked || draft === '' || el === null) return
-    revealSelectionFocus(el)
+    if (locked || draft === '') return
+    revealSelection()
   }, [draft !== ''])
 
-  // Caret restore after an edit the composer performs itself. The machine owns
-  // the draft and the undo log, so paste and cut suppress the native edit and
-  // write the value through the machine — and a
-  // programmatic selection change reveals nothing: measured in chromium and
-  // WebKit, pasting a long block leaves the view where it was while the caret
-  // sits at the end of the draft. Native typing gets its reveal from the
-  // browser; these two have to ask for it, so they share one restore.
-  const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
-    requestAnimationFrame(() => {
-      el.setSelectionRange(caret, caret)
-      revealCaret(caret)
-    })
-  }
-
   // Wheel chaining on the draft scrollport, one lifetime (it is never
   // unmounted — the inert state renders the same element disabled). While the
   // capped box can still move in this direction, keep the native scroll; only
@@ -269,169 +210,6 @@ export function InputBar({
     return () => { el.removeEventListener('wheel', onWheel) }
   }, [])
 
-  // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
-  /* oxlint-disable typescript/no-unnecessary-condition */
-  const selectionOf = (el: HTMLTextAreaElement) => ({
-    start: el.selectionStart ?? 0,
-    end: el.selectionEnd ?? el.selectionStart ?? 0,
-  })
-  /* oxlint-enable typescript/no-unnecessary-condition */
-
-  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
-    if (workspaceTrigger) {
-      if (e.key === 'Enter' || e.key === ' ') {
-        e.preventDefault()
-        onRequestWorkspace()
-      }
-      return
-    }
-    // Absent machine without a Workspace recovery action stays disabled; the
-    // guard narrows the faces for the paths below.
-    if (input === undefined || keyboard === undefined || inputActions === undefined) return
-    // Shift+Enter is the native newline UNCONDITIONALLY — decided before the
-    // IME guard so a composition-closing Shift+Enter still breaks the line.
-    if (e.key === 'Enter' && e.shiftKey) return
-    // keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
-    // oxlint-disable-next-line typescript/no-deprecated
-    const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
-    if (!composing && !machineBusy && !locked
-      && (e.key === 'Backspace' || e.key === 'Delete')) {
-      const selection = selectionOf(e.currentTarget)
-      if (selection.start === selection.end) {
-        const occurrence = input.occurrences.find(o => e.key === 'Backspace'
-          ? o.offset + o.length === selection.start
-          : o.offset === selection.start)
-        if (occurrence !== undefined) {
-          e.preventDefault()
-          const start = occurrence.offset
-          const end = occurrence.offset + occurrence.length
-          keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
-          restoreCaret(e.currentTarget, start)
-          keyboard.track(keyboard.snapshot.draft, start)
-          return
-        }
-      }
-    }
-    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
-      if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
-      return
-    }
-    if (e.key === 'Escape') {
-      // Escape layering: an open overlay closes; claimed without an overlay
-      // does NOT release (backspacing the token is the only exit gesture).
-      keyboard.dismissPopup()
-      if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault()
-      return
-    }
-    if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
-      // The machine owns the undo/redo log (chip transactions have semantics
-      // the browser stack cannot represent); never let the native stack run.
-      e.preventDefault()
-      if (machineBusy || locked) return
-      const redo = e.key === 'y' || e.shiftKey
-      if (redo) keyboard.redo()
-      else keyboard.undo()
-      return
-    }
-    if (e.key === ' ') {
-      if (composing) return
-      if (keyboard.space()) e.preventDefault() // claim token already carries the trailing separator
-      return
-    }
-    if (e.key !== 'Enter') return
-    if (composing) return
-    // Menu-open Enter picks the highlight through arbitration; a no-highlight
-    // menu passes down to the machine's own adjudication.
-    const arbitrated = keyboard.arbitrate('enter', composing)
-    if (arbitrated !== 'pass') {
-      e.preventDefault()
-      return
-    }
-    e.preventDefault()
-    if (e.repeat) return // held-down Enter must not machine-gun sends
-    if (locked || machineBusy) return
-    const accelerated = e.ctrlKey || e.metaKey
-    // Empty-draft accelerated Enter acts on the queue instead of the (empty)
-    // draft: the machine rejects empty drafts, so the gesture steers every
-    // still-pending queued message into the running turn (the dock's per-row
-    // steer button applied to the whole queue). Steering needs the same
-    // window as the per-row button: a running ordinary session.
-    if (accelerated && canSteerQueue) {
-      keyboard.steerQueue()
-      return
-    }
-    keyboard.submit(resolveSubmitMode(
-      running,
-      accelerated ? 'accelerated' : 'enter',
-      subagent === null,
-    ))
-  }
-
-  const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
-    if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
-    if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
-    const next = e.target.value
-    safariNativeShrinkRef.current = safari && next.length < draft.length
-    keyboard.setDraft(next)
-    // selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
-    // oxlint-disable-next-line typescript/no-unnecessary-condition
-    keyboard.track(next, e.target.selectionStart ?? next.length)
-  }
-
-  const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
-    if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut
-    const el = e.currentTarget
-    const { start, end } = selectionOf(el)
-    if (start === end) return
-    const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start)
-    if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine
-    e.preventDefault()
-    const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start)
-    const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end)
-    // Expand structured ranges to their owner clipboard projections.
-    let text = ''
-    let cursor = copyStart
-    for (const o of touched) {
-      text += draft.slice(cursor, o.offset) + o.clipboardText
-      cursor = o.offset + o.length
-    }
-    text += draft.slice(cursor, copyEnd)
-    e.clipboardData.setData('text/plain', text)
-    if (cut && !machineBusy && !locked) {
-      keyboard.setDraft(
-        draft.slice(0, copyStart) + draft.slice(copyEnd),
-        { start: copyStart, end: copyEnd, insertedLength: 0 },
-      )
-      restoreCaret(el, copyStart)
-    }
-  }
-
-  const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
-    if (keyboard === undefined) return // absent machine: no draft can accept a paste
-    if (machineBusy || locked) return
-    const files = Array.from(e.clipboardData.items)
-      .filter(item => item.kind === 'file')
-      .map(item => item.getAsFile())
-      .filter((file): file is File => file !== null)
-    if (files.length > 0) intakeImages(files)
-    const text = e.clipboardData.getData('text/plain')
-    if (text === '') {
-      if (files.length > 0) e.preventDefault()
-      return
-    }
-    e.preventDefault()
-    const el = e.currentTarget
-    const sel = selectionOf(el)
-    // Sync components stay empty at this layer: hot-snapshot matching needs
-    // the Slash roster, which lives behind keyboard.track — the paste attempt
-    // opens in the machine and the controller upgrades tokens as matches
-    // land (paste-upgrade). The DOM layer only starts the transaction.
-    keyboard.pasteBegin(text, sel)
-    const caret = sel.start + text.length
-    restoreCaret(el, caret)
-    keyboard.track(keyboard.snapshot.draft, caret)
-  }
-
   // Intake pre-check (DeepSeek Chat semantics): an addition that would break
   // a projected limit is refused as a whole batch, announced immediately, and
   // never enters the rail — no more submit-time failure rolling the rail
@@ -466,25 +244,67 @@ export function InputBar({
 
   const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
 
-  const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
-    // Any caret/selection gesture ends a live paste attempt (the machine
-    // cannot observe DOM selection). Cheap no-op when none is live.
-    if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
-    void e
-  }
+  // The keymap handlers read live bar state through this ref so the editor
+  // registration survives re-renders without re-arming per keystroke.
+  const gate = useRef({
+    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages,
+  })
+  gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages }
+
+  useEffect(() => {
+    if (editor === null || keyboard === undefined) return
+    return registerComposerKeymap(editor, {
+      arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
+      space: () => {
+        if (gate.current.machineBusy || gate.current.locked) return false
+        return keyboard.space()
+      },
+      dismissPopup: () => { keyboard.dismissPopup() },
+      canSubmit: () => !gate.current.locked && !gate.current.machineBusy,
+      submit: (accelerated) => {
+        const g = gate.current
+        // Empty-draft accelerated Enter acts on the queue instead of the
+        // (empty) draft: the machine rejects empty drafts, so the gesture
+        // steers every still-pending queued message into the running turn.
+        if (accelerated && g.canSteerQueue) {
+          keyboard.steerQueue()
+          return
+        }
+        keyboard.submit(g.resolveSubmitMode(
+          g.running,
+          accelerated ? 'accelerated' : 'enter',
+          g.subagent === null,
+        ))
+      },
+      intakeFiles: (files) => { gate.current.intakeImages(files) },
+      pasteText: (text) => {
+        if (gate.current.machineBusy || gate.current.locked) return
+        keyboard.paste(text)
+      },
+    })
+  }, [editor, keyboard])
 
-  // Button presses steal focus from the textarea; suppress at mousedown so
-  // typing continues seamlessly. `preventScroll` for the same reason as the
-  // unlock effect, and with no reveal of its own: the caret has not moved, and
-  // the next keystroke gets the browser's native one.
+  // Button presses steal focus from the editor; suppress at mousedown so
+  // typing continues seamlessly. Lexical's focus() carries preventScroll and
+  // restores the previous selection, so no reveal is needed: the caret has
+  // not moved, and the next keystroke gets the browser's native one.
   const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
     e.preventDefault()
-    inputRef.current?.focus({ preventScroll: true })
+    editor?.getRootElement()?.focus({ preventScroll: true })
   }
 
   const onToggleCommandMenu = (): void => {
-    const el = inputRef.current
-    if (el !== null) toggleCommandMenu?.(selectionOf(el))
+    if (keyboard !== undefined) toggleCommandMenu?.(keyboard.caretSpan())
+  }
+
+  // The no-session Workspace trigger: the resident editable div acts as the
+  // picker trigger for keyboard users (no editor is bound in this state).
+  const onWorkspaceKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
+    if (!workspaceTrigger) return
+    if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault()
+      onRequestWorkspace()
+    }
   }
 
   // Ordinary sessions retain their primary Send/Stop toggle. A continuable
@@ -510,102 +330,36 @@ export function InputBar({
     ? null
     : <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
 
-  // Mirror-layer decorations: a visible backdrop with transparent textarea
-  // text. Claim tokens and references retain the draft's own glyph metrics,
-  // so their decoration cannot drift from wrapping, selection, or the caret.
-  const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
-  const backdrop: ReactNode[] = []
-  {
-    // Segment boundaries: the token range end, every structured-reference
-    // offset, and every text-ref range — merged in draft order (the sources never
-    // overlap: structured references own their ranges, text-refs own plain tokens, the
-    // claim token only leads).
-    let cursor = 0
-    const pushPlain = (upTo: number): void => {
-      if (upTo > cursor) backdrop.push(draft.slice(cursor, upTo))
-      cursor = upTo
-    }
-    if (deco.token !== null) {
-      backdrop.push(
-        <mark key="token" className={css.hlToken} data-decoration="token">
-          {draft.slice(deco.token.start, deco.token.end)}
-        </mark>,
-      )
-      cursor = deco.token.end
-    }
-    type Boundary =
-      | { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] }
-      | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number }
-    const boundaries: Boundary[] = [
-      ...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })),
-      ...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })),
-    ].sort((a, b) => a.at - b.at)
-    for (const b of boundaries) {
-      if (b.at < cursor) continue // claim-token overlap: the leading mark wins
-      pushPlain(b.at)
-      if (b.kind === 'chip') {
-        const chip = b.chip
-        backdrop.push(
-          <span
-            key={`chip-${chip.occurrenceId}`}
-            className={clsx(css.chip, chip.invalid && css.chipInvalid)}
-            data-decoration="chip"
-            data-reference-appearance={chip.appearance}
-            data-occurrence={chip.occurrenceId}
-            data-invalid={chip.invalid || undefined}
-            title={chip.label}
-          >
-            {chip.appearance === undefined
-              ? chip.text[0]
-              : (
-                <span className={css.chipTrigger}>
-                  <span className={css.chipTriggerGlyph}>{chip.text[0]}</span>
-                  <ReferenceIcon kind={chip.appearance} size={16} className={css.chipIcon} />
-                </span>
-              )}
-            <span>{chip.text.slice(1)}</span>
-          </span>,
-        )
-        cursor = chip.offset + chip.length
-      } else {
-        // Plain-range highlight: the glyphs stay the
-        // textarea's (advance untouched); the mark paints the chip look.
-        // The key is the draft-order ordinal: a fresh scan derives these
-        // ranges every render, so none of them carries identity past its
-        // position, and a draft-offset key would unmount the mark and its
-        // icon for every character typed ahead of it. Structured references
-        // key by occurrenceId, the identity their occurrence table owns.
-        const text = draft.slice(b.ref.start, b.ref.end)
-        backdrop.push(
-          <mark key={`ref-${b.ordinal}`} className={css.textRef} data-decoration="text-ref">
-            {b.ref.appearance === 'folder'
-              ? (
-                <>
-                  <span className={css.textRefTrigger}>
-                    <span className={css.textRefTriggerGlyph}>{text[0]}</span>
-                    <ReferenceIcon kind="folder" size={16} className={css.textRefIcon} />
-                  </span>
-                  {text.slice(1)}
-                </>
-              )
-              : text}
-          </mark>,
-        )
-        cursor = b.ref.end
-      }
-    }
-    pushPlain(draft.length)
-    if (deco.hint !== null) {
-      // Claim tokens have the `/name ` format (trailing space); trim to the bare name.
-      const commandName = input?.claim?.token.slice(1).trim() ?? ''
-      const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
-      // Dynamic lookup by claimed command name: unknown commands miss the
-      // dictionary and keep the machine's own hint, so the call is wide.
-      const translated = (t as Translate)(hintKey)
-      const displayHint = translated !== hintKey ? translated : deco.hint
-      backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
-    }
-  }
+  // Claim ghost hint: rendered by CSS as generated content after the last
+  // paragraph while the claim's args are blank (a hint implies a single-line
+  // token draft). The translated per-command hint wins over the claim's own.
+  const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting')
+    && input.claim !== undefined && draft.startsWith(input.claim.token)
+  const rawHint = claimActive && input.claim?.hint !== undefined
+    && draft.slice(input.claim.token.length).trim() === ''
+    ? input.claim.hint
+    : null
+  const hint = ((): string | null => {
+    if (rawHint === null) return null
+    // Claim tokens have the `/name ` format (trailing space); trim to the bare name.
+    const commandName = input?.claim?.token.slice(1).trim() ?? ''
+    const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
+    // Dynamic lookup by claimed command name: unknown commands miss the
+    // dictionary and keep the machine's own hint, so the call is wide.
+    const translated = (t as Translate)(hintKey)
+    return translated !== hintKey ? translated : rawHint
+  })()
+
+  const placeholderText = placeholder ?? (parentOffline
+    ? t('placeholder.parentOffline')
+    : disabled
+      ? t('placeholder.unavailable')
+      // The steer hint deliberately outranks the plan placeholder:
+      // while it shows, the whole-queue gesture is genuinely available
+      // (the gate never consults plan mode), so the actionable hint wins.
+      : canSteerQueue
+        ? t('placeholder.steerQueue')
+        : planActive ? t('placeholder.plan') : t('placeholder.default'))
 
   return (
     <div className={clsx(css.root, variant === 'hero' && css.hero)}>
@@ -623,7 +377,7 @@ export function InputBar({
           {notice.text}
         </div>
       )}
-      {/* Trigger clicks land on the card, not the textarea: the toolbar row's
+      {/* Trigger clicks land on the card, not the editor: the toolbar row's
           disabled controls swallow clicks otherwise (the CSS state disarms
           their pointer events), so the WHOLE capsule is the pick target.
           pointerdown stops here so the Menu's outside-close cannot race the
@@ -647,54 +401,33 @@ export function InputBar({
             size: imageSizeText(imageLimits.maxImageBytes),
           },
         })}
-        {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
-            stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
-            absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
-            lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
-            glyphs to the backdrop, so they can only stay together by moving together: one scroll
-            offset the browser applies to both layers at once, never a JS mirror between two boxes,
-            which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
+        {/* One scrollport, one text surface: the contenteditable grows with
+            its content and .scroll — capped at 14 lines in CSS — is the only
+            thing that scrolls. Chips are decorator portals inside the same
+            surface, so wrapping, caret geometry, and scrolling are the
+            browser's own. */}
         <div ref={scrollRef} className={css.scroll} data-input-scroll>
           <div className={css.grow}>
-            <div
-              aria-hidden
-              className={clsx(css.backdrop, textareaDisabled && css.backdropDisabled)}
-              data-input-backdrop
-              data-disabled={textareaDisabled || undefined}
-            >
-              {backdrop}
-            </div>
-            <textarea
-              ref={inputRef}
-              className={css.input}
-              value={draft}
-              disabled={textareaDisabled}
-              readOnly={machineBusy || workspaceTrigger}
+            <ComposerContentEditable
+              editor={workspaceTrigger ? null : editor}
+              editable={editable}
+              className={clsx(css.input, editorDisabled && css.inputDisabled)}
+              data-phase={input?.phase ?? 'inert'}
+              aria-disabled={editorDisabled || undefined}
+              data-placeholder={placeholderText}
               aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined}
               aria-haspopup={workspaceTrigger ? 'menu' : undefined}
               aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
-              data-phase={input?.phase ?? 'inert'}
-              placeholder={placeholder ?? (parentOffline
-                ? t('placeholder.parentOffline')
-                : disabled
-                  ? t('placeholder.unavailable')
-                  // The steer hint deliberately outranks the plan placeholder:
-                  // while it shows, the whole-queue gesture is genuinely available
-                  // (the gate never consults plan mode), so the actionable hint wins.
-                  : canSteerQueue
-                    ? t('placeholder.steerQueue')
-                    : planActive ? t('placeholder.plan') : t('placeholder.default'))}
-              rows={2}
-              onChange={onChange}
-              onKeyDown={onKeyDown}
-              onSelect={onSelect}
-              onCopy={(e) => { onCopyOrCut(e, false) }}
-              onCut={(e) => { onCopyOrCut(e, true) }}
-              onPaste={onPaste}
-              onCompositionStart={onCompositionStart}
-              onCompositionEnd={onCompositionEnd}
+              tabIndex={workspaceTrigger ? 0 : undefined}
+              onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
+              style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
             />
-            <div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
+            {empty && !claimActive && (
+              <div aria-hidden className={css.placeholder} data-composer-placeholder>
+                {placeholderText}
+              </div>
+            )}
+            <DecoratorPortals editor={workspaceTrigger ? null : editor} />
           </div>
         </div>
         <div className={css.row}>

+ 28 - 16
packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx

@@ -1,14 +1,22 @@
 // @vitest-environment jsdom
 /** Conversation assembly acceptance independent of Tool presentation. */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
+import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react'
 import { useState } from 'react'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { InputHub } from '../src/client/input/hub.ts'
 import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 usePinnedBrowserLanguages('zh-CN')
 
 const SID = 's1' as SessionId
@@ -90,10 +98,10 @@ describe('resident composer', () => {
     await runtime.mount({ inject: [...inject], apply })
     runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
     const view = runtime.renderRoot()
-    const textarea = view.container.querySelector('textarea')
+    const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')
     expect(textarea).not.toBeNull()
-    expect(textarea!.disabled).toBe(false)
-    expect(textarea!.readOnly).toBe(true)
+    expect(textarea!.getAttribute('aria-disabled')).not.toBe('true')
+    expect(textarea!.getAttribute('contenteditable')).not.toBe('true')
     expect(textarea!.getAttribute('aria-haspopup')).toBe('menu')
     expect(view.getByTestId('workspace-probe').textContent).toBe('false:0')
     fireEvent.click(textarea!)
@@ -127,11 +135,11 @@ describe('resident composer', () => {
     const root = view.container.querySelector('[data-phase="hero"]')!
     const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
     const composerSeat = view.container.querySelector('[data-composer-seat]')!
-    const textarea = view.container.querySelector('textarea')!
+    const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
     const workspaceChip = view.getByRole('button', { name: '选择工作区' })
     const workspaceProbe = view.getByTestId('workspace-probe')
-    expect(textarea.disabled).toBe(false)
-    expect(textarea.readOnly).toBe(true)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect(textarea.getAttribute('contenteditable')).not.toBe('true')
 
     fireEvent.click(workspaceChip)
     fireEvent.click(workspaceProbe)
@@ -146,12 +154,12 @@ describe('resident composer', () => {
     expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
     expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
     expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
-    expect(view.container.querySelector('textarea')).toBe(textarea)
+    expect(view.container.querySelector<HTMLDivElement>('[data-composer-input]')).toBe(textarea)
     expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
     expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
     expect(workspaceProbe.textContent).toBe('true:1')
-    expect(textarea.disabled).toBe(false)
-    expect(textarea.readOnly).toBe(false)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect(textarea.getAttribute('contenteditable')).toBe('true')
     await runtime.dispose()
   })
 
@@ -161,15 +169,15 @@ describe('resident composer', () => {
       draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
     })
     const view = runtime.renderRoot()
-    const hero = view.container.querySelector('textarea')
+    const hero = view.container.querySelector<HTMLDivElement>('[data-composer-input]')
     expect(hero).not.toBeNull()
-    expect(hero!.disabled).toBe(false)
+    expect(hero!.getAttribute('aria-disabled')).not.toBe('true')
 
     await runtime.sessions.updateSnapshot(SID, (draft) => {
       draft.blank = false
       draft.composerPhase = 'active'
     })
-    expect(view.container.querySelector('textarea')).toBe(hero)
+    expect(view.container.querySelector<HTMLDivElement>('[data-composer-input]')).toBe(hero)
     await runtime.dispose()
   })
 })
@@ -197,8 +205,12 @@ describe('prompt rejection through the assembled composer', () => {
     await runtime.mount({ inject: [...inject], apply })
     const view = runtime.renderRoot()
 
-    const composer = view.container.querySelector('textarea')!
-    fireEvent.change(composer, { target: { value: 'do not lose this' } })
+    const composer = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
+    // Write through the assembled input resolver (contenteditable change
+    // events carry no value; the resolver is the public draft write path).
+    const conversation = runtime.ctx.get('conversation') as { input: InputHub }
+    const shell = conversation.input.shell(SID)
+    act(() => { shell.setDraft('do not lose this') })
     fireEvent.keyDown(composer, { key: 'Enter' })
     await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
 
@@ -211,7 +223,7 @@ describe('prompt rejection through the assembled composer', () => {
     const alert = await view.findByRole('alert')
     expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
     await waitFor(() => {
-      expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
+      expect(shell.snapshot.draft).toBe('do not lose this')
     })
     await runtime.dispose()
   })

+ 194 - 376
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -1,11 +1,16 @@
 // @vitest-environment jsdom
-// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
-// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
-// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
-// decoration backdrop, error banners, status strips, and the focus-keeping mousedown.
+// InputBar behavior over the editor + submit-machine wiring: Enter-send
+// semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
+// repeat suppression), running semantics (input stays free; continuable
+// children keep Send beside Stop), the machine pending lock, chip decorators,
+// error banners, status strips, and the focus-keeping mousedown. Keyboard
+// gestures dispatch real KeyboardEvents at the contenteditable (the Lexical
+// root listener routes them through the keymap commands); draft writes drive
+// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
 
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { $getRoot, $isTextNode } from 'lexical'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import {
   createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
@@ -15,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import { SessionInputShell } from '../src/client/input/facade.ts'
+import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
 import type {
   ComposerAttachment, ComposerAttachmentsOwnerProps,
 } from '../src/client/contract/slots.ts'
@@ -32,11 +38,6 @@ afterEach(cleanup)
 const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
 Range.prototype.getBoundingClientRect = ZERO_RECT
 
-// Read through the descriptor so the native method is never referenced unbound;
-// the reveal case below wraps it to record what it was asked to measure.
-const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
-  .value as (this: Range, node: Node, offset: number) => void
-
 const SCTX = {} as ClientContext
 const SID = 's1' as SessionId
 
@@ -134,6 +135,7 @@ function bench(over?: BenchOptions) {
     ...(lex !== undefined
       ? {
         inputTriggers: (() => ({
+          track: () => {},
           lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
         })) as unknown as NonNullable<ShellDeps['inputTriggers']>,
       }
@@ -201,7 +203,7 @@ function bench(over?: BenchOptions) {
     ...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
   }
   const view = render(<InputBar {...props} />)
-  const textarea = view.container.querySelector('textarea')!
+  const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
   const primaryStops = over?.running === true && over.subagent === undefined
   const button = view.container.querySelector<HTMLButtonElement>(
     `button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
@@ -211,6 +213,8 @@ function bench(over?: BenchOptions) {
     view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
     menuLauncher,
     steerQueue: over?.steerQueue,
+    get placeholder() { return placeholderOf(view.container) },
+    get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
   }
 }
 
@@ -222,8 +226,23 @@ function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]):
   throw new Error('attachment slot was not rendered')
 }
 
+/** The state's placeholder copy (the textarea.placeholder equivalent; the visible layer renders it only while empty). */
+function placeholderOf(container: HTMLElement): string {
+  return container.querySelector('[data-composer-input]')?.getAttribute('data-placeholder') ?? ''
+}
+
+/** Whether the composer surface accepts edits right now (setEditable's DOM face). */
+function editableOf(input: HTMLElement): boolean {
+  return input.getAttribute('contenteditable') === 'true'
+}
+
+/** Write the draft through the shell inside act (the seed path; caret lands at the end). */
+function writeDraft(shell: SessionInputShell, text: string): void {
+  act(() => { shell.setDraft(text) })
+}
+
 describe('image draft rail', () => {
-  it('collects clipboard files while preserving text from a mixed paste', () => {
+  it('collects clipboard files while preserving text from a mixed paste', async () => {
     const addImages = vi.fn(() => null)
     const { textarea, shell } = bench({ addImages })
     const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
@@ -237,7 +256,8 @@ describe('image draft rail', () => {
       },
     })
     expect(addImages).toHaveBeenCalledWith([image])
-    expect(shell.snapshot.draft).toBe('同时粘贴的文字')
+    // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away.
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') })
   })
 
   it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
@@ -404,14 +424,14 @@ describe('image draft rail', () => {
 
 describe('Enter semantics', () => {
   it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
-    const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
-    expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
+    const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
+    expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
   })
 
   it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
-    expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
-    expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
-    expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
+    expect(bench({ running: true }).placeholder).toBe('给智能体发消息')
+    expect(bench({ queue: [row('q-1')] }).placeholder).toBe('给智能体发消息')
+    expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('给智能体发消息')
     expect(bench({
       running: true,
       queue: [row('q-1')],
@@ -419,26 +439,26 @@ describe('Enter semantics', () => {
         address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
         parentAvailable: true,
       },
-    }).textarea.placeholder).toBe('给智能体发消息')
+    }).placeholder).toBe('给智能体发消息')
     expect(bench({
       running: true,
       queue: [row('q-1')],
       placeholder: '上层指定提示',
-    }).textarea.placeholder).toBe('上层指定提示')
+    }).placeholder).toBe('上层指定提示')
     // The command menu owns Enter while open: neither the hint nor the
     // gesture may claim the chord.
     expect(bench({
       running: true,
       queue: [row('q-1')],
       commandMenuOpen: true,
-    }).textarea.placeholder).toBe('给智能体发消息')
+    }).placeholder).toBe('给智能体发消息')
     // The steer hint intentionally outranks the plan placeholder: while it
     // shows, the whole-queue gesture is genuinely available in plan mode.
     expect(bench({
       running: true,
       queue: [row('q-1')],
       plan: { active: true, pending: false },
-    }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
+    }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
   })
 
   it('an open command menu withholds the whole-queue steering gesture', () => {
@@ -570,11 +590,26 @@ describe('Enter semantics', () => {
 
   it('platform undo/redo chords route to the machine, never the browser stack', () => {
     const { textarea, shell } = bench({ draft: '' })
-    fireEvent.change(textarea, { target: { value: 'first' } })
-    fireEvent.change(textarea, { target: { value: 'first second' } })
-    fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
-    expect(shell.snapshot.draft).not.toBe('first second')
-    fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
+    vi.useFakeTimers()
+    onTestFinished(() => { vi.useRealTimers() })
+    writeDraft(shell, 'first')
+    vi.advanceTimersByTime(1100) // beyond the history merge window: a separate undo step
+    act(() => {
+      shell.editor.update(() => {
+        const first = $getRoot().getAllTextNodes()[0]
+        if ($isTextNode(first)) first.select(5, 5).insertText(' second')
+      }, { discrete: true })
+    })
+    expect(shell.snapshot.draft).toBe('first second')
+    // Lexical's chord detection reads keyCode (90 = z); a history restore
+    // commits on the next flush (setEditorState defers inside the command
+    // update).
+    const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
+    fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
+    flush()
+    expect(shell.snapshot.draft).toBe('first')
+    fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true, shiftKey: true })
+    flush()
     expect(shell.snapshot.draft).toBe('first second')
   })
 
@@ -602,9 +637,9 @@ describe('Enter semantics', () => {
 
 describe('running and lock semantics', () => {
   it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
-    const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
-    expect(textarea.disabled).toBe(false)
-    fireEvent.change(textarea, { target: { value: '排队消息2' } })
+    const { textarea, button, stop, sink, shell } = bench({ running: true, draft: '排队消息' })
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    writeDraft(shell, '排队消息2')
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
     expect(button.getAttribute('aria-label')).toBe('停止生成')
@@ -643,7 +678,7 @@ describe('running and lock semantics', () => {
     })
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(interruptButton).not.toBeNull()
-    expect(textarea.disabled).toBe(false)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
     fireEvent.click(button)
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     fireEvent.click(interruptButton!)
@@ -663,8 +698,8 @@ describe('running and lock semantics', () => {
         parentAvailable: false,
       },
     })
-    expect(textarea.disabled).toBe(true)
-    expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
+    expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(button.disabled).toBe(true)
@@ -711,8 +746,8 @@ describe('running and lock semantics', () => {
 
   it('disabled (session removed) locks the textarea and chrome', () => {
     const { textarea, view } = bench({ disabled: true })
-    expect(textarea.disabled).toBe(true)
-    expect(textarea.placeholder).toBe('会话不可用')
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
+    expect(placeholderOf(view.container)).toBe('会话不可用')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
   })
 
@@ -724,21 +759,22 @@ describe('running and lock semantics', () => {
     expect(empty.button.disabled).toBe(true)
   })
 
-  it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
+  it('unlock refocuses the surface; mousedown on the button keeps focus', () => {
     const first = bench({ disabled: true, draft: 'x' })
+    const textarea = first.view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
+    const focused: (boolean | undefined)[] = []
+    textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
     act(() => { first.session.set(snapshotOf({ removed: false })) })
-    const textarea = first.view.container.querySelector('textarea')!
-    expect(document.activeElement).toBe(textarea)
-    textarea.blur()
+    expect(focused).toEqual([true])
     fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
-    expect(document.activeElement).toBe(textarea)
+    expect(focused).toEqual([true, true])
   })
 
-  it('typing forwards through the machine (draft state echoes back)', () => {
-    const { textarea, wiring } = bench()
-    fireEvent.change(textarea, { target: { value: 'typed' } })
+  it('a programmatic draft write echoes back through the state and the editor DOM', () => {
+    const { textarea, wiring, shell } = bench()
+    writeDraft(shell, 'typed')
     expect(wiring.state.getSnapshot().draft).toBe('typed')
-    expect((textarea).value).toBe('typed')
+    expect(textarea.textContent).toBe('typed')
   })
 
   it('wheel over a non-overflowing draft forwards to the conversation host', () => {
@@ -792,270 +828,45 @@ describe('running and lock semantics', () => {
     }
   })
 
-  it('the caret layer and the glyph layer ride one scrollport', () => {
-    const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
+  it('one editable surface rides the scrollport (no mirror or backdrop layers)', () => {
+    const { view, textarea } = bench({ draft: 'line\n'.repeat(40).trimEnd() })
     const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
-    // The caret is the textarea's and every visible glyph is the backdrop's, so
-    // one box has to carry both or an offset can exist in one and not the other.
-    // jsdom has no layout and loads no stylesheet — which box scrolls is the
-    // browser scenario's to assert; what is checkable here is that the
-    // scrollport element holds both layers.
     expect(scroll.contains(textarea)).toBe(true)
-    expect(scroll.contains(backdrop)).toBe(true)
-    // The glyph layer carries the draft and nothing else — no height padding
-    // to a second box's scroll extent.
-    expect(backdrop.textContent).toBe('line\n'.repeat(40))
-  })
-
-  it('repairs Safari native overflow after the mirror shrinks the draft', () => {
-    const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
-    const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
-      'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
-    )
-    onTestFinished(() => {
-      vendor.mockRestore()
-      userAgent.mockRestore()
-    })
-    const { textarea } = bench({ draft: 'two wrapped lines' })
-    const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
-    let inputRepaired = false
-    let scrollportRepaired = false
-    const inputLayouts: string[] = []
-    const scrollportLayouts: string[] = []
-    Object.defineProperty(textarea, 'clientHeight', {
-      configurable: true,
-      get: () => textarea.style.height === '29px' ? 29 : 28,
-    })
-    Object.defineProperty(textarea, 'scrollHeight', {
-      configurable: true,
-      get: () => inputRepaired ? 28 : 52,
-    })
-    Object.defineProperty(textarea, 'offsetHeight', {
-      configurable: true,
-      get: () => {
-        inputLayouts.push(textarea.style.height)
-        if (textarea.style.height === '') inputRepaired = true
-        return textarea.clientHeight
-      },
-    })
-    Object.defineProperty(scrollport, 'clientHeight', {
-      configurable: true,
-      get: () => {
-        if (scrollport.style.height === '53px') return 53
-        if (inputRepaired && !scrollportRepaired) return 52
-        return 28
-      },
-    })
-    Object.defineProperty(scrollport, 'offsetHeight', {
-      configurable: true,
-      get: () => {
-        scrollportLayouts.push(scrollport.style.height)
-        if (scrollport.style.height === '') scrollportRepaired = true
-        return scrollport.clientHeight
-      },
-    })
-    textarea.setSelectionRange(5, 5)
-
-    fireEvent.change(textarea, { target: { value: 'one line' } })
-
-    expect(inputLayouts).toEqual(['29px', ''])
-    expect(scrollportLayouts).toEqual(['53px', ''])
-    expect(textarea.style.height).toBe('')
-    expect(scrollport.style.height).toBe('')
-    expect(textarea.scrollHeight).toBe(textarea.clientHeight)
-    expect(scrollport.clientHeight).toBe(28)
-  })
-
-  it('does not force the Safari recovery for another iOS browser', () => {
-    const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
-    const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
-      'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
-    )
-    onTestFinished(() => {
-      vendor.mockRestore()
-      userAgent.mockRestore()
-    })
-    const { textarea } = bench({ draft: 'two wrapped lines' })
-    const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
-    Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 })
-    Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 })
-    Object.defineProperty(textarea, 'offsetHeight', {
-      configurable: true,
-      get: () => { throw new Error('non-Safari browser must not force textarea layout') },
-    })
-    Object.defineProperty(scrollport, 'offsetHeight', {
-      configurable: true,
-      get: () => { throw new Error('non-Safari browser must not force scrollport layout') },
-    })
-
-    fireEvent.change(textarea, { target: { value: 'one line' } })
-
-    expect(scrollport.style.height).toBe('')
-  })
-
-  it('does not read Safari layout while a native edit grows the draft', () => {
-    const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
-    const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
-      'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
-    )
-    onTestFinished(() => {
-      vendor.mockRestore()
-      userAgent.mockRestore()
-    })
-    const { textarea, shell } = bench({ draft: 'one line' })
-    Object.defineProperty(textarea, 'clientHeight', {
-      configurable: true,
-      get: () => { throw new Error('growing Safari input must not read layout') },
-    })
-    Object.defineProperty(textarea, 'scrollHeight', {
-      configurable: true,
-      get: () => { throw new Error('growing Safari input must not read layout') },
-    })
-
-    fireEvent.change(textarea, { target: { value: 'one line grows' } })
-
-    expect(shell.snapshot.draft).toBe('one line grows')
-  })
-
-  it('an edit the composer performs itself scrolls the caret back into view', async () => {
-    // Paste and cut suppress the native edit, so no engine reveals the caret
-    // for them. jsdom has no layout: the rects are stubbed,
-    // and what is asserted is the arithmetic — minimal scroll, in both
-    // directions, and nothing at all for a caret already inside the box.
-    const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
-    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
-    expect(mirror.firstChild).toBeInstanceOf(Text)
-    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
-    // jsdom reports scrollHeight === clientHeight for every element, which is
-    // the composer's own "nothing to reveal" case; a scrollable box is what
-    // puts the reveal on the table at all.
-    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
-    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
-    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
-    onTestFinished(() => {
-      Range.prototype.getBoundingClientRect = ZERO_RECT
-      Range.prototype.setStart = NATIVE_SET_START
-    })
-    // Which layer the caret is measured against, and at which index: the stub
-    // records `setStart` so a helper that measured the backdrop instead, or
-    // always collapsed at 0, fails here rather than only in the browser lane.
-    let measured: { node: Node; offset: number } | null = null
-    Range.prototype.setStart = function setStart(node: Node, offset: number): void {
-      measured = { node, offset }
-      NATIVE_SET_START.call(this, node, offset)
-    }
-    const caretAt = (top: number): void => {
-      Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
-    }
-    const settle = async (): Promise<void> => {
-      await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
-    }
-    // Pasted text lands below the fold: scroll down by exactly the overshoot.
-    caretAt(500)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
-    await settle()
-    expect(scroll.scrollTop).toBe(88) // 524 - 436
-    // Measured on the mirror's own text, at the index the paste left the caret
-    // (an empty draft's selection start, 0, plus the pasted length).
-    expect(measured!.node).toBe(mirror.firstChild)
-    expect(measured!.offset).toBe('pasted'.length)
-    // A caret already inside the box does not move it.
-    caretAt(200)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
-    await settle()
-    expect(scroll.scrollTop).toBe(88)
-    // Above the fold (a cut can leave it there): scroll back up.
-    caretAt(60)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
-    await settle()
-    expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
-    // A caret straight after a newline has nothing on its line to measure, so
-    // the newline it just left is measured instead and one line is added.
-    // chromium reports no client rects at all for the collapsed position.
-    mirror.style.lineHeight = '24px'
-    caretAt(500)
-    fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
-    await settle()
-    // The four pastes accumulate at the draft's head, so the caret is at the
-    // end of what they inserted — and the measured index is the newline before it.
-    expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
-    expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
-  })
-
-  it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
-    // The composer DOM is reused across sessions, so the previous session's
-    // offset survives while the value swap puts the caret at the new draft's
-    // end. `preventScroll` keeps the browser from revealing it through the
-    // conversation scrollport, which leaves the reveal to the effect itself.
-    const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
-    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
-    onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
-    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
-    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
-    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
-    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
-    Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
-    // The draft ends in a newline, so the reveal takes the after-newline path
-    // and needs a resolvable line-height (jsdom computes `normal`).
-    mirror.style.lineHeight = '24px'
-    // Which index the effect reveals at, not merely that it scrolled: a
-    // revealCaret(0) would land the same offset without this.
-    onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
-    let measured: { node: Node; offset: number } | null = null
-    Range.prototype.setStart = function setStart(node: Node, offset: number): void {
-      measured = { node, offset }
-      NATIVE_SET_START.call(this, node, offset)
-    }
+    // The three-layer stack is gone: the editable surface owns glyphs, caret,
+    // and height at once.
+    expect(view.container.querySelector('[data-input-backdrop]')).toBeNull()
+    expect(view.container.querySelector('[data-input-mirror]')).toBeNull()
+    // DOM textContent joins paragraphs without separators; the projection
+    // carries the newlines.
+    expect(textarea.textContent).toBe('line'.repeat(40))
+    expect(bench({ draft: 'a\nb' }).shell.snapshot.draft).toBe('a\nb')
+  })
+
+  it('a session switch refocuses the editable surface with preventScroll', () => {
+    const { view, textarea, props } = bench({ draft: 'line one' })
     const focused: (boolean | undefined)[] = []
     textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
-    textarea.setSelectionRange(textarea.value.length, textarea.value.length)
     act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
     expect(focused).toEqual([true])
-    expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
-    // The draft ends in a newline, so the rule measures that newline: the
-    // caret's own index is the mirror text's length minus its sentinel.
-    expect(measured!.node).toBe(mirror.firstChild)
-    expect(measured!.offset).toBe(textarea.value.length - 1)
-  })
-
-  it('a persisted draft adopted after mount gets its caret revealed too', () => {
-    // ConversationSession seeds the stored draft in its own mount effect, which
-    // runs after this component's: the first reveal measures an empty mirror,
-    // so the draft's arrival has to run it again without reclaiming focus.
-    const { view, textarea, shell } = bench()
-    const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
-    const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
-    // The restored draft ends in a newline, so the reveal takes the
-    // after-newline path and needs a resolvable line-height (jsdom says `normal`).
-    mirror.style.lineHeight = '24px'
-    onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
-    scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
-    Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
-    Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
-    Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
-    Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
+  })
+
+  it('a persisted draft adopted after mount does not steal focus from another control', () => {
+    const { shell } = bench()
     const other = document.createElement('input')
     document.body.appendChild(other)
     onTestFinished(() => { other.remove() })
     other.focus()
-    expect(scroll.scrollTop).toBe(0)
     act(() => { shell.setDraft('restored\n'.repeat(40)) })
     expect(document.activeElement).toBe(other)
-    // The caret the machine left at the draft's end, revealed once the draft exists.
-    expect(textarea.selectionStart).toBe(textarea.value.length)
-    expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
+    expect(shell.snapshot.draft).toBe('restored\n'.repeat(40))
   })
 
   it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
-    const { textarea } = bench({ disabled: true })
-    expect(textarea.placeholder).toBe('会话不可用')
+    expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
     const live = bench()
-    expect(live.textarea.placeholder).toBe('给智能体发消息')
+    expect(live.placeholder).toBe('给智能体发消息')
     const custom = bench({ placeholder: 'Custom placeholder' })
-    expect(custom.textarea.placeholder).toBe('Custom placeholder')
+    expect(custom.placeholder).toBe('Custom placeholder')
   })
 
   it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
@@ -1066,8 +877,8 @@ describe('running and lock semantics', () => {
       onRequestWorkspace,
       placeholder: '选择一个工作区开始',
     })
-    expect(textarea.disabled).toBe(false)
-    expect(textarea.readOnly).toBe(true)
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect(editableOf(textarea)).toBe(false)
     expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
     expect(textarea.getAttribute('aria-expanded')).toBe('false')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
@@ -1094,16 +905,16 @@ describe('running and lock semantics', () => {
 
   it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
     const active = bench({ plan: { active: true, pending: false } })
-    expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
+    expect(active.placeholder).toBe('描述你的任务以生成计划')
     // /plan just ran: pending entry already reads as the plan target.
     const entering = bench({ plan: { active: false, pending: true } })
-    expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
+    expect(entering.placeholder).toBe('描述你的任务以生成计划')
     // Pending exit: target is default again.
     const leaving = bench({ plan: { active: true, pending: true } })
-    expect(leaving.textarea.placeholder).toBe('给智能体发消息')
+    expect(leaving.placeholder).toBe('给智能体发消息')
     // Owner placeholder outranks the plan swap.
     const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
-    expect(custom.textarea.placeholder).toBe('Custom placeholder')
+    expect(custom.placeholder).toBe('Custom placeholder')
   })
 })
 
@@ -1123,34 +934,40 @@ describe('machine pending lock', () => {
       shell.submit()
     })
     expect(shell.snapshot.phase).toBe('submitting')
-    const textarea = view.container.querySelector('textarea')!
-    expect(textarea.readOnly).toBe(true)
+    const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
+    expect(editableOf(textarea)).toBe(false)
     expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
   })
 })
 
 describe('decorations', () => {
-  it('claimed token renders the mirror highlight and the blank-args hint', () => {
+  /** The claim-token styled leaf (the transform's inline warn color). */
+  function tokenSpanOf(container: HTMLElement): HTMLElement | null {
+    return container.querySelector('[data-lexical-text][style*="warn-label"]')
+  }
+
+  it('claimed token styles the leading leaf and sets the blank-args hint variable', () => {
     // Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
-    const { view, shell } = bench({ t: makeTranslate({}) })
+    const { view, shell, textarea } = bench({ t: makeTranslate({}) })
     act(() => {
       shell.setDraft('/goal ')
       shell.beginCommand(
         { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
+      shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh
     })
-    const token = view.container.querySelector('[data-decoration="token"]')
-    expect(token?.textContent).toBe('/goal ')
-    expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
-    // Args typed: the hint disappears, the token highlight stays.
+    expect(tokenSpanOf(view.container)?.textContent).toBe('/goal ')
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('目标内容'))
+    // Args typed: the hint disappears, the token style stays.
     act(() => { shell.setDraft('/goal 发布') })
-    expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
-    expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
+    act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
+    expect(tokenSpanOf(view.container)).not.toBeNull()
   })
 
   it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
-    const { view, shell } = bench()
+    const { shell, textarea } = bench()
     act(() => {
       shell.setDraft('/goal ')
       shell.beginCommand(
@@ -1158,10 +975,10 @@ describe('decorations', () => {
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
     })
-    expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
   })
 
-  it('an inserted reference decorates its complete inline display range', () => {
+  it('an inserted reference renders a real chip capsule with its icon and label', () => {
     const { view, shell } = bench()
     const reference = {
       source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
@@ -1173,115 +990,115 @@ describe('decorations', () => {
         { start: 3, end: 6, draftRev: shell.snapshot.draftRev },
       )
     })
-    const chip = view.container.querySelector('[data-decoration="chip"]')
-    expect(chip?.textContent).toBe('@会话一')
-    expect(chip?.getAttribute('data-reference-appearance')).toBe('session')
+    const chip = view.container.querySelector('[data-composer-chip]')
+    expect(chip?.textContent).toBe('会话一')
     expect(chip?.querySelector('svg')).not.toBeNull()
+    expect(chip?.getAttribute('contenteditable')).toBe('false')
     expect(shell.snapshot.occurrences).toHaveLength(1)
-    expect(shell.snapshot.draft).toBe('参考 @会话一 内容')
-    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 })
+    // The draft IS the clipboard projection; the label lives in the chip DOM.
+    expect(shell.snapshot.draft).toBe('参考 @w1 内容')
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 3 })
   })
 
-  it('keeps the textarea glyph layer transparent when a structured reference becomes disabled', () => {
+  it('keeps the chip decorator mounted when the session becomes disabled', () => {
     const { view, shell, session, textarea } = bench()
     act(() => {
       shell.setDraft('@w1')
       shell.insertReference({
         source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
       }, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
-      session.set(snapshotOf({ removed: true }))
     })
-    const backdrop = view.container.querySelector('[data-input-backdrop]')
-    expect(textarea.disabled).toBe(true)
-    expect(backdrop?.getAttribute('data-disabled')).toBe('true')
-    expect(backdrop?.querySelector('[data-decoration="chip"] svg')).not.toBeNull()
+    const chip = view.container.querySelector('[data-composer-chip]')
+    expect(chip?.querySelector('svg')).not.toBeNull()
+    act(() => { session.set(snapshotOf({ removed: true })) })
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
+    expect(view.container.querySelector('[data-composer-chip]')).toBe(chip)
   })
 
-  it('Backspace and Delete remove a reference as one range at its boundaries', () => {
-    const reference = {
-      source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
-    }
-    const backspace = bench()
+  it('a chip leaves the document as one unit (keyboard deletion is asserted in the browser lane)', () => {
+    // jsdom lacks the non-standard Selection.modify Lexical's character
+    // deletion crosses nodes with, so the Backspace/Delete gesture itself is
+    // an e2e assertion; the structural unit — one span covering the chip
+    // removes the whole occurrence — is checkable here.
+    const { shell } = bench()
     act(() => {
-      backspace.shell.setDraft('前 @w1 后')
-      backspace.shell.insertReference(
-        reference,
-        { start: 2, end: 5, draftRev: backspace.shell.snapshot.draftRev },
-      )
+      shell.setDraft('前 @w1 后')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
     })
-    backspace.textarea.setSelectionRange(6, 6)
-    fireEvent.keyDown(backspace.textarea, { key: 'Backspace' })
-    expect(backspace.shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
-
-    const forwardDelete = bench()
+    expect(shell.snapshot.occurrences).toHaveLength(1)
     act(() => {
-      forwardDelete.shell.setDraft('前 @w1 后')
-      forwardDelete.shell.insertReference(
-        reference,
-        { start: 2, end: 5, draftRev: forwardDelete.shell.snapshot.draftRev },
-      )
+      shell.editor.update(() => {
+        expect($replaceDetectSpanWithText({ start: 2, end: 3 }, '')).toBe(true)
+      }, { discrete: true })
     })
-    forwardDelete.textarea.setSelectionRange(2, 2)
-    fireEvent.keyDown(forwardDelete.textarea, { key: 'Delete' })
-    expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
+    expect(shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
   })
 
-  it('copy and cut expand a partial reference selection to its structured range', () => {
+  it('copy and cut expand a selected chip to its clipboard projection natively', async () => {
     const { shell, textarea } = bench()
     act(() => {
       shell.setDraft('前 @w1 后')
       shell.insertReference({
         source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
       }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
+      // Select the chip plus its flanking spaces: detect [1, 4).
+      shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
     })
     const setData = vi.fn()
-    textarea.setSelectionRange(3, 4)
-    fireEvent.copy(textarea, { clipboardData: { setData } })
-    expect(setData).toHaveBeenCalledWith('text/plain', '@w1')
-    expect(shell.snapshot.draft).toBe('前 @会话一 后')
-
-    textarea.setSelectionRange(3, 4)
-    fireEvent.cut(textarea, { clipboardData: { setData } })
-    expect(setData).toHaveBeenLastCalledWith('text/plain', '@w1')
-    expect(shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
+    fireEvent.copy(textarea, { clipboardData: { setData, getData: () => '' } })
+    expect(setData).toHaveBeenCalledWith('text/plain', ' @w1 ')
+    expect(shell.snapshot.draft).toBe('前 @w1 后')
+
+    act(() => {
+      shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
+    })
+    fireEvent.cut(textarea, { clipboardData: { setData, getData: () => '' } })
+    await vi.waitFor(() => {
+      expect(shell.snapshot).toMatchObject({ draft: '前后', occurrences: [] })
+    })
+    expect(setData).toHaveBeenLastCalledWith('text/plain', ' @w1 ')
   })
 
-  it('a lexicon-matched plain token renders the text-ref mark', () => {
+  it('a lexicon-matched plain token renders the text-ref node', () => {
     const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
     const { view, shell } = bench({ lexicon })
     act(() => { shell.setDraft('use /fixture-demo now') })
-    const mark = view.container.querySelector('[data-decoration="text-ref"]')
+    const mark = view.container.querySelector('[data-composer-text-ref]')
     expect(mark?.textContent).toBe('/fixture-demo')
     // Editing the token out of match shape drops the decoration.
     act(() => { shell.setDraft('use /fixture-dem now') })
-    expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
+    expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
   })
 
-  it('a directory completion renders a folder glyph without changing its plain text', () => {
+  it('a directory completion carries the folder appearance without changing its plain text', () => {
     const { view, shell } = bench()
     act(() => { shell.setDraft('see @src/components/') })
-    const mark = view.container.querySelector('[data-decoration="text-ref"]')
+    const mark = view.container.querySelector('[data-composer-text-ref]')
     expect(mark?.textContent).toBe('@src/components/')
-    expect(mark?.querySelector('svg')).not.toBeNull()
+    expect(mark?.getAttribute('data-ref-appearance')).toBe('folder')
     expect(shell.snapshot.draft).toBe('see @src/components/')
   })
 
-  it('a plain-text reference keeps its nodes while earlier text shifts its offset', () => {
-    const { view, textarea, shell } = bench()
+  it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
+    const { view, shell } = bench()
     act(() => { shell.setDraft('see @src/components/ here') })
-    const backdrop = view.container.querySelector('[data-input-backdrop]')!
-    const mark = backdrop.querySelector('[data-decoration="text-ref"]')!
-    const icon = mark.querySelector('svg')!
-    act(() => { fireEvent.change(textarea, { target: { value: 'X see @src/components/ here' } }) })
-    // Node identity, not text: an offset-derived key remounts the mark and its
-    // icon on every keystroke landing ahead of the range.
-    expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBe(mark)
-    expect(icon.isConnected).toBe(true)
+    const mark = view.container.querySelector('[data-composer-text-ref]')!
+    // Type ahead through the node API (the transaction path typing takes).
+    act(() => {
+      shell.editor.update(() => {
+        const first = $getRoot().getAllTextNodes()[0]
+        if ($isTextNode(first)) first.spliceText(0, 0, 'X ')
+      }, { discrete: true })
+    })
+    // Node identity, not text: the entity node survives edits ahead of it.
+    expect(view.container.querySelector('[data-composer-text-ref]')).toBe(mark)
     expect(mark.textContent).toBe('@src/components/')
+    expect(shell.snapshot.draft).toBe('X see @src/components/ here')
     // A token edited out of match shape still loses its decoration.
-    act(() => { fireEvent.change(textarea, { target: { value: 'X see X@src/components/ here' } }) })
-    expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBeNull()
-    expect(shell.snapshot.draft).toBe('X see X@src/components/ here')
+    act(() => { shell.setDraft('X see X@src/components/ here') })
+    expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
   })
 })
 
@@ -1364,8 +1181,9 @@ describe('command launcher chrome and control seats', () => {
     expect(view.getByLabelText('命令')).toBeTruthy()
     // Capability absent (no projection value): the chip renders nothing.
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
-    // Every seat dispatched, nothing rendered.
-    expect(slotCalls.map(c => c.key)).toEqual([
+    // Every seat dispatched, nothing rendered (render passes may repeat; the
+    // seat set is the contract).
+    expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
       'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
     ])
     expect(view.queryByLabelText('Plan mode')).toBeNull()
@@ -1374,8 +1192,8 @@ describe('command launcher chrome and control seats', () => {
 
   it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
     const toggleCommandMenu = vi.fn()
-    const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
-    textarea.setSelectionRange(2, 7)
+    const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
+    act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
     const launcher = view.getByLabelText('命令')
     expect(launcher.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(launcher)

+ 0 - 927
packages/client/ui-conversation/tests/input-machine.client.spec.ts

@@ -1,927 +0,0 @@
-/**
- * InputMachine unit account: the submit
- * plane (adjudication, span CAS, drift
- * guard, anti-backwash), plus the occurrence table (shift / whole-chip
- * deletion / same-name independence), the self-managed undo log (typing
- * coalescing, paste two-stage undo, redo chain), consume-token guards, the
- * paste attempt lifecycle, projectClipboard, and the decoration projection.
- * Pure event sequences — no React, no DOM, no ambient clock.
- */
-import { describe, expect, it } from 'vitest'
-import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
-import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
-import {
-  InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText,
-} from '../src/client/input/machine.ts'
-import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts'
-
-const LEGACY_PLACEHOLDER = PLACEHOLDER
-
-function claimOf(name: string, hint?: string): CommandClaim {
-  return {
-    token: `/${name} `,
-    ...(hint !== undefined ? { hint } : {}),
-    submit: async () => ({ kind: 'success' }),
-  }
-}
-
-function refOf(name: string, source = 'skill'): ReferenceInsert {
-  return { source, ref: name, label: name, clipboardText: `/${name}` }
-}
-
-function spanOf(m: InputMachine, start: number, end: number): TokenSpan {
-  return { start, end, draftRev: m.state.draftRev }
-}
-
-function effectAt<T extends InputEffect['type']>(
-  effects: readonly InputEffect[], index: number, type: T,
-): Extract<InputEffect, { type: T }> {
-  const e = effects[index]
-  expect(e?.type).toBe(type)
-  return e as Extract<InputEffect, { type: T }>
-}
-
-/** Drive plain → adjudicating and hand back the minted attempt. */
-function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
-  m.dispatch({ type: 'draft-changed', draft })
-  const fx = m.dispatch({ type: 'enter', mode })
-  return effectAt(fx, 0, 'adjudicate').attempt
-}
-
-/** Drive plain → claimed → submitting and hand back attempt + claim. */
-function enterSubmitting(m: InputMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
-  const claim = claimOf(name)
-  m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
-  m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
-  m.dispatch({ type: 'draft-changed', draft: claim.token + args })
-  const fx = m.dispatch({ type: 'enter', mode: 'queue' })
-  return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
-}
-
-function staleAttempt(): SubmitAttempt {
-  return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
-}
-
-describe('input-machine: plain × enter', () => {
-  it('empty and whitespace-only drafts produce nothing', () => {
-    const m = new InputMachine()
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    m.dispatch({ type: 'draft-changed', draft: '  \n ' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-  })
-
-  it('non-command text falls to the default sink', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' })
-    expect(effect.attempt.draftSnapshot).toBe('hello world')
-    expect(m.state.phase).toBe('submitting')
-  })
-
-  it('retains an explicit steer mode on the default sink effect', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'steer now' })
-    expect(effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink'))
-      .toMatchObject({ draft: 'steer now', mode: 'steer' })
-  })
-
-  it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    const fx = m.dispatch({ type: 'enter', mode: 'queue' })
-    const eff = effectAt(fx, 0, 'adjudicate')
-    expect(eff.draft).toBe('/goal x')
-    expect(eff.attempt.draftSnapshot).toBe('/goal x')
-    expect(eff.attempt.signal.aborted).toBe(false)
-    expect(m.state.phase).toBe('adjudicating')
-  })
-
-  it('leading is judged after trim including newlines', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
-  })
-
-  it('a non-whitespace prefix before "/" is not leading — default sink', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
-    expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink'))
-      .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' })
-  })
-})
-
-describe('input-machine: adjudication outcomes', () => {
-  it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/goal x\ny')
-    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
-    const eff = effectAt(fx, 0, 'begin-submit')
-    expect(eff.args).toBe('x\ny')
-    expect(eff.attempt.seq).toBe(attempt.seq)
-    expect(m.state.phase).toBe('submitting')
-    expect(m.state.claim).toEqual({ token: '/goal ' })
-  })
-
-  it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
-    const a = new InputMachine()
-    const attemptA = enterAdjudicating(a, '/goal')
-    expect(effectAt(a.dispatch({ type: 'adjudicated', attempt: attemptA, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('')
-
-    const b = new InputMachine()
-    const attemptB = enterAdjudicating(b, '\n\n/goal x')
-    expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
-  })
-
-  it('undefined outcome falls back to the default sink', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
-    expect(effectAt(
-      m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }),
-      0,
-      'default-sink',
-    )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' })
-    expect(m.state.phase).toBe('submitting')
-  })
-
-  it("'handled' lands plain with zero effects (popup shell path)", () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/model')
-    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.draft).toBe('/model')
-  })
-
-  it('adjudication failure notices and keeps the draft — no silent downgrade', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' }))
-      .toEqual([{ type: 'notice', level: 'error', text: 'warmup failed' }])
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.draft).toBe('/goal x')
-  })
-
-  it('enter is a no-op while adjudicating (pending lock)', () => {
-    const m = new InputMachine()
-    enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    expect(m.state.phase).toBe('adjudicating')
-  })
-
-  it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
-    const m = new InputMachine()
-    enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: { claim: claimOf('goal') } })).toEqual([])
-    expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
-    expect(m.state.phase).toBe('adjudicating')
-  })
-
-  it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/goal x')
-    m.dispatch({ type: 'release' })
-    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-  })
-})
-
-describe('input-machine: begin-command CAS', () => {
-  it('valid span replaces it with the token and enters claimed; success = draftRev advance', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    const before = m.state.draftRev
-    const fx = m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
-    expect(fx).toEqual([])
-    expect(m.state.draftRev).toBeGreaterThan(before)
-    expect(m.state.draft).toBe('/goal ')
-    expect(m.state.phase).toBe('claimed')
-    expect(m.state.claim).toEqual({ token: '/goal ', hint: 'objective' })
-  })
-
-  it('a leading-whitespace prefix is dropped so the startsWith watch holds', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '\n\n/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })
-    expect(m.state.draft).toBe('/goal ')
-    m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    expect(m.state.phase).toBe('claimed')
-  })
-
-  it('a stale draftRev no-ops the whole action — no state change, no revision bump', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    const span = spanOf(m, 0, 3)
-    m.dispatch({ type: 'draft-changed', draft: '/goX' })
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span })).toEqual([])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: '/goX', draftRev: rev })
-  })
-
-  it('a non-whitespace prefix before the span no-ops (leading-trigger contract)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'x /go' })
-    expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-  })
-
-  it('claimed overwrites in place — no stack', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })
-    expect(m.state.draft).toBe('/model ')
-    expect(m.state.claim?.token).toBe('/model ')
-    expect(m.state.phase).toBe('claimed')
-  })
-
-  it('submitting rejects begin-command (lock)', () => {
-    const m = new InputMachine()
-    enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })).toEqual([])
-    expect(m.state.claim?.token).toBe('/goal ')
-    expect(m.state.phase).toBe('submitting')
-  })
-
-  it('undo reverts the claim transaction and the watch releases the claim', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'undo' })
-    expect(m.state).toMatchObject({ draft: '/go', phase: 'plain' })
-    expect(m.state.claim).toBeUndefined()
-  })
-})
-
-describe('input-machine: insert-ref and the occurrence table', () => {
-  it('valid span becomes one inline display range + one occurrence with cached projections', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
-    const reference = { ...refOf('worker-1', 'reference'), appearance: 'session' as const }
-    const fx = m.dispatch({
-      type: 'insert-ref',
-      reference,
-      span: spanOf(m, 4, 8),
-    })
-    expect(fx).toEqual([])
-    const displayText = referenceDraftText(reference)
-    expect(m.state.draft).toBe(`see ${displayText} now`)
-    expect(m.state.occurrences).toEqual([{
-      occurrenceId: 1, source: 'reference', ref: 'worker-1', offset: 4,
-      length: displayText.length,
-      label: 'worker-1', appearance: 'session', clipboardText: '/worker-1',
-    }])
-    expect(m.state.phase).toBe('plain')
-  })
-
-  it('same-named references stay independent: distinct occurrenceIds, one deletion leaves the other', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
-    const displayText = referenceDraftText(refOf('alpha'))
-    const secondDraft = `${displayText} and /alp`
-    const secondStart = secondDraft.lastIndexOf('/alp')
-    m.dispatch({
-      type: 'draft-changed',
-      draft: secondDraft,
-      editRange: { start: displayText.length, end: displayText.length + 1, insertedLength: ' and /alp'.length },
-    })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, secondStart, secondStart + 4) })
-    expect(m.state.draft).toBe(`${displayText} and ${displayText} `)
-    expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
-    // Delete the first reference range whole; the second survives with its own identity.
-    m.dispatch({
-      type: 'draft-changed',
-      draft: ` and ${displayText} `,
-      editRange: { start: 0, end: displayText.length, insertedLength: 0 },
-    })
-    expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
-  })
-
-  it('claimed stays claimed across an inline insert (inline "@" during command args)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
-    expect(m.state.draft).toBe(`/goal ask ${referenceDraftText(refOf('worker-1'))} `)
-    expect(m.state.phase).toBe('claimed')
-    expect(m.state.occurrences).toHaveLength(1)
-  })
-
-  it('a stale draftRev no-ops: no draft change, no occurrence', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'see @wor' })
-    const span = spanOf(m, 4, 8)
-    m.dispatch({ type: 'draft-changed', draft: 'see @work' })
-    expect(m.dispatch({ type: 'insert-ref', reference: refOf('w'), span })).toEqual([])
-    expect(m.state.occurrences).toEqual([])
-  })
-})
-
-describe('input-machine: occurrence reconciliation on draft edits', () => {
-  /** Machine with one reference range at offset 4 inside `see @worker-1 now`. */
-  function withChip(): InputMachine {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
-    return m
-  }
-
-  it('an edit before the reference shifts the offset by the length delta (explicit editRange)', () => {
-    const m = withChip()
-    m.dispatch({ type: 'draft-changed', draft: `I ${m.state.draft}`, editRange: { start: 0, end: 0, insertedLength: 2 } })
-    expect(m.state.occurrences[0]?.offset).toBe(6)
-    m.dispatch({ type: 'draft-changed', draft: m.state.draft.slice(2), editRange: { start: 0, end: 2, insertedLength: 0 } })
-    expect(m.state.occurrences[0]?.offset).toBe(4)
-  })
-
-  it('an edit after the reference leaves the offset alone', () => {
-    const m = withChip()
-    const oldDraft = m.state.draft
-    const start = oldDraft.indexOf('now')
-    m.dispatch({
-      type: 'draft-changed',
-      draft: oldDraft.replace('now', 'later'),
-      editRange: { start, end: start + 3, insertedLength: 5 },
-    })
-    expect(m.state.occurrences[0]?.offset).toBe(4)
-  })
-
-  it('a deletion covering the reference removes the whole occurrence', () => {
-    const m = withChip()
-    const occurrence = m.state.occurrences[0]!
-    m.dispatch({
-      type: 'draft-changed',
-      draft: m.state.draft.slice(0, occurrence.offset) + m.state.draft.slice(occurrence.offset + occurrence.length),
-      editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 0 },
-    })
-    expect(m.state.occurrences).toEqual([])
-    expect(m.state.draft).toBe('see  now')
-  })
-
-  it('a replacement spanning the reference removes the occurrence and keeps the replacement text', () => {
-    const m = withChip()
-    const occurrence = m.state.occurrences[0]!
-    m.dispatch({
-      type: 'draft-changed',
-      draft: 'see all of it now',
-      editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 9 },
-    })
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
-    const m = withChip()
-    m.dispatch({ type: 'draft-changed', draft: m.state.draft.replace('see ', 'see there ') })
-    expect(m.state.occurrences[0]?.offset).toBe(10)
-  })
-
-  it('without editRange the diff scan detects reference deletion', () => {
-    const m = withChip()
-    m.dispatch({ type: 'draft-changed', draft: 'see now' })
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('an identical draft is a no-op: no revision bump, no undo entry', () => {
-    const m = withChip()
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'draft-changed', draft: m.state.draft })).toEqual([])
-    expect(m.state.draftRev).toBe(rev)
-  })
-})
-
-describe('input-machine: consume-token guards', () => {
-  it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/model rest' })
-    const before = m.state.draftRev
-    m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
-    expect(m.state.draftRev).toBeGreaterThan(before)
-    expect(m.state.draft).toBe('rest')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('/model rest')
-  })
-
-  it('span guard: a stale draftRev refuses — no deletion, no revision bump', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/model' })
-    const span = spanOf(m, 0, 6)
-    m.dispatch({ type: 'draft-changed', draft: '/model x' })
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'consume-token', guard: { kind: 'span', span } })).toEqual([])
-    expect(m.state).toMatchObject({ draft: '/model x', draftRev: rev })
-  })
-
-  it('bare-token guard: trimmed equality clears the draft; mismatch refuses', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '  /model \n' })
-    m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })
-    expect(m.state.draft).toBe('')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('  /model \n')
-
-    m.dispatch({ type: 'draft-changed', draft: '/model extra' })
-    const rev = m.state.draftRev
-    expect(m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })).toEqual([])
-    expect(m.state).toMatchObject({ draft: '/model extra', draftRev: rev })
-  })
-
-  it('a chip elsewhere in the draft shifts across a span consume', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
-    m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
-    expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
-    expect(m.state.occurrences[0]?.offset).toBe(0)
-  })
-})
-
-describe('input-machine: undo / redo', () => {
-  it('the default constant clock coalesces contiguous single-char typing into one transaction', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-    m.dispatch({ type: 'redo' })
-    expect(m.state.draft).toBe('abc')
-  })
-
-  it('the merge window splits typing runs: within merges, beyond opens a new transaction', () => {
-    let t = 0
-    const m = new InputMachine({ mergeWindowMs: 1000, now: () => t })
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    t = 900
-    m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
-    t = 2500 // beyond the window from the previous char
-    m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('ab')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('non-contiguous or multi-char edits never merge into a typing run', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'ba', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'draft-changed', draft: 'baXY', editRange: { start: 2, end: 2, insertedLength: 2 } })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('ba')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('a')
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('a new transaction cuts the redo chain', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    m.dispatch({ type: 'undo' })
-    m.dispatch({ type: 'draft-changed', draft: 'z', editRange: { start: 0, end: 0, insertedLength: 1 } })
-    expect(m.dispatch({ type: 'redo' })).toEqual([])
-    expect(m.state.draft).toBe('z')
-  })
-
-  it('undo on an empty log and redo on an empty chain are no-ops', () => {
-    const m = new InputMachine()
-    expect(m.dispatch({ type: 'undo' })).toEqual([])
-    expect(m.dispatch({ type: 'redo' })).toEqual([])
-  })
-
-  it('the log ring caps at 100 transactions', () => {
-    let t = 0
-    const m = new InputMachine({ mergeWindowMs: 0, now: () => (t += 10) })
-    let draft = ''
-    for (let i = 0; i < 110; i += 1) {
-      draft += 'x'
-      m.dispatch({ type: 'draft-changed', draft, editRange: { start: i, end: i, insertedLength: 1 } })
-    }
-    for (let i = 0; i < 100; i += 1) m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('x'.repeat(10))
-    expect(m.dispatch({ type: 'undo' })).toEqual([])
-    expect(m.state.draft).toBe('x'.repeat(10))
-  })
-
-  it('undo restores the occurrence table with the draft (chip resurrection)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '@wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
-    m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: m.state.draft.length, insertedLength: 0 } })
-    expect(m.state.occurrences).toEqual([])
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
-    expect(m.state.occurrences).toHaveLength(1)
-  })
-
-  it('a committed submit clears the log: undo cannot resurrect sent content', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    m.dispatch({ type: 'submit-settled', attempt, ok: true })
-    expect(m.state.draft).toBe('')
-    expect(m.dispatch({ type: 'undo' })).toEqual([])
-    expect(m.state.draft).toBe('')
-  })
-
-  it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'hello' })
-    const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true })
-    expect(m.state.draft).toBe(' world')
-
-    const n = new InputMachine()
-    n.dispatch({ type: 'draft-changed', draft: 'hello' })
-    const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    n.dispatch({ type: 'draft-changed', draft: 'hXello' })
-    n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true })
-    expect(n.state.draft).toBe('')
-  })
-})
-
-describe('input-machine: paste plane', () => {
-  it('paste replaces the selection as one transaction and opens a match attempt', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'abc' })
-    m.dispatch({ type: 'paste-begin', text: 'XY', selection: { start: 1, end: 2 }, generation: 7 })
-    expect(m.state.draft).toBe('aXYc')
-    expect(m.state.paste).toEqual({ attemptId: 1, insertedRange: { start: 1, end: 3 }, generation: 7 })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('abc')
-  })
-
-  it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: `x${LEGACY_PLACEHOLDER}y`, selection: { start: 0, end: 0 } })
-    expect(m.state.draft).toBe('xy')
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('sync hot-snapshot components mint inside the SAME transaction: one undo returns to pre-paste', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'hi ' })
-    m.dispatch({
-      type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
-      components: [{ start: 0, end: 6, reference: refOf('alpha') }],
-    })
-    expect(m.state.draft).toBe(`hi ${referenceDraftText(refOf('alpha'))} x`)
-    expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
-    expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: m.state.draft.length })
-    m.dispatch({ type: 'undo' })
-    expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
-  })
-
-  it('async upgrade is an INDEPENDENT transaction: undo #1 → token text, undo #2 → pre-paste', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
-    expect(m.state.paste?.attemptId).toBe(1)
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
-    expect(m.state.draft).toBe(`${referenceDraftText(refOf('alpha'))} rest`)
-    expect(m.state.occurrences).toHaveLength(1)
-    m.dispatch({ type: 'undo' })
-    expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
-    m.dispatch({ type: 'undo' })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('the attempt survives upgrades: successive tokens re-CAS against the advanced revision', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
-    const alpha = referenceDraftText(refOf('alpha'))
-    expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: alpha.length + 6 })
-    const betaStart = m.state.draft.indexOf('/beta')
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, betaStart, betaStart + 5), reference: refOf('beta') })
-    expect(m.state.draft).toBe(`${alpha} ${referenceDraftText(refOf('beta'))} `)
-    expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
-    expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: m.state.draft.length })
-  })
-
-  it('a stale span CAS drops one upgrade without ending the attempt', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
-    const preSpan = spanOf(m, 7, 12)
-    m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
-    expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: preSpan, reference: refOf('beta') })).toEqual([])
-    expect(m.state.occurrences).toHaveLength(1)
-    expect(m.state.paste).toBeDefined()
-  })
-
-  it('any new input transaction ends the attempt; late upgrades drop whole', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
-    m.dispatch({ type: 'draft-changed', draft: '/alpha!', editRange: { start: 6, end: 6, insertedLength: 1 } })
-    expect(m.state.paste).toBeUndefined()
-    expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
-    expect(m.state.occurrences).toEqual([])
-  })
-
-  it('invalidate-paste (caret/selection/slash activity) and submit start end the attempt', () => {
-    const a = new InputMachine()
-    a.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
-    a.dispatch({ type: 'invalidate-paste' })
-    expect(a.state.paste).toBeUndefined()
-
-    const b = new InputMachine()
-    b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
-    b.dispatch({ type: 'enter', mode: 'queue' })
-    expect(b.state.paste).toBeUndefined()
-  })
-
-  it('a mismatched attemptId is dropped (superseded paste)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
-    m.dispatch({ type: 'paste-begin', text: ' /beta', selection: { start: 6, end: 6 } })
-    expect(m.state.paste?.attemptId).toBe(2)
-    expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
-    expect(m.state.occurrences).toEqual([])
-  })
-})
-
-describe('input-machine: set-invalid styling bits', () => {
-  it('flags exactly the listed occurrences without a transaction', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
-    const alpha = referenceDraftText(refOf('alpha'))
-    m.dispatch({
-      type: 'draft-changed',
-      draft: `${alpha} /bet`,
-      editRange: { start: alpha.length + 1, end: alpha.length + 1, insertedLength: 5 },
-    })
-    m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, alpha.length + 1, alpha.length + 5) })
-    const rev = m.state.draftRev
-    m.dispatch({ type: 'set-invalid', invalidIds: [1] })
-    expect(m.state.draftRev).toBe(rev)
-    expect(m.state.occurrences.map(o => o.invalid === true)).toEqual([true, false])
-    // Recovery: the same source/ref resolving again clears the bit.
-    m.dispatch({ type: 'set-invalid', invalidIds: [] })
-    expect(m.state.occurrences.every(o => o.invalid === undefined)).toBe(true)
-  })
-
-  it('a no-change call keeps the table reference (no spurious publish)', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
-    const table = m.state.occurrences
-    expect(m.dispatch({ type: 'set-invalid', invalidIds: [] })).toEqual([])
-    expect(m.state.occurrences).toBe(table)
-  })
-})
-
-describe('input-machine: projectClipboard', () => {
-  it('expands each reference range to its occurrence clipboardText in draft order', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
-    const alpha = referenceDraftText(refOf('alpha'))
-    const secondDraft = `use ${alpha} then /bet`
-    const secondStart = secondDraft.lastIndexOf('/bet')
-    m.dispatch({
-      type: 'draft-changed',
-      draft: secondDraft,
-      editRange: { start: 4 + alpha.length + 1, end: 4 + alpha.length + 1, insertedLength: 'then /bet'.length },
-    })
-    m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, secondStart, secondStart + 4) })
-    expect(m.state.draft).toBe(`use ${alpha} then ${referenceDraftText(refOf('beta'))} `)
-    expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
-  })
-
-  it('is the identity on a chip-free draft', () => {
-    expect(projectClipboard({ draft: 'plain text', occurrences: [] })).toBe('plain text')
-  })
-})
-
-describe('decorations: scanTextRefs', () => {
-  const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
-    ['/', ['commit-helper', 'fixture-demo']],
-    ['@', ['worker-1']],
-  ])
-
-  it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
-    expect(scanTextRefs('/commit-helper then @worker-1 ok', LEX)).toEqual([
-      { start: 0, end: 14, trigger: '/' },
-      { start: 20, end: 29, trigger: '@' },
-    ])
-  })
-
-  it('a cold (empty) lexicon scans nothing', () => {
-    expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
-  })
-
-  it('recognizes directory paths independently of the dynamic lexicon', () => {
-    expect(scanTextRefs('open @src/components/ or @"docs/design notes/', new Map())).toEqual([
-      { start: 5, end: 21, trigger: '@', appearance: 'folder' },
-      { start: 25, end: 45, trigger: '@', appearance: 'folder' },
-    ])
-  })
-
-  it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
-    expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
-  })
-
-  it('word boundary: a trigger glued to text never matches', () => {
-    expect(scanTextRefs('x/commit-helper', LEX)).toEqual([])
-    expect(scanTextRefs('a@worker-1', LEX)).toEqual([])
-  })
-
-  it('tokens never cross a newline; a token straight after one matches', () => {
-    expect(scanTextRefs('line\n/commit-helper', LEX)).toEqual([
-      { start: 5, end: 19, trigger: '/' },
-    ])
-  })
-
-  it('deriveDecorations threads the lexicon through as textRefs', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: 'use /commit-helper now' })
-    expect(deriveDecorations(m.state, LEX).textRefs).toEqual([
-      { start: 4, end: 18, trigger: '/' },
-    ])
-  })
-})
-
-describe('input-machine: decorations', () => {
-  it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/alp' })
-    const reference = { ...refOf('alpha'), appearance: 'file' as const }
-    m.dispatch({
-      type: 'insert-ref',
-      reference,
-      span: spanOf(m, 0, 4),
-    })
-    m.dispatch({ type: 'set-invalid', invalidIds: [1] })
-    expect(deriveDecorations(m.state)).toEqual({
-      token: null,
-      chips: [{
-        occurrenceId: 1,
-        offset: 0,
-        length: referenceDraftText(reference).length,
-        text: referenceDraftText(reference),
-        label: 'alpha',
-        appearance: 'file',
-        invalid: true,
-      }],
-      textRefs: [],
-      hint: null,
-    })
-  })
-
-  it('claim token range and ghost hint show while claimed with blank args; args clear the hint', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
-    expect(deriveDecorations(m.state)).toEqual({
-      token: { start: 0, end: 6 },
-      chips: [],
-      textRefs: [],
-      hint: 'objective',
-    })
-    m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    expect(deriveDecorations(m.state)).toMatchObject({ token: { start: 0, end: 6 }, hint: null })
-  })
-
-  it('the token range persists through submitting; a hintless claim never ghosts', () => {
-    const m = new InputMachine()
-    enterSubmitting(m, 'goal', '')
-    expect(deriveDecorations(m.state)).toEqual({ token: { start: 0, end: 6 }, chips: [], textRefs: [], hint: null })
-  })
-})
-
-describe('input-machine: claimed lifecycle', () => {
-  it('breaking startsWith(token) auto-releases back to plain', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'draft-changed', draft: '/goal make' })
-    expect(m.state.phase).toBe('claimed')
-    m.dispatch({ type: 'draft-changed', draft: '/goa make' })
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.claim).toBeUndefined()
-    expect(m.state.draft).toBe('/goa make')
-  })
-
-  it('explicit release returns to plain when nothing is in flight', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '/go' })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    expect(m.dispatch({ type: 'release' })).toEqual([])
-    expect(m.state.phase).toBe('plain')
-    expect(m.state.claim).toBeUndefined()
-  })
-
-  it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
-    const m = new InputMachine()
-    const { attempt, claim } = enterSubmitting(m, 'goal', 'line1\nline2')
-    expect(attempt.draftSnapshot).toBe('/goal line1\nline2')
-    m.dispatch({ type: 'submit-settled', attempt, ok: true })
-    expect(m.state.draft).toBe('')
-    expect(claim.token).toBe('/goal ')
-  })
-})
-
-describe('input-machine: submitting transaction', () => {
-  it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
-    const m = new InputMachine()
-    enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
-    expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
-    expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
-  })
-
-  it('commit clears draft and occurrences, releases the claim, and relays the outcome text', () => {
-    const m = new InputMachine()
-    m.dispatch({ type: 'draft-changed', draft: '@wor' })
-    m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
-    const refLength = referenceDraftText(refOf('worker-1')).length
-    m.dispatch({
-      type: 'draft-changed',
-      draft: `${referenceDraftText(refOf('worker-1'))}/go`,
-      editRange: { start: refLength + 1, end: refLength + 1, insertedLength: 3 },
-    })
-    m.dispatch({
-      type: 'draft-changed',
-      draft: '/go',
-      editRange: { start: 0, end: refLength + 1, insertedLength: 0 },
-    })
-    m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
-    m.dispatch({ type: 'draft-changed', draft: '/goal go' })
-    const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
-    expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
-    expect(m.state.claim).toBeUndefined()
-  })
-
-  it('rollback with an undeviated draft keeps the snapshot and re-enters claimed (same claim)', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
-    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
-    expect(m.state).toMatchObject({ phase: 'claimed', draft: '/goal x' })
-    expect(m.state.claim?.token).toBe('/goal ')
-  })
-
-  it('rollback with a deviated draft only notices — the newer input wins', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    m.dispatch({ type: 'draft-changed', draft: 'fresh typing' })
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
-    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: 'fresh typing' })
-    expect(m.state.claim).toBeUndefined()
-  })
-
-  it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
-    // '\n\n/goal x' round-trips through adjudication; the whitespace prefix
-    // would instantly break the claimed watch, so rollback lands plain.
-    const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '\n\n/goal x')
-    m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
-    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
-    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
-    expect(m.state).toMatchObject({ phase: 'plain', draft: '\n\n/goal x' })
-  })
-
-  it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
-    const m = new InputMachine()
-    const { attempt: first } = enterSubmitting(m, 'goal', 'x')
-    m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
-    const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
-    expect(second.seq).not.toBe(first.seq)
-    expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
-    expect(m.state.phase).toBe('submitting')
-    m.dispatch({ type: 'submit-settled', attempt: second, ok: true })
-    expect(m.state.draft).toBe('')
-  })
-
-  it('release mid-flight aborts the attempt and later settles are dropped', () => {
-    const m = new InputMachine()
-    const { attempt } = enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'release' })).toEqual([])
-    expect(attempt.signal.aborted).toBe(true)
-    expect(m.state.phase).toBe('plain')
-    expect(m.dispatch({ type: 'submit-settled', attempt, ok: true })).toEqual([])
-    expect(m.state.draft).toBe('/goal x')
-  })
-})
-
-describe('input-machine: per-session isolation', () => {
-  it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
-    const a = new InputMachine()
-    const b = new InputMachine()
-    const { attempt } = enterSubmitting(a, 'goal', 'from A')
-    // B stays fully live while A holds its lock.
-    b.dispatch({ type: 'draft-changed', draft: '/mo' })
-    b.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(b, 0, 3) })
-    expect(b.state.phase).toBe('claimed')
-    expect(a.state.phase).toBe('submitting')
-    // A's commit falls back to A alone.
-    a.dispatch({ type: 'submit-settled', attempt, ok: true })
-    expect(a.state).toMatchObject({ phase: 'plain', draft: '' })
-    expect(b.state).toMatchObject({ phase: 'claimed', draft: '/model ' })
-  })
-})

+ 32 - 23
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -21,6 +21,13 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 afterEach(cleanup)
 
 const SCTX = {} as ClientContext
@@ -86,7 +93,7 @@ function bench(over?: {
   const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
   const wiring = shell
   const view = mountBar(shell, over)
-  const textarea = view.container.querySelector('textarea')!
+  const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
   const claim = (token = '/goal ', hint = '目标', images?: true) => {
     act(() => {
       shell.setDraft(token)
@@ -106,7 +113,7 @@ function bench(over?: {
 describe('matrix row: plain', () => {
   it('enter falls to the default sink; no claim on the currency; edits free', async () => {
     const { textarea, shell, sink } = bench()
-    fireEvent.change(textarea, { target: { value: '普通消息' } })
+    act(() => { shell.setDraft('普通消息') })
     expect(shell.snapshot.claim).toBeUndefined()
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
@@ -120,37 +127,39 @@ describe('matrix row: claimed', () => {
   it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
     const { view, textarea, shell, claim } = bench()
     claim()
+    act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
     expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
-    expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
+    expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ')
     // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
-    expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
-    expect((textarea).readOnly).toBe(false)
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
+    expect(textarea.getAttribute('contenteditable')).toBe('true')
     // Free editing beyond the token: hint drops, claim holds.
-    fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
+    act(() => { shell.setDraft('/goal 发布版本') })
     expect(shell.snapshot.phase).toBe('claimed')
-    expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
+    expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
   })
 
   it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
-    const { view, textarea, sink, claim } = bench({ submit })
+    const { view, textarea, shell, sink, claim } = bench({ submit })
     claim()
-    fireEvent.change(textarea, { target: { value: '/goal 发布' } })
+    act(() => { shell.setDraft('/goal 发布') })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).not.toHaveBeenCalled()
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) })
     // Commit: draft cleared, notice surfaced, back to plain.
-    await vi.waitFor(() => { expect((textarea).value).toBe('') })
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
     expect(view.getByText('完成')).toBeTruthy()
   })
 
   it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
-    const { view, textarea, shell, claim } = bench()
+    const { view, shell, claim } = bench()
     claim()
-    fireEvent.change(textarea, { target: { value: '/goa 发布' } }) // token broken
+    act(() => { shell.setDraft('/goa 发布') }) // token broken
     expect(shell.snapshot.phase).toBe('plain')
     expect(shell.snapshot.claim).toBeUndefined()
-    expect(view.container.querySelector('[data-decoration="token"]')).toBeNull()
+    act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+    expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull()
   })
 })
 
@@ -169,7 +178,7 @@ describe('matrix row: claimed with images', () => {
     expect(sink).not.toHaveBeenCalled()
     expect(view.getByText('/goal images-unsupported')).toBeTruthy()
     expect(shell.snapshot.imageIds).toEqual([img])
-    expect((textarea).value).toBe('/goal ')
+    expect(shell.snapshot.draft).toBe('/goal ')
   })
 
   it('an accepting claim serializes and forwards the images; success consumes and clears', async () => {
@@ -183,7 +192,7 @@ describe('matrix row: claimed with images', () => {
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) })
     expect(serialize).toHaveBeenCalledWith([img])
-    await vi.waitFor(() => { expect((textarea).value).toBe('') })
+    await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
     expect(release).toHaveBeenCalledWith([img])
     expect(shell.snapshot.imageIds).toEqual([])
     expect(shell.snapshot.phase).toBe('plain')
@@ -199,7 +208,7 @@ describe('matrix row: claimed with images', () => {
     expect(shell.snapshot.phase).toBe('claimed')
     expect(shell.snapshot.imageIds).toEqual([img])
     expect(release).not.toHaveBeenCalled()
-    expect((textarea).value).toBe('/goal ')
+    expect(shell.snapshot.draft).toBe('/goal ')
   })
 
   it('a serialize rejection blocks the transaction: notice, no submit call, images kept', async () => {
@@ -253,7 +262,7 @@ describe('matrix row: submitting', () => {
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(shell.snapshot.phase).toBe('submitting')
     expect(shell.snapshot.claim).toBeDefined()
-    expect((textarea).readOnly).toBe(true)
+    expect(textarea.getAttribute('contenteditable')).toBe('false')
     // Enter is dead inside the lock (submit dispatch is microtask-deferred).
     await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -271,7 +280,7 @@ describe('matrix row: submitting', () => {
     await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
     act(() => { rejectSubmit(new Error('执行失败')) })
     await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
-    expect((first.textarea).value).toBe('/goal ')
+    expect(first.shell.snapshot.draft).toBe('/goal ')
     expect(first.view.getByText('执行失败')).toBeTruthy()
     cleanup()
     // Drift: typing during flight wins; no restore, plain, notice only.
@@ -283,7 +292,7 @@ describe('matrix row: submitting', () => {
     act(() => { second.shell.setDraft('用户飞行中打的新稿') })
     act(() => { rejectSubmit(new Error('晚到失败')) })
     await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
-    expect((second.textarea).value).toBe('用户飞行中打的新稿')
+    expect(second.shell.snapshot.draft).toBe('用户飞行中打的新稿')
     expect(second.view.getByText('晚到失败')).toBeTruthy()
   })
 })
@@ -291,15 +300,15 @@ describe('matrix row: submitting', () => {
 describe('matrix row: locked (session disabled)', () => {
   it('disables the textarea and chrome; the machine currency is untouched', () => {
     const { view, textarea, shell } = bench({ disabled: true })
-    expect((textarea).disabled).toBe(true)
+    expect(textarea.getAttribute('aria-disabled')).toBe('true')
     expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
     expect(shell.snapshot.phase).toBe('plain')
   })
 
   it('running does NOT lock: typing and enter-queue stay live', () => {
-    const { textarea, sink } = bench({ running: true })
-    expect((textarea).disabled).toBe(false)
-    fireEvent.change(textarea, { target: { value: '排队' } })
+    const { textarea, shell, sink } = bench({ running: true })
+    expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    act(() => { shell.setDraft('排队') })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal))
   })

+ 15 - 12
packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts

@@ -53,7 +53,9 @@ describe('reference submission', () => {
       end: 4,
       draftRev: first.snapshot.draftRev,
     })).toBe(true)
-    expect(first.snapshot.draft).toBe('@Research notes ')
+    // InputState.draft IS the clipboard projection now (chips expand to their
+    // canonical text); the display label lives in the chip's decorator DOM.
+    expect(first.snapshot.draft).toBe(`${spacedMention} `)
     expect(mirror).toHaveBeenLastCalledWith(`${spacedMention} `)
 
     const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
@@ -82,6 +84,7 @@ describe('reference submission', () => {
     const inputTriggers = {
       serializeReference,
       track: vi.fn(),
+      lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
     } as unknown as InputTriggerController
     const shell = new SessionInputShell({
       actx: {} as ClientContext,
@@ -91,8 +94,8 @@ describe('reference submission', () => {
     })
     chip(shell)
     expect(shell.snapshot).toMatchObject({
-      draft: '@Research ',
-      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
+      draft: `${mention} `,
+      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: mention.length }],
     })
 
     shell.submit('queue')
@@ -102,8 +105,8 @@ describe('reference submission', () => {
     })
     expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
     expect(shell.snapshot).toMatchObject({
-      draft: '@Research ',
-      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }],
+      draft: `${mention} `,
+      occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: mention.length }],
     })
     expect(shell.notices.getSnapshot()).toMatchObject({
       level: 'error',
@@ -124,6 +127,7 @@ describe('reference submission', () => {
     const inputTriggers = {
       serializeReference: () => Promise.reject(new Error('reference codec unavailable')),
       track: vi.fn(),
+      lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
     } as unknown as InputTriggerController
     const shell = new SessionInputShell({
       actx: {} as ClientContext,
@@ -137,7 +141,7 @@ describe('reference submission', () => {
       expect(shell.snapshot.phase).toBe('plain')
     })
     expect(sink).not.toHaveBeenCalled()
-    expect(shell.snapshot.draft).toBe('@Research ')
+    expect(shell.snapshot.draft).toBe(`${mention} `)
     expect(shell.snapshot.occurrences).toHaveLength(1)
     expect(shell.notices.getSnapshot()).toMatchObject({
       level: 'error',
@@ -219,11 +223,12 @@ describe('submit transaction hardening', () => {
     expect(shell.notices.getSnapshot()).toBeNull()
   })
 
-  it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => {
+  it('re-tracks at the caret when an insert-text splice lands (directory descent reopens the menu)', () => {
     const track = vi.fn()
+    const lexicon = { getSnapshot: () => new Map(), subscribe: () => () => {} }
     const shell = new SessionInputShell({
       actx: {} as ClientContext,
-      inputTriggers: () => ({ track } as unknown as InputTriggerController),
+      inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController),
       defaultSink: vi.fn(),
       commandImages,
     })
@@ -231,10 +236,8 @@ describe('submit transaction hardening', () => {
     const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)
     expect(applied).toBe(true)
     expect(shell.snapshot.draft).toBe('@src/')
+    // Every editor commit re-tracks at the settled caret (the continue flag
+    // is a contract passenger now): a trailing '/' keeps the menu open.
     expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
-
-    track.mockClear()
-    shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev })
-    expect(track).not.toHaveBeenCalled()
   })
 })

+ 22 - 13
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -30,6 +30,13 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 afterEach(cleanup)
 
 /** Directory row driving kind derivation (input? = leadingInput, else execute). */
@@ -181,9 +188,9 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
     variant: 'composer',
   }
   const view = render(<InputBar {...barProps} />)
-  const textarea = view.container.querySelector('textarea')!
+  const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
   const type = (text: string): void => {
-    fireEvent.change(textarea, { target: { value: text } })
+    act(() => { shell.setDraft(text) })
   }
   return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release }
 }
@@ -209,17 +216,18 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => {
     // Pointer pick (menu path executes through the bound target inside the pipeline).
     act(() => { b.controller.pick('command', 0) })
     expect(b.shell.snapshot.phase).toBe('claimed')
-    expect(b.textarea.value).toBe('/goal ')
-    expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
+    expect(b.shell.snapshot.draft).toBe('/goal ')
+    act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
+    expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ')
     // The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
-    expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
+    expect(b.textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
     // Continue typing args; hint drops; claim holds.
     b.type('/goal 发布 v1')
     expect(b.shell.snapshot.phase).toBe('claimed')
     // Enter: submitting → command execute → commit clears.
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1', []) })
-    await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
+    await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') })
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy()
     expect(b.sink).not.toHaveBeenCalled()
@@ -235,7 +243,7 @@ describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => {
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布', []) })
     await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
-    expect(b.textarea.value).toBe('')
+    expect(b.shell.snapshot.draft).toBe('')
     expect(b.sink).not.toHaveBeenCalled()
   })
 })
@@ -278,7 +286,7 @@ describe('scenario: images ride an accepting command through the real pipeline',
     // The envelope the controller forwarded to matchEnter carried the count.
     expect(b.envelopes).toEqual([{ images: 1 }])
     expect(b.serialize).toHaveBeenCalledWith(['img-1'])
-    await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
+    await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') })
     expect(b.release).toHaveBeenCalledWith(['img-1'])
     expect(b.shell.snapshot.imageIds).toEqual([])
     expect(b.sink).not.toHaveBeenCalled()
@@ -301,12 +309,12 @@ describe('scenario H: backspace breaks the token', () => {
     b.type('/goal')
     await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
     // Space adjudication claims (space column, leadingInput).
-    fireEvent.keyDown(b.textarea, { key: ' ' })
+    fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
     expect(b.shell.snapshot.phase).toBe('claimed')
     // Backspace into the token: watch break → plain, visuals gone.
     b.type('/goa ')
     expect(b.shell.snapshot.phase).toBe('plain')
-    expect(b.view.container.querySelector('[data-decoration="token"]')).toBeNull()
+    expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull()
   })
 })
 
@@ -328,13 +336,14 @@ describe('scenario: reference decoration lights up when the lexicon settles', ()
     })
     // Typed before the catalog settled: a plain token, no decoration.
     b.type('/deploy now')
-    expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
+    expect(b.view.container.querySelector('[data-composer-text-ref]')).toBeNull()
     // The catalog settles (ui-skill's settle path fires the same notification).
     act(() => {
       roll = ['deploy']
       notify?.()
     })
-    const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
+    act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued re-scan
+    const mark = b.view.container.querySelector('[data-composer-text-ref]')
     expect(mark?.textContent).toBe('/deploy')
   })
 })
@@ -362,7 +371,7 @@ describe('scenario I: unknown /xyz + enter', () => {
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.view.getByText('目录预热失败')).toBeTruthy() })
     // Never a silent downgrade: draft retained, sink untouched.
-    expect(b.textarea.value).toBe('/plan 上线')
+    expect(b.shell.snapshot.draft).toBe('/plan 上线')
     expect(b.sink).not.toHaveBeenCalled()
   })
 })

+ 32 - 0
packages/client/ui-conversation/tests/keydown-probe.client.spec.tsx

@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+/** Probe: does a synthetic keydown at the contenteditable reach the keymap commands? */
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent } from '@testing-library/react'
+import { createEditor } from 'lexical'
+import { registerPlainText } from '@lexical/plain-text'
+import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts'
+
+describe('keydown probe', () => {
+  it('routes Enter to the keymap submit handler', () => {
+    const editor = createEditor({ namespace: 'probe', onError: (e) => { throw e } })
+    const root = document.createElement('div')
+    root.contentEditable = 'true'
+    document.body.appendChild(root)
+    editor.setRootElement(root)
+    registerPlainText(editor)
+    const submit = vi.fn()
+    registerComposerKeymap(editor, {
+      arbitrate: () => 'pass',
+      space: () => false,
+      dismissPopup: () => {},
+      canSubmit: () => true,
+      submit,
+      intakeFiles: () => {},
+      pasteText: () => {},
+    })
+    fireEvent.keyDown(root, { key: 'Enter' })
+    expect(submit).toHaveBeenCalledWith(false)
+    fireEvent.keyDown(root, { key: 'Enter', metaKey: true })
+    expect(submit).toHaveBeenCalledWith(true)
+  })
+})

+ 1 - 1
packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx

@@ -150,7 +150,7 @@ describe('ReferenceChipNode', () => {
       if (chip === undefined) return
       const el = chip.createDOM()
       expect(el.getAttribute('data-composer-chip')).toBe('session-reference')
-      expect(el.contentEditable).toBe('false')
+      expect(el.getAttribute('contenteditable')).toBe('false')
       expect(chip.updateDOM()).toBe(false)
     })
   })

+ 23 - 16
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -30,6 +30,13 @@ import type {
 } from '../src/client/contract/slots.ts'
 import type { ViewTab } from '../src/client/contract/views.ts'
 
+// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
+// caret with one once the surface is genuinely contenteditable).
+Range.prototype.getBoundingClientRect = () => ({
+  top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
+}) as DOMRect
+
+
 /** Machine-backed wiring over a sink spy. */
 function fakeWiring() {
   const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
@@ -252,7 +259,7 @@ function mount(
   }
   const view = render(<ConversationRoot {...props} />)
   return {
-    view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
+    view, chat, sink, wiring, retargetWorkspace, session, slotCalls, seatOwners, open,
     pickerOwner: () => pickerOwner,
     rerender: () => { view.rerender(<ConversationRoot {...props} />) },
   }
@@ -281,11 +288,11 @@ describe('ConversationRoot resident composer', () => {
     const b = mount(conversationSnapshot(), undefined, undefined, {
       composerBlock: { reason: 'select a model first' },
     })
-    const box = b.view.getByRole('textbox') as HTMLTextAreaElement
-    // One disabled textarea with the blocker's placeholder, never a second
+    const box = b.view.getByRole('textbox')
+    // One disabled surface with the blocker's placeholder, never a second
     // tree: the DOM survives the block being raised and cleared.
-    expect(box.disabled).toBe(true)
-    expect(box.placeholder).toBe('select a model first')
+    expect(box.getAttribute('aria-disabled')).toBe('true')
+    expect(box.getAttribute('data-placeholder')).toBe('select a model first')
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(b.sink).not.toHaveBeenCalled()
 
@@ -304,11 +311,11 @@ describe('ConversationRoot resident composer', () => {
       summaryBlank: true,
       composerBlock: { reason: 'select a model first' },
     })
-    const box = b.view.getByRole('textbox') as HTMLTextAreaElement
-    expect(box.disabled).toBe(false)
-    expect(box.readOnly).toBe(true)
+    const box = b.view.getByRole('textbox')
+    expect(box.getAttribute('aria-disabled')).not.toBe('true')
+    expect(box.getAttribute('contenteditable')).not.toBe('true')
     expect(box.getAttribute('aria-haspopup')).toBe('menu')
-    expect(box.placeholder).not.toBe('select a model first')
+    expect(box.getAttribute('data-placeholder')).not.toBe('select a model first')
     const modelSeat = b.seatOwners.filter(call => call.key === 'conversation.input.model').at(-1)?.owner
     expect(modelSeat).toEqual({ locked: true })
   })
@@ -316,8 +323,8 @@ describe('ConversationRoot resident composer', () => {
   it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
     const b = mount(conversationSnapshot())
     const box = b.view.getByRole('textbox')
-    expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
-    fireEvent.change(box, { target: { value: 'ordinary revised' } })
+    expect(b.wiring.snapshot.draft).toBe('ordinary draft')
+    act(() => { b.wiring.setDraft('ordinary revised') })
     expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal))
@@ -338,7 +345,7 @@ describe('ConversationRoot resident composer', () => {
     const host = b.view.container.querySelector('[data-conversation-scroll]')
     const seat = b.view.container.querySelector('[data-composer-seat]')
     const header = b.view.container.querySelector('header')
-    const textarea = b.view.container.querySelector('textarea')
+    const textarea = b.view.container.querySelector<HTMLDivElement>('[data-composer-input]')
     expect(host).not.toBeNull()
     expect(seat).not.toBeNull()
     expect(header).not.toBeNull()
@@ -381,7 +388,7 @@ describe('ConversationRoot resident composer', () => {
     // for blank sessions): hero typing reaches the chat store.
     const box = b.view.getByRole('textbox')
     expect(host?.contains(box)).toBe(true)
-    fireEvent.change(box, { target: { value: 'draft in hero' } })
+    act(() => { b.wiring.setDraft('draft in hero') })
     expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
     // Picker: open through the chip; a pick switches to the other
     // workspace's blank session (draft carry is apply-layer wiring).
@@ -429,15 +436,15 @@ describe('ConversationRoot resident composer', () => {
   it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
     const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
     const before = b.view.getByRole('textbox')
-    fireEvent.change(before, { target: { value: 'kept across flip' } })
+    act(() => { b.wiring.setDraft('kept across flip') })
     // First message landed: content exists, phase leaves blank. Composer
     // already sat in the resident scrollport during hero, so the textarea
     // node and InputHub draft both survive.
     b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
     b.rerender()
-    const after = b.view.getByRole('textbox') as HTMLTextAreaElement
+    const after = b.view.getByRole('textbox')
     expect(after).toBe(before)
-    expect(after.value).toBe('kept across flip')
+    expect(b.wiring.snapshot.draft).toBe('kept across flip')
     expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
     expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
     expect(b.view.queryByText('探索未至之境')).toBeNull()

+ 356 - 0
packages/client/ui-conversation/tests/submit-machine.client.spec.ts

@@ -0,0 +1,356 @@
+/**
+ * SubmitMachine behavior: enter routing, adjudication outcomes, the claimed
+ * lifecycle and its integrity watch, settlement (commit-draft and claim
+ * re-entry decisions), anti-backwash, and per-session isolation. Text-edit
+ * semantics live in the editor (lexical-editor-core spec) — the machine only
+ * observes drafts through event payloads.
+ */
+import { describe, expect, it } from 'vitest'
+import type { CommandClaim } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
+import { SubmitMachine } from '../src/client/input/machine.ts'
+import { scanTextRefs } from '../src/client/input/decorations.ts'
+
+function claimOf(name: string, hint?: string): CommandClaim {
+  return {
+    token: `/${name} `,
+    ...(hint !== undefined ? { hint } : {}),
+    submit: async () => ({ kind: 'success' }),
+  }
+}
+
+function effectAt<T extends InputEffect['type']>(
+  effects: readonly InputEffect[], index: number, type: T,
+): Extract<InputEffect, { type: T }> {
+  const e = effects[index]
+  expect(e?.type).toBe(type)
+  return e as Extract<InputEffect, { type: T }>
+}
+
+/** Drive plain → adjudicating and hand back the minted attempt. */
+function enterAdjudicating(m: SubmitMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
+  const fx = m.dispatch({ type: 'enter', mode, draft })
+  return effectAt(fx, 0, 'adjudicate').attempt
+}
+
+/** Drive plain → claimed → submitting and hand back attempt + claim. */
+function enterSubmitting(m: SubmitMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
+  const claim = claimOf(name)
+  m.dispatch({ type: 'claim', claim })
+  const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: claim.token + args })
+  return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
+}
+
+function staleAttempt(): SubmitAttempt {
+  return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
+}
+
+describe('submit-machine: plain × enter', () => {
+  it('empty and whitespace-only drafts produce nothing', () => {
+    const m = new SubmitMachine()
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '' })).toEqual([])
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '  \n ' })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('non-command text falls to the default sink with the draft and mode', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
+    const sink = effectAt(fx, 0, 'default-sink')
+    expect(sink.draft).toBe('hello')
+    expect(sink.mode).toBe('queue')
+    expect(sink.attempt.draftSnapshot).toBe('hello')
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it('retains an explicit steer mode on the default sink effect', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'steer', draft: 'go' })
+    expect(effectAt(fx, 0, 'default-sink').mode).toBe('steer')
+  })
+
+  it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal write tests' })
+    const adjudicate = effectAt(fx, 0, 'adjudicate')
+    expect(adjudicate.draft).toBe('/goal write tests')
+    expect(adjudicate.attempt.draftSnapshot).toBe('/goal write tests')
+    expect(adjudicate.attempt.signal.aborted).toBe(false)
+    expect(m.state.phase).toBe('adjudicating')
+  })
+
+  it('leading is judged after trim including newlines', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n /goal x' })
+    expect(effectAt(fx, 0, 'adjudicate').draft).toBe(' \n /goal x')
+  })
+
+  it('a non-whitespace prefix before "/" is not leading — default sink', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'see /goal' })
+    expect(effectAt(fx, 0, 'default-sink').draft).toBe('see /goal')
+  })
+})
+
+describe('submit-machine: adjudication outcomes', () => {
+  it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal write x\nand y')
+    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
+    const begin = effectAt(fx, 0, 'begin-submit')
+    expect(begin.args).toBe('write x\nand y')
+    expect(m.state.phase).toBe('submitting')
+    expect(m.state.claim?.token).toBe('/goal ')
+  })
+
+  it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal')
+    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
+    expect(effectAt(fx, 0, 'begin-submit').args).toBe('')
+
+    const m2 = new SubmitMachine()
+    const attempt2 = enterAdjudicating(m2, '  /goal args')
+    const fx2 = m2.dispatch({ type: 'adjudicated', attempt: attempt2, outcome: { claim: claimOf('goal') } })
+    expect(effectAt(fx2, 0, 'begin-submit').args).toBe('args')
+  })
+
+  it('undefined outcome falls back to the default sink with the snapshot', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
+    const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })
+    const sink = effectAt(fx, 0, 'default-sink')
+    expect(sink.draft).toBe('/unknown thing')
+    expect(sink.mode).toBe('steer')
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it("'handled' lands plain with zero effects (popup shell path)", () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/model')
+    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('adjudication failure notices and keeps plain — no silent downgrade', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal x')
+    const fx = m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' })
+    expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'warmup failed' })
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('enter is a no-op while adjudicating (pending lock)', () => {
+    const m = new SubmitMachine()
+    enterAdjudicating(m, '/goal x')
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
+    expect(m.state.phase).toBe('adjudicating')
+  })
+
+  it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
+    const m = new SubmitMachine()
+    enterAdjudicating(m, '/goal x')
+    expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: undefined })).toEqual([])
+    expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
+    expect(m.state.phase).toBe('adjudicating')
+  })
+
+  it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '/goal x')
+    m.dispatch({ type: 'release' })
+    expect(attempt.signal.aborted).toBe(true)
+    expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+})
+
+describe('submit-machine: claimed lifecycle', () => {
+  it('the claim event enters claimed and snapshots hint and images bits', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), images: true } })
+    expect(m.state.phase).toBe('claimed')
+    expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', images: true })
+  })
+
+  it('claimed overwrites in place — no stack', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    m.dispatch({ type: 'claim', claim: claimOf('plan') })
+    expect(m.state.claim?.token).toBe('/plan ')
+    expect(m.state.phase).toBe('claimed')
+  })
+
+  it('submitting rejects the claim event (lock)', () => {
+    const m = new SubmitMachine()
+    enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'claim', claim: claimOf('plan') })
+    expect(m.state.claim?.token).toBe('/goal ')
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it('breaking startsWith(token) auto-releases back to plain', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    m.dispatch({ type: 'draft-changed', draft: '/goal args fine' })
+    expect(m.state.phase).toBe('claimed')
+    m.dispatch({ type: 'draft-changed', draft: '/goa' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('explicit release returns to plain when nothing is in flight', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    m.dispatch({ type: 'release' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
+    const m = new SubmitMachine()
+    m.dispatch({ type: 'claim', claim: claimOf('goal') })
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal line one\nline two' })
+    expect(effectAt(fx, 0, 'begin-submit').args).toBe('line one\nline two')
+  })
+})
+
+describe('submit-machine: submitting transaction', () => {
+  it('enter and claim are locked while submitting; draft-changed is recorded without leaving submitting', () => {
+    const m = new SubmitMachine()
+    enterSubmitting(m, 'goal', 'x')
+    expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
+    m.dispatch({ type: 'draft-changed', draft: 'typed during flight' })
+    expect(m.state.phase).toBe('submitting')
+  })
+
+  it('commit emits commit-draft with the snapshot, releases the claim, and relays the outcome text', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    const fx = m.dispatch({
+      type: 'submit-settled', attempt, ok: true, draft: '/goal x',
+      outcome: { kind: 'success', text: 'goal saved' },
+    })
+    expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x')
+    expect(effectAt(fx, 1, 'notice')).toMatchObject({ level: 'info', text: 'goal saved' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('an error-kind outcome text relays as an error notice on success=false settles', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    const fx = m.dispatch({
+      type: 'submit-settled', attempt, ok: false, draft: 'deviated',
+      outcome: { kind: 'error', text: 'rejected' },
+    })
+    expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'rejected' })
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('rollback with an undeviated draft keeps the claim and re-enters claimed', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '/goal x', message: 'transport' })
+    expect(m.state.phase).toBe('claimed')
+    expect(m.state.claim?.token).toBe('/goal ')
+  })
+
+  it('rollback with a deviated draft only notices — the newer input wins', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: 'rewritten', message: 'transport' })
+    expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'transport' })
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
+    const m = new SubmitMachine()
+    const attempt = enterAdjudicating(m, '  /goal x')
+    m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
+    m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '  /goal x', message: 'nope' })
+    // The snapshot carries leading whitespace the token never had: plain, claim cleared.
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.claim).toBeUndefined()
+  })
+
+  it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
+    const m = new SubmitMachine()
+    const { attempt: first } = enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'submit-settled', attempt: first, ok: false, draft: '/goal x', message: 'try again' })
+    const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })
+    const second = effectAt(fx, 0, 'begin-submit').attempt
+    expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, draft: '/goal x' })).toEqual([])
+    expect(m.state.phase).toBe('submitting')
+    m.dispatch({ type: 'submit-settled', attempt: second, ok: true, draft: '/goal x' })
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('release mid-flight aborts the attempt and later settles are dropped', () => {
+    const m = new SubmitMachine()
+    const { attempt } = enterSubmitting(m, 'goal', 'x')
+    m.dispatch({ type: 'release' })
+    expect(attempt.signal.aborted).toBe(true)
+    expect(m.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '' })).toEqual([])
+    expect(m.state.phase).toBe('plain')
+  })
+
+  it('send-committed clears unconditionally (image-only sends have no draft to retain)', () => {
+    const m = new SubmitMachine()
+    const fx = m.dispatch({ type: 'send-committed' })
+    expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBeNull()
+    const busy = new SubmitMachine()
+    enterSubmitting(busy, 'goal', 'x')
+    expect(busy.dispatch({ type: 'send-committed' })).toEqual([])
+  })
+})
+
+describe('submit-machine: per-session isolation', () => {
+  it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
+    const a = new SubmitMachine()
+    const b = new SubmitMachine()
+    const { attempt } = enterSubmitting(a, 'goal', 'x')
+    const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
+    expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
+    a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' })
+    expect(a.state.phase).toBe('plain')
+    expect(b.state.phase).toBe('submitting')
+  })
+})
+
+describe('decorations: scanTextRefs', () => {
+  const lexicon: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
+    ['/', ['commit-helper', 'goal'] as readonly string[]],
+    ['@', ['research'] as readonly string[]],
+  ])
+
+  it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
+    const out = scanTextRefs('/goal then @research and /commit-helper', lexicon)
+    expect(out.map(r => [r.start, r.end, r.trigger])).toEqual([
+      [0, 5, '/'], [11, 20, '@'], [25, 39, '/'],
+    ])
+  })
+
+  it('a cold (empty) lexicon scans nothing lexicon-based', () => {
+    expect(scanTextRefs('/goal x', new Map())).toEqual([])
+  })
+
+  it('recognizes directory paths independently of the dynamic lexicon', () => {
+    const out = scanTextRefs('see @src/x/ now', new Map())
+    expect(out).toEqual([{ start: 4, end: 11, trigger: '@', appearance: 'folder' }])
+  })
+
+  it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
+    expect(scanTextRefs('/research @goal', lexicon)).toEqual([])
+  })
+
+  it('word boundary: a trigger glued to text never matches', () => {
+    expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([])
+  })
+
+  it('tokens never cross a newline; a token straight after one matches', () => {
+    const out = scanTextRefs('a\n/goal', lexicon)
+    expect(out).toEqual([{ start: 2, end: 7, trigger: '/' }])
+  })
+})