会话选择器不是列表:Orca 如何守住切换边界

4 阅读4分钟

很多终端 Agent 的会话选择器,看起来只是一个列表:上下移动,按 Enter 打开,按 Tab 看几个操作。真正容易出错的地方藏在“打开”之后:当前会话能不能删?Fork 之后屏幕上的历史是谁的?重命名写进磁盘后,运行中的标题会不会又变回去?

我最近重读 Orca v0.3.8 的 TUI,会发现 session picker 更像一个小型状态机。列表只是入口,身份、投影和破坏性操作才是它真正要守的边界。

操作列表先看当前身份

available_session_actions 接收两个值:当前附着的 session id,以及用户选中的 session id。Resume、Fork、Rename 对两者都可用;只有选中的不是当前会话时,Archive 和 Delete 才会加入操作列表;最后再追加 Copy session ID。

这段判断很短,却比在 controller 里收到 Delete 后再拒绝更可靠。用户根本看不到针对当前会话的删除入口,键盘上下移动的索引也只在真实可用的列表里计算。current_session_actions_exclude_archive_and_delete 测试同时检查了两层:动作集合没有 Archive/Delete,连续按 Down 也不会越过最后一个合法动作。

这里有一条值得带走的 UI 原则:破坏性动作的禁用状态要在动作生成处表达,controller 仍然可以保底拒绝,但不能把错误选项先交给用户。

Fork 不是打开旧会话

Resume 和 Fork 都从保存的 session 出发,运行时语义却不同。Resume 让当前 thread 接回原身份;Fork 则创建新身份,并把源会话作为 parent。切换完成后,TUI 必须同时更换运行时 owner 和屏幕上的 projection。

Orca 的 ForkSavedSession 分支现在先调用 switch_saved_hosted_session,再轮换 attached event sender。随后发出 SessionProjectionReset,清掉当前会话的消息、计划和状态;announce_runtime_ready 重新报告新 thread 的身份;emit_typed_history_snapshot 再把 fork 后的历史投影回来。最后才发 SessionForked,让界面更新标题和 session id。

顺序不能随意交换。只发一个 SessionForked 事件,旧 transcript 可能还留在屏幕上;先绘制历史再换 owner,则旧 session 的后台事件可能继续写进新 projection。对应的 picker_fork_replaces_source_transcript 测试明确验证:新会话里能看到 source-only prompt,看不到 current-only prompt,而且 fork id 同时不同于 source id 和原当前会话 id。

标题有两份,写入不是结束

重命名也有类似的双层状态。保存文件里的标题是 durable metadata,TUI 和 runtime surface 里的标题是当前 projection。Orca v0.3.8 的修复把 rename 接到带 revision 的 SessionMetadataPatch::SetTitle:先确认读到的 revision 仍然匹配,再提交持久化变更,成功后发出 SessionRenamed

这解决的是一个很具体的回滚问题:如果只改磁盘,不更新 runtime projection,下一次 announce_runtime_ready 仍可能从旧 snapshot 读标题,把用户刚改的名字重新覆盖掉。hosted_tui_rename_updates_durable_title_and_projection_event 测试把 durable title、revision 和 projection event 放在一次流程里验证;写入失败时,另一个回归测试还要求运行时标题恢复到原值。

把“改名成功”写成一次字符串赋值,会漏掉 revision、失败恢复和下一次 ready event。它其实是一条小型提交协议。

读代码时可以先画三条线

我现在看会话选择器,会先把同一个操作沿三条线展开:

用户动作 -> 可用动作集合 -> controller dispatch
会话切换 -> 新 runtime owner -> projection reset/history snapshot
元数据写入 -> revision commit -> durable row + typed event

第一条线负责“用户能不能选”;第二条线负责“屏幕和会话是不是同一个”;第三条线负责“改动能不能在下一次恢复后继续成立”。这三条线都通过,picker 才不只是一个看起来能用的列表。

Orca 的这组修复给我的启发很具体:TUI 里最危险的 bug,通常不是某个按键没有响应,而是动作、身份和投影在不同时间点各自向前走了一步。先把当前 session id 作为动作生成的输入,再用 reset 和 typed snapshot 管住切换,最后用 revision 把标题写回同一个 owner,很多“偶尔显示错了”的问题才有机会变成可测试的不变量。

源码依据:crates/orca-tui/src/session_picker_actions.rs L32-L56、L401-L430;crates/orca-tui/src/app.rs L4102-L4151、L8339-L8381;crates/orca-tui/src/types.rs L448-L459、L2610-L2629;修复提交 c055cd955829b744b8