Update the six touched client package README pairs (slash menu ordering, localized group titles and dismiss, permission label twin, goal pause, plan hint localization, useAnchoredMaxHeight) and keep the owning agent notes current: SlashSource.order and the MenuView dismiss/localize/clamp face in the slash-pipeline note, the pause verb in the goal bar note.
16 KiB
Agent Note: Web 输入状态机、composer 坑位与 slash 管线(ui-conversation input / ui-slash)
Status: implemented
English | 中文
范围:输入状态机(occurrence 表 + claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、
/与@触发检测与菜单管线(ui-slash)、composer 周边坑位体系。依赖会话作用域 note的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是命令业务面 note的领地。
问题
两个各自为政的 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 事件);
dismiss()动词支撑 MenuView 注入的onDismiss(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经slash.menulocale 命名空间本地化组标题,并经 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 外框。 - 无 session 时外壳渲染纯展示的
DisabledInputBar;connectWorkspace返回 blank session 后,仅输入体换成严格 session 的 InputBar。这里允许 textarea 重建,ConversationRoot、Hero 与布局骨架保持;blank → engaging/active 仍是同一 session-bound InputBar,textarea 不因 phase 翻转而重建。 - ConversationRoot 的 Hero 判据是
sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走
session.prompt {mode:'queue'|'steer'};失败且 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 把各名录聚合进自己的lexiconsnapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录。 decorations.scanTextRefs:词边界扫描 draft(行首/空白后的/name、@name,x/name永不命中)对照名录,命中即.textRefmark(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)+inputActionsprop(setDraft/submit,稳定 void 回调)。 - 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。
坑位体系
conversation 本身是 session-maybe;其会话内容与 composer 输入坑位严格 session,Hero Workspace picker 保持 root。子坑均由 ui-conversation 的 conversation 注册声明:
conversation.session(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。conversation.composer.bar(single)——InputBar 本体的坑位:InputBar 是真 slot entry(自家坑自注册),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。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 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
| 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 |
后果
- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 只保证大框架 React identity,允许 disabled textarea 替换为严格 InputBar;同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(
sessions.updateIntent/updatePendingPrompt/workspaces.sendSession)随最后消费者一并删除。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;
@引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。
- 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。