# Agent Note: Web 输入状态机、composer slot 与 slash 管线（ui-conversation input / ui-slash）

Status: implemented

[English](2026-07-25-web-input-machine-and-slash-pipeline.md) | 中文

> 范围：输入状态机（occurrence 表 + claim 看护 + 提交事务）、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单管线（ui-slash）、composer 周边 slot 体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md)的 sctx / provide / session-maybe 与 blank 实体模型；命令知识（三型、目录、popup）零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)的领地。

## 问题

两个各自为政的 composer：hero（EmptyState，受控链直写 Session）与会话内 InputBar（普通受控 textarea），行为、draft 所有权、发送路径全不一致。要让 `/` 命令、skill 引用、`@` 引用三类触发进入输入面，必须回答：

- 三类触发如何分层，谁对"命令"有知识、谁零知识；
- 输入框如何表达"命令态"——从 draft 文本推导还是显式状态？退格、回车、空格、整行粘贴各是什么语义；
- 提交是异步事务（RPC 往返）——晚到结果回灌、会话切换、React concurrent 重放如何防御；
- 引用 chip 在纯 textarea 上如何表示，undo/剪贴板/粘贴匹配/模型序列化各归谁；
- 跨插件的输入改写（菜单回填、引用插入、token 消费）如何做到依赖倒置；
- 无 session → blank session 时哪些 React 外壳必须复用，哪些严格 session 输入体允许替换。

硬约束：组件一律经 slots 挂载；呈现物不进 session log；键盘路径全程 IME 安全。

## 决策

### 输入状态机（`InputMachine`）

纯状态机，事件进/效果出，注入时钟。四相 phase（plain / adjudicating / claimed / submitting）。命令态**永不从 draft 推导**，由 pick 路径在离散时刻显式建立；claim 由 `draft.startsWith(token)` 看护、退格破坏自动 release；claim 形状 `{token, hint?}`（hint 供 ghost text）。

事件面（`dispatch(ev)` 单写入口，每个事件一个 transaction）：

- `draft-changed {draft, editRange?}`——textarea 全量草稿；editRange 缩小 occurrence 平移计算，缺省前后缀共扫。
- `newline {selection}`——Ctrl+Enter 换行（不经浏览器 execCommand：自管 undo 下浏览器写入会分叉双历史）。
- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}`——三个 bail 事件的机器侧；span CAS = draftRev 相等。
- `set-invalid {invalidIds}`——owner resolution 结果的样式位（非 transaction）。
- `undo` / `redo`——自管 transaction log（环形 100；单字符打字按注入时钟窗合并；提交成功清 log）。
- `paste-begin {text, selection, components?, generation?}`——粘贴 + 热快照同步匹配组件同 transaction（Undo 一次回粘贴前）；打开 PasteMatchAttempt。
- `paste-upgrade {attemptId, span, reference}`——异步匹配升级为独立 transaction（Undo 两段）；attempt 保持 current，insertedRange 随升级收缩。
- `invalidate-paste`——DOM 层观察到的 attempt 终结手势（caret/selection 操作等）。
- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release`——提交事务平面：SubmitAttempt（seq + AbortSignal）防回灌，成功 commit 清稿，失败带漂移守卫 rollback（回车时快照仅当 live draft 仍等于它才回填；用户已再输入则只发 notice）。

效果面（shell 执行）：`adjudicate`（调 SlashController.adjudicate）、`begin-submit`（claim.submit 事务）、`default-sink`（普通消息，hub 编排）、`notice`。

occurrence 表与 chip 三投影：

- 每颗引用在 draft 中占一个 `U+FFFC`；表项 `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`；同名 chip 因 occurrenceId 独立。
- 一切编辑同 transaction 更新 draft 与表：区间平移；与占位符相交的删除/替换作用于整颗。
- 单字符占位使键盘原子性大半原生成立（caret 无内部位；Backspace/方向键/Shift 扩选原生即整颗）；鼠标点 chip 由 backdrop 命中 → 整颗 setSelectionRange。
- 视觉投影 = label：backdrop 在占位符 offset 渲染 chip（textarea 字形不可见），invalid 走失效样式。
- 剪贴板/持久化投影 = clipboardText：copy/cut 把选区内占位符展开；draft 持久化 mirror 写同一投影（chat store 里永远是普通文本，刷新 seed 语义 = 全选复制→重开→粘贴，chip 跨刷新降级为文本）。
- 模型投影 = submit 时经 source `codec.serialize` 逐颗生成（归 submit attempt 的 signal 与 stale guard；owner 缺失/失败/取消则不发送，不降级为 `/name`）。

### 跨插件输入改写：三个 scoped bail 事件

契约声明在 ui-slash（依赖最底层），生产者经 `sctx.bail(sctx, ...)` 派发，唯一消费侧是 hub 建 shell 时挂在 sctx 上的三个 listener；返回 `true` ⟺ 机器过 phase + CAS 守卫并实际改写（发出事件 ≠ 修改成功，Space 是否 `preventDefault` 以返回值为准）：

- `slash/input-begin-command` `{claim, span}`——菜单 pick / Space 裁决出的命令 claim 回填（SlashController 派发）。
- `slash/input-insert-reference` `{reference, span}`——引用 chip 插入（SlashController 派发）。
- `slash/input-consume-token` `{guard: span | bare-token}`——业务成功后消费命令 token（下游命令面派发）。

不事件化的调用（registry 注册 → 显式调用 → await）：Input 自身的 draft/submit、Enter 异步裁决、reference serializer、异步 paste matcher。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁（scripts/jsdoc.ts）。

### slash 管线（ui-slash：root `SlashService` + per-session `SlashController`）

对"命令"零知识的触发/菜单/pick 管线：

- service 只有 source 注册表（`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`；(trigger,name) 唯一；可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序）与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明；管线按 roster 序轮询，首个非 undefined 应答胜出，无人认领落 default sink。matchSpace 同步（空格在击键中触发，只许热缓存）；matchEnter 异步（可 await 源自身预热，预热失败即 reject）。
- controller 持有唯一权威 hit（含 span；菜单关闭后为 Space 保留）、per-session menu store、候选 fetch generation、键盘仲裁（combobox 模式：焦点始终在 textarea，↑↓/Enter/Escape 拦截且全程过 IME composition 守卫，唯一例外 Shift+Enter 无条件先行），以及 pick 编排（outcome → 自派 bail 事件）。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径：它基于调用方的 textarea selection，只 seed 对应的已注册 source，并发布 `launcher = name` 直至关闭；普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView，并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`（指针落在菜单与所在 composer 卡片之外即关闭菜单；MenuView 还经 `slash.menu` locale 命名空间本地化组标题，并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间）；每个 session scope 出生时对 source roster 做一次 `warm(projection)`，projection 在该 scope 内只有稳定的 sessionId，无 published/能力跃迁；scope disposer 拆除 controller。
- 触发检测词边界（`user@host`、URL `/` 永不触发）、守卫分档（plain：`/` 到处 + `@` 行内 / claimed：`/` 抑制、`@` 活 / frozen：全无）为冻结纯核。

### hub / facade：常驻外壳与严格 session 输入体

- hub（trigger/decoration 注册表 + 发送编排）对 slash/command 服务是可选 `ctx.get()` 依赖：无 ui-slash/命令面时输入正常收发，优雅降级。
- 每个实体 Session 只有一个 `SessionInputShell`（facade），随 session scope 创建和拆除；无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳，持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat；Session 出现后，彼此独立的严格 session header 和 body outlet 只填入这些固定区域。
- composer bar 是一个无条件渲染的 `session-maybe` slot entry：无 session 时同一个 InputBar 以惰性态渲染（machine face 缺席、`disabled` owner prop），`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建；`ConversationRoot`、Hero 与布局骨架全程保持。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`：summary 已证实为空的 Session 在任何 open state 下都保持 Hero，未经证实的 Session 则在 loading 期间进入 settling。首次 submit 同步进入 engaging，失败也保留 composer 与错误上下文，不退回 blank Hero；sidebar 的 blank 位只在 prompt 成功受理后翻 false。
- 发送统一在 hub defaultSink：乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`（Web UI 无 steer 入口；host 线缆上的 `mode:'steer'` 不经此 machine）；失败且 live draft 仍为空才回填，用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时，外壳调用 `connectWorkspace`；目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell，再 open 新 id，旧 blank session 留存但不再 current。
- Notifier 双位契约：`dirty`（快照新鲜度，`ensureFresh` 拉取可清）与 `notifyPending`（通知欠账，只有 flush 清）各自独立——拉取不得吞推送，对象层推订阅者（watchTransaction）依赖这一保证。

### 纯文本引用（决策 21）：text outcome 与 lexicon 装饰

skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft，chip 视觉纯派生：

- PickOutcome 增 `{text}` arm；新 scoped bail 事件 `slash/input-insert-text` `{text, span}`（与另三个同契约：draftRev CAS、返回 true ⟺ 实际改写）；facade.insertText 走 setDraft 拼接，机器零改动。
- source 可选 `lexicon?(session)` 钩子：同步热快照名录，`undefined` = 数据未热——零装饰、永不触发 fetch（渲染路径保持同步无副作用）；配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化（目录 settle、子代生灭）时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store（每次 source 通知重拉）；scope 出生后才注册的 source 由 service 广播给活 controller，补 warm 并并入名录。
- `decorations.scanTextRefs`：词边界扫描 draft（行首/空白后的 `/name`、`@name`，`x/name` 永不命中）对照名录，命中即 `.textRef` mark（backdrop 纯 range 高亮，同 hlToken）；编辑破坏匹配形状下次扫描自然消失。
- 发送即原文（不再 `<skill>` 序列化）；气泡侧 MessageItem 双形状装饰（legacy `<skill>` 标签 + 纯文本 token）。
- 旧 occurrence/paste/serialize 链全部保留在盘未删（additive；删除另成将来一刀）。装饰响应性：InputBar 以 uSES 订阅 shell 的 lexicon source，scope 出生预热后才 settle 的名录会直接点亮已有 draft token，无需菜单交互或无关重渲染。

### per-session 供数贡献与键盘私面

- ui-conversation（hub 兼贡献者）经 `sessions.provide` 供 `'input'` hook（机器状态 + queue overlay）+ `inputActions` prop（`setDraft`/`submit`，稳定 void 回调）。
- 公私分界：公共 provide 只放 React 语汇成员；键盘/DOM 命令面（track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义）是 InputBar 独占，走 InputBar entry 自己的 inject 包内私递，不出插件边界。

### slot 体系

`conversation` 本身是 session-maybe；其会话内容与 composer 输入 slot 严格限定为 session，Hero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方，把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明：

- `conversation.session.header`（single）——常驻 scrollport 上方严格 session 的 breadcrumb、view tab 与 header action。
- `conversation.session`（single）——常驻 scrollport 内严格 session 的 view ring 与 draft mirror。header 和 body 共享同一个 session scope chat store；session id 切换时各自重建。
- `conversation.composer.bar`（single）——InputBar 本体的 slot：InputBar 是真 slot entry（自有 slot 自注册），composer chain fallback 的内容；不做 chain entry——chain 单选举会在 takeover 时卸载它，破坏 textarea DOM 存活。
- `conversation.input.overlay`——输入卡内浮层锚点；注册者 inject 按 slot sessionId 解析各自 per-session controller。
- `conversation.input.dock`——输入上方堆叠条（QueueDock 的队列只读列表落此），order 定序。
- `conversation.composer.dock`——composer 上沿统计带。
- `conversation.input.left` / `conversation.input.right`——工具行左右区。
- `conversation.input.plan` / `conversation.input.model`（single）——工具行两具名控制位；bar 只传 `locked`（owner props），空到 owning 插件注册为止，无占位 fallback。plan seat 未激活时保持为空，因为入口归共享 Command source 所有；有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮，其唯一动作是 `/plan off`。
- `conversation.hero.workspace`（root scope）——无 session / blank Hero 共用的 Workspace picker；pick 经 `connectWorkspace` 复用或创建目标 blank session，必要时搬运 draft 后切 current。

### 测试纪律

状态机全部行为由纯 JS 单测覆盖（事件序列进、断言状态与效果，零浏览器 DOM）；交互矩阵逐行投影测试。这一要求正是纯核 + 服务壳分层的成因。

## Alternatives considered

| 弃案 | 一行理由 |
|---|---|
| ActiveCommand 中间态 / registerMode 模式注册表 / 从 draft 推导命令态 | claim 由 pick 路径显式建立——无表、无推导 |
| bindTarget/bindDraft 对象直连 | 反向耦合 + root 单例跨会话误配；scoped bail 事件保依赖倒置且路由结构性正确 |
| 统一 slash/input-apply 或全事件化 | 三个独立 payload 覆盖跨插件改写；异步链路保持 registry 显式调用 |
| contenteditable / 富文本树 | 兼容性差；textarea + U+FFFC + occurrence 表覆盖全部交互契约 |
| draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念；chip 跨刷新降级可接受 |
| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠；粘贴两段 undo 语义只能自管 |
| InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员；标准件通道让组件自取，键盘面包内私递 |
| 空格裁决也认领即执行型命令 | 误触发防线：空格后整行是普通 prompt；不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名 slot 在注册前保持为空；占位件与真实现冲突时是双真相源 |
| 始终可见的 Plan 开／关切换 | 入口已归共享 Command source 所有；第二个入口会把状态 seat 变成冗余的 mode chrome |
| 第二套加号菜单组件／controller，或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态；加号控件只是既有 MenuView 按 source 过滤的 launcher，且此 scope 没有文件能力 |
| 引用一律走 U+FFFC chip（决策 21 前旧线） | 纯文本 + 派生装饰零身份状态；原文即模型投影，undo/剪贴板免特判；chip 链保留给需要不可分原子性的场景 |

## 后果

- 一个常驻 conversation 外壳承接 no-session/blank/active：无 session → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea；只有严格 session header 和 body outlet 开始承载内容。同一 blank session → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链（`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`）随最后消费者一并删除。
- 输入面对命令零知识 + 可选依赖：无命令包时纯输入可用；`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议，其应答语义（同步/异步、undefined 含义）为冻结契约。
- 提交事务化（attempt seq + 漂移守卫）使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能，由矩阵测试钉住。
- 已知欠账：chip 跨刷新保真（可复用粘贴匹配）未立项；subagent 引用的模型表示待业务立项。
