青雲的博客
Orca 的终端界面太生硬,我把它重做了一遍

Article

Orca 的终端界面太生硬,我把它重做了一遍

· 43 分钟阅读

9 月 20 日晚上 11 点 20 分,我在 Orca 的仓库里对 Claude Code 说:

tui界面现在做的太生硬了,我想优化下这里的交互和设计,你来分析下

Orca 是我用 Rust 写的 DeepSeek 编码 Agent,终端界面基于 ratatui。当时它的功能并不少,审批、问卷、子代理面板、会话恢复都有。这句话里没有一个具体问题,所以第一步是把它拆开。

六天后,改版随 v0.5.0 发到 npm,时间是 9 月 27 日凌晨 2 点 56 分。v0.4.32 到 v0.5.0 之间有 106 个提交,除了开头几个评测相关的改动,基本都属于这一轮;光 orca-tui 的源码就改了 70 个文件,新增 14256 行,删除 2138 行。

时间是这样分配的:两个设计方案在头两天做完,一共 21 个任务,每个都经过实现和审查;接下来三天多在追真实使用暴露的问题;最后一整天耗在发版上。后面这些问题大多出在样式之外:界面画对了,数据没到;或者交互的时机和我想的不一样。

六天的时间线:前两天做完方案一和方案二,9 月 23 日到 25 日处理真实使用暴露的问题,9 月 26 日到 27 日发版

“生硬”要先落到代码上

Claude Code 一开始没有先给方案。它加载了 brainstorming 流程,开始读代码、在 tmux 里跑 Orca。我打断了它:“只是想让你先设计下方案啊。”

这段探索里有两件小事。一是 tmux 里按 F1,Orca 收到的是字母 P,于是误发出去一条 P/config,/resume 列表里多了一个空会话。二是我看到工作区有文件在变,问它“你刚刚是不是改了我代码?”。它没改。改动来自同时开着的 Codex 会话,那边正在给欢迎页换一只 braille 点阵画的鲸鱼。后来实现阶段放进单独的 worktree,就是为了不和它互相踩。

F1 这件小事后来进了设计:帮助面板加了一个 ? 别名,状态栏写 ? help。

诊断写成了一份 spec,每一条都带 ui.rs 的行号和 tmux 里实测的画面。结论是三件事:

  • 没有统一的视觉语言。输入框是直角边框,其他弹窗全是圆角;选中标记有六种写法,>、›、▸、❯ 分散在不同的弹窗里;setup 页和快捷键面板写死了 Color::Cyan、Color::Yellow 这类 ANSI 颜色,不走主题;每个弹窗底部的按键提示各有各的格式。
  • transcript 没有节奏,这是生硬感最主要的来源。助手消息没有任何角色标记;thinking 截到 3 行,但截的是逻辑行,一个逻辑行就是一整段,实测一个 thinking 块占 8 行屏幕,比正文还长;resume 之后工具行显示的是 ✓ tool:call_00_JxiwWkyhCvbqycLuMpBq8394 (completed),工具名丢了;一条 6 行的沙箱警告、一行 Runtime settings updated 和正文用同样的权重铺在对话里。
  • 交互是九种弹窗的集合。审批、计划确认、会话恢复、配置、Full Access、快捷键、图片、会话选择、setup,各有各的按键处理和外观。审批还是居中盖住对话,而 ask_user_question 早已内联到输入框的位置,两种“需要用户决定”的交互长得完全不一样。Esc 在五种上下文里是五种意思。

spec 给了三个方案:只统一视觉层;在视觉层之上收敛交互模型;把渲染层重构成组件树。第三个被排除了,理由很具体:ui.rs 在 v0.4.32 已经有 12461 行,而 inline viewport 要求刷进终端 scrollback 的内容和实时区一致,重写的风险正好压在这条最脆弱的路径上。我选了前两个都做,先做视觉层。

交互稿定下了样子,定不下时机

接着我让它“用 HTML 绘制下给我看看交互什么样”。12 分钟后拿到一个单文件交互稿:按 100×34 的终端网格排版,颜色直接取 theme.rs 的 Dark 调色板,十个场景,其中七个带“改前 / 改后”切换,点进终端画面后能用键盘操作。对话区的“改前”是照着当时一个真实会话 resume 后的画面复刻的。

交互稿里的对话区。左边是改前:工具行只剩 call id,thinking 和工具输出都是裸文本,通知和正文同一个权重;右边是改后:角色 gutter、一行 thinking、│ 左栏、edit 行带 +1 −1 和 diff

我在交互稿上提了几条意见,都是看到画面才说得出来的:

  • 输入框两边的竖线没必要,要上下两条横线,像 Claude Code 那样;
  • 欢迎页改成左右两栏,鲸鱼在左,版本、模型、目录和提示在右,整块高度从 28 行降到 14 行;
  • 队列提示里的 “Enter queue” 不需要写;
  • 默认模型改成 deepseek-flash。

还有一条是:Ctrl+Enter 是跳过队列立即发送的快捷键。交互稿能把这个键画出来,画不出“立即”到底有多快:是插进正在跑的这一轮,还是等这一轮结束后第一个跑。这个问题后来绕了一圈。

交互稿自己也出过一处错位:欢迎页左边的鲸鱼和右栏对不齐。原因是浏览器用回退字体画 braille,字形不是 1 格宽。解决办法是在页面加载时量一下 braille 和数字的宽度比,把鲸鱼横向缩放回终端里的宽度。真实终端没有这个问题。

chrome.rs 和三个守卫

方案一的主体是一个新文件 chrome.rs,把以前散在各个渲染函数里的东西收进来:边框类型、选中标记 ›、4 列 gutter、面板外框 panel_block、提示行 hint_line、选项行 option_line、按内容自适应高度的弹窗矩形 dialog_rect。所有弹窗都改成调用它们。

transcript 按 gutter 重排:你的消息前面是 ›,Orca 的回复第一行是 ●;thinking 默认折成一行 ⋯ thinking · 首句;工具行是短名加目标,输出挂在 │ 左栏下,折叠后尾行写 └ +N lines;诊断改成卡片,标题里不再带错误码。底部分成三段:输入框上方的活动行显示当前在跑哪个工具,输入框只剩上下两条横线,状态栏左边是审批模式 ⇧Tab auto-edit,右边是模型、推理强度和上下文余量。

这些样式能保持多久,取决于有没有东西拦住下一次各写各的。方案一留下了三个守卫:

  • 一条源码扫描测试:ui.rs、chrome.rs、shortcuts.rs 的生产代码里不允许出现 Color:: 字面量,只放行 Color::Reset;
  • 四张整帧黄金快照:欢迎页、带工具调用的 transcript、审批、帮助面板,用 ORCA_UPDATE_GOLDEN=1 重新生成,行尾在 .gitattributes 里固定为 LF;
  • 鲸鱼字符画的行宽守卫,直接引用渲染时用的宽度常量。

执行用的是 superpowers 的 subagent-driven development。计划拆成 13 个任务,Task 0 就是把默认模型改成 flash。每个任务派一个新的实现子代理,做完由另一个子代理对照 spec 和代码质量审查,有问题进修复轮,修完再复审;全部任务结束后,再做一次整分支的终审。

方案一从 9 月 21 日上午 9 点多做到第二天凌晨 1 点,前后派了 35 次子代理,26 个提交快进合入 main,合并后 orca-tui 的 1416 个测试全部通过。

每个任务都审过了,问题出在任务之间

任务级审查抓到的问题都很实在。Task 3 里 vim 模式的标签在一串按键之后没有同步;Task 8 把弹窗统一到共享组件时去掉了 .wrap(),恢复提示的说明文字在 58 列的内宽里被静默截断;Task 12 提交的黄金快照没在 .gitattributes 里固定行尾,Windows CI 检出时会被换成 CRLF,每张快照都会比对失败,其中审批那张还冻结了一个真实程序里不可能出现的选项顺序。

终审报告开头的判断是这样写的:

The thirteen task reviews did their job. Within each surface the work is careful, well-tested and faithful to the spec. Every finding is a seam between two tasks or a whole-branch comparison against the mock.

唯一的 Critical 就是这样一道缝。Task 9 给 / 菜单加了一行底部提示,渲染函数算弹窗几何时把 wants_status 改成了 true;鼠标命中函数不在这个任务的改动范围里,还在传 false:

// ui.rs:5317, render_slash_menu
let Some(geometry) = popup_geometry(frame.area(), input_area, items.len(), selected, true)
// ui.rs:5249, slash_menu_hit_index
let geometry = popup_geometry(frame_area, input_area, len, selected, false)?;

这个标志让弹窗多出一行。弹窗贴底,画出来的弹窗就比命中函数以为的高一行:点第一条命令没反应,点其他命令选中的是它上面那一条,点提示行选中的是最后一条。在 Orca 里,再点一次已选中的行就是执行,而这份列表里有 /clear、/compact 和 /trust。

它能活过任务审查,是因为已有的测试只调用了命中函数,从不渲染,等于拿命中函数验证它自己。修复把 false 改成 true;测试改为先用 frame_string 渲染,在画出来的文本里找到每条命令所在的行,再断言命中函数把这一行映射回同一条命令。

另一个 Important 也在两个任务之间。Task 5 让每条消息的第一行取决于它前面那条消息,而渲染缓存按单条消息失效。中间有消息被移除时,后面那条的缓存还是旧的,结果是一条回复多出一个 ●,或者两个工具行之间多空一行。

方案二:审批搬进输入框

写方案二的计划前,先派了一个子代理去核对 spec 和合并后的代码。它找到四处对不上:活动行里其实已经有一个 mini-dock;/tasks 只是 /agents 的别名;系统通知没有 expanded 字段;审批面板的提示行一直写着 Esc deny,按键表里却根本没有绑定 Esc。

八个任务分别是:审批内联到输入框的位置,和问卷共用布局与按键;补上 Esc 拒绝;/tasks 改成对话下方的 dock,不再替换整个对话区;把屏幕上的行反查回画出它的那条消息,让折叠行可以单独点开,Shift+E 展开全部;长的系统通知也折叠;一张 Esc 优先级表,输入框有内容时 Esc 清空草稿;还有 Ctrl+Enter。

交互稿里的内联审批:审批面板占住输入框的位置,对话仍然可见,↑↓、数字、Enter 和 Esc 都能操作

Ctrl+Enter 这一条,Claude Code 在开工前专门停下来问了我。它说队列归 runtime 管,出队只在没有活动 turn 时发生,真正的“turn 中途插入”要改 ThreadActor;当天能落地的是另一半:把追问加进队列再挪到队首,当前 turn 一结束它第一个跑。它还特意写了一句:“等当前 turn 结束”和“下一个工具间隙就插进去”,对用户是两种不同的体验。

我回的是:“是立即把队首的消息发给 agent,turn 结束后立即处理。”

它按“插到队首”实现了。方案二做到 9 月 23 日凌晨,派了 22 次子代理,合入 main。

第一次真用的那张截图

合并之后我重新构建,自己用了一下,截图发了回去。第一张问的是对话里那几条 ℹ 通知是什么、需不需要显示。Runtime settings updated 说的是运行时设置变了,这类信息该放在状态栏,于是从对话里去掉了。第二张我写的是:“你说的收起来效果也没有啊,而且一个问题,为什么回复拆分了两个。”

方案二合并后第一次真用:沙箱警告没有折叠,换行后的续行顶格,一条回复出现了两个 ●

这张图里有三个问题:沙箱警告没有折叠;换行后的续行顶格,没有和正文对齐;一条回复出现了两个 ●。

Claude Code 对其中两个现象的第一次解释都不对。它先说这条通知有 7 行,重新构建就会折叠;实际上它是一个逻辑行,折叠规则数的是逻辑行,所以这条在屏幕上占 6 行的警告永远不会折叠。它又说回复拆成两段是 thinking 造成的;实际原因是流式回复会把写完的块冻结成 chunk,还在增长的最后一段在渲染时把“本轮第一段”写死成了 true,于是又画了一个 ●。

三个修复分别是:按渲染后的行数折叠通知(070c8a74);一段回复只画一个 ●(06da9f29);续行悬挂缩进到正文的第一列,复制、搜索和选词拿到的仍是原文(84c7ef3d)。

悬挂缩进影响每一条消息的渲染,但改完之后,四张黄金快照一张都没变,说明没有一张快照覆盖到换行。它的 bug 是 Linux CI 上的 PTY 测试抓到的:一个从首行开始的长单词按首行的宽度测量,折到更窄的续行时溢出,最后几格被切掉,PTY_PERMISSION_RESUMED 显示成了 PTY_PERMI 和 SION_RESUMED(2c5ed6fa)。

同一轮还有一个交互回归。为了让折叠行可以点开,整段工具输出都成了点击区,在输出里按下鼠标会切换展开状态,没法拖选复制报错信息。现在只有标题行和 └ 尾行响应点击(f89538cc)。

Ctrl+Enter:照计划实现了,但不是我要的

第二天早上,我在运行中试了 Ctrl+Enter:

Ctrl+Enter 立即发送没有效果,并不像Claude code那种交互一样

“插到队首”要等这一轮结束才处理,而队列里只有这一条时,队首和队尾是同一个位置,按 Ctrl+Enter 和按 Enter 没有任何区别。

而 runtime 里早就有 Claude Code 那样的机制,叫 steer:进行中的一轮可以收下一条输入,在下一次调用模型之前把它当作用户消息加进对话。TUI 从来没用过它。

同一个键的两种时机:第一版把消息插到队首,等下一轮;第二版走 steer,当前工具步骤结束后、下一次模型请求前就读到

第二版(43b5a47e)改走 steer。运行中按 Ctrl+Enter,纯文本直接插进当前这一轮,当前工具步骤一结束,模型就能看到,transcript 里立刻显示成你的消息;Enter 不变,仍然排到下一轮。steer 只能带文字,所以带图片或 @、$ 引用时,或者这一轮已经过了最后一次模型请求,就退回“插到队首”,消息不会丢。

改的时候还补了 runtime 里两个会丢消息的地方:模型已经不再调用工具、这一轮正要自然结束时才收到的 steer,原来会被丢掉,现在先让模型回应它,再结束这一轮;在最后一次模型请求之后才被接受的 steer,剩下的输入排到队首,成为下一轮。新增的 UserAction::SubmitNow 还要登记进 runtime surface 的契约清单,TUI 动作从 45 个变成 46 个,清单摘要和校验脚本的基线要在同一个提交里一起改。

回头看那次问答,两种时机的区别在问题里已经写明了。我的回答里既有“立即发给 agent”,也有“turn 结束后立即处理”,实现选了后一半。两种读法在文字上都说得通,按下去才分得出来。

这个键还有一个应用自己解决不了的前提:终端要支持 kitty keyboard protocol,否则 Ctrl+Enter 和 Enter 发出来是同一个序列,只能退化成排队。帮助面板里写明了这一点,但没有做能力探测。

子代理跑完了,界面还说它在跑

同一天上午我还截了一张图:三条 Agent completed 通知已经出来了,下面的 agents 面板里仍有两个子代理显示 running,主 agent 也没有任何动静。

三条 Agent completed 通知已经出现,agents 面板里仍有两个子代理显示 running,主 agent 没有继续

这是两件事。

第一件是没有东西叫醒主 agent。子代理的结果只在两个时机交给主 agent:主 agent 某一轮进行中、每次调用模型之前,或者下一轮开始的时候。截图里主 agent 先答完了,子代理后来才完成,没有任何东西会开新的一轮,结果就一直挂着,直到我发下一条消息。那三条 Agent completed 只是 TUI 上的显示,不会唤醒谁。

修法照着 Claude Code 的行为:主线程空闲、又有还没送达的子代理结果时,runtime 自己开一轮,把结果交给主 agent(f6371829)。条件写得很保守:只唤醒主线程,要有 TUI 正在看这个线程,队列没有暂停、也没有排队内容,没有审批或提问在等,离上次唤醒至少 2 秒。每次唤醒都是一次要付费的模型调用。

两天后的审查仍然在这些条件里找到三个洞(ab4c739d)。如果会话记录写入失败,结果永远标不成已送达,线程会每 2 秒左右开一轮付费调用,一直开下去。用户切到旁路对话或子代理的视图后,这个线程仍被算作“有人在看”,唤醒的那一轮没人看得见,它的审批还出现在另一个视图里。Esc 会暂停队列,哪怕队列是空的,而下一条消息只在当时有排队内容时才会恢复它,所以同一个会话里只要按过一次 Esc,就再也不会唤醒。

第二件是显示卡住了。我让它去 ~/.orca 里找当天早上的会话。两个显示 running 的子代理,在任务注册表里早就是 completed,结果也已经作为通知交给了主 agent,worker 进程都退出了,卡住的只有界面。

界面上子代理的状态只有一个来源:子进程把进度写进 relay 文件,父进程逐帧校验 digest 之后回放。这条路上断了三处:

  • 父进程要把事件解析出来、重新序列化才能算 digest,而 serde_json 没开 float_roundtrip,有的 f64 读回来差最后一位。第一个子代理第 18 帧的费用 0.0012501160000000001 读回来成了 0.001250116,digest 对不上,relay 被隔离,状态停在第 17 帧。开启这个 feature 后,这个会话里五个 relay 共 270 帧全部校验通过(19ca465e)。
  • 整个 turn 期间一帧都没回放。relay 轮询是一个 50ms 的定时器,但它在 actor 每次循环时都会重新计时,而 TUI 的 turn 期间还有一个 25ms 的 tick,每次都抢先触发。实测一轮里 tick 触发了 1023 次,relay 轮询 0 次。后台子代理的权限请求走的也是这条轮询,于是主 agent 在等子代理,子代理在等审批(bc3eafb8)。
  • 第二个子代理被主 agent 用 resume_from 续跑了,continuation 转给了新任务,它自己的 binding 随之失效,relay 再也不会被回放。续跑能抢在回放前面,正是因为 turn 期间一帧都没回放。

所以还需要一个兜底:任务注册表记录子代理结束 2 秒后,界面上如果还显示 running,就按注册表的终态和结果结算(e4e66f27)。修这件事时还发现,我那个从早上 9 点 42 分开着的 TUI 空闲时占着 88% 的 CPU。它每 50ms 就把每个已结束子代理的 relay 和注册记录重读一遍,还重新解析一次全局任务索引。那个文件有 5MB、5.4 万条记录,大部分是测试漏写进真实 ~/.orca 的。

复现时也踩到一个坑。Claude Code 把这个会话复制到临时目录,用 mock provider 恢复,一开始得出“重启后注册表找不到已完成任务”的结论。后来发现是复制时少了两个全局索引文件,缺了它们,回放会把完成的记录报告成“消失”。那条结论被更正了,“重启也修不好”这一点仍然成立。

拿 v0.4.32 做对照的真终端实测

9 月 24 日,另一个分支上已经做好的 session recap 也合了进来。我让 Claude Code 按新的设计体系重做它的摘要条和详情面板,顺便审一遍实现。审出来的问题里有一个很典型:取消 recap 的 CancelRecap 定义了,却没有任何地方发送。合并之后,它在 tmux 里用真实的按键、鼠标和焦点转义序列把新功能挨个跑了一遍,又找出四个问题。

其中一个是中英混排的换行:英文词后面紧跟一长串中文时,整串中文被当成一个词挪到下一行,实测里 Rust 单独占了一行,行尾的宽字符还会超出一列;现在中文字之间可以断行,逗号、句号不会出现在行首。另一个是 PTY 测试并行跑时会挂住:输入库 qwertty 用 dup 复制出来的终端描述符没有设 close-on-exec,后台 worker、MCP 服务这些子进程全继承了它,TUI 退出后终端还被它们占着。

修这四个问题之前,我想换成 computer-use 来测,但 cmux 的 Computer Use 没有辅助功能和屏幕录制权限,这只能我自己去设置里授权。我问能不能用 Ghostty 测,于是有了一套 Ghostty 实测工具:Orca 跑在真实的 Ghostty 窗口里,按键和鼠标通过 AppleScript 走 Ghostty 自己的编码;中间一层 PTY relay 记下所有原始输出;屏幕内容用 pyte 解析,再和 Ghostty 自己导出的屏幕网格逐行比对。它只需要“允许控制 Ghostty”这一项权限。中英混排的修复就是在 Ghostty 里复查的。

到 9 月 24 日中午,我问还有没有没做完的、达没达到发布要求。回答是代码上看不出阻塞项,只差 CI 结果和一次发布准备提交。

我没有直接发,而是要求再 review 一遍相对主线的全部变更,“最好是都能通过 computer-use 实测一下”。范围是 v0.4.32 到当时的 main:77 个提交,161 个文件,新增约 3.05 万行,删除约 2200 行。

审查分两路:后台跑一遍代码审查,同时在 Ghostty 里逐项实测,mock provider 和真实 DeepSeek 都跑。为了区分“这一轮引入的”和“本来就有的”,还单独构建了一个 v0.4.32 的二进制做对照。第一份报告说,会话选择器在标题含土耳其字母 İ 时过滤会 panic,是这一轮引入的;在 v0.4.32 上一跑,同样崩,于是改判为老问题。

排在发布前必修第一位的,来自前一天的一个显示修复。那张被我叫作“一大坨”的截图里,bash 工具行显示的是结果信封的原始 JSON:

bash 工具行显示的是结果信封的原始 JSON,而不是命令的输出

修复(d4e781f8)让工具行直接显示命令自己的输出。可原来包在 JSON 里被转义的控制字符,现在原样到了终端。审查时用 printf 输出 ESC]2;…BEL 和 ESC[7;31m,终端真的执行了:一条命令的输出,或者 read 显示的文件内容,可以改窗口标题、改掉之后所有文字的颜色、移动光标,在允许的终端里还能通过 OSC 52 写剪贴板。v0.4.32 没有这个问题,因为 JSON 把这些字符都转义了。

修复(852dcd96)让工具输出、工具目标、agents 面板里的记录和审批面板统一经过 printable_output:丢掉 CSI、OSC、DCS 这类转义序列;回车和退格按终端的方式覆盖当前行,进度条只显示最后的状态;Tab 换成空格;其他控制字符丢掉。审批面板尤其需要这一步,否则一条命令可以在屏幕上擦掉自己的一部分,用户批准的比看到的多。

其他在发布前修掉的,也大多是实测才看得出来的:

  • 后台轮次的审批够不着。v0.4.32 会切到 Tasks 面板,按 Enter 就能批准;改成 dock 之后,提示写着 ↑↓ select · Enter open,按 ↑ 却把上一条提示词拉进了输入框,这时再按 Enter,它会被当成新的一轮再发一次。
  • 方案二加的“Esc 清空草稿”,清掉就永久没了,Ctrl+Z 和 ↑ 都找不回来。刚好在 turn 结束那一刻按 Esc 想中断,写了一半的追问就丢了。现在清空后按 ↑ 能找回。
  • 会话选择器的 ↑↓ 按时间顺序走,不按屏幕上分组后的顺序走;项目在 $HOME 下时,“当前项目”分组永远认不出来,因为拿显示用的 ~/… 路径去比绝对路径。
  • Plan 模式整条流程走不通。实时投影把计划通道直接丢了(AssistantChannel::Plan => {}),提议的计划不显示,计划审批框永远不会弹出。这个问题从 7 月 24 日就在,方案一给这个审批框做的样式调整,在真实会话里根本碰不到。
  • ~/.orca/auth.json 用默认权限写入,同一台机器上的其他用户可读,改成了 0600。

按我说的“按这个顺序修吧”,Claude Code 修了 10 项;接着又把一份“修的过程中新发现、没修的”清单里的 7 项修完。其中一项是后台轮次复用工具调用 id 会卡死。它起初被判断为只影响测试环境,因为 mock provider 每轮都复用 mock-tool-1,而真实 DeepSeek 的 id 不会重复。后来确认是真 bug:provider 只保证同一个响应里的 id 唯一,前台早在 9 月 17 日就为此(issue #67)给重复的 id 改名,后台补全的那条路径漏掉了(ac062788)。

构建全绿,npm 发不出去

9 月 25 日傍晚,我定了版本:“版本用 0.5.0,沙箱放下个版本”。沙箱指的是 macOS 26 上的问题:Seatbelt 的可用性探测会直接 abort,Orca 于是判定沙箱不可用,在 full-auto 以外的模式里拿掉 shell 工具。前面那张截图里的 Shell unavailable 通知就是它。这个问题 v0.4.32 就有,我决定不让它挡住这一版。发版前我还要求把相关文档和官网一起更新,于是有了新的 Terminal UI 文档,官网首页的终端动画也换成了新界面。

9 月 26 日凌晨 CI 通过,打了 v0.5.0 的 tag。发布工作流里六个平台的构建全部成功,然后停在 npm 登录检查上,npm whoami 返回 E401,GitHub Release、npm 发布和官网部署都被跳过了。

原因是仓库里的 NPM_TOKEN 是 6 月 21 日设置的,npm 带写权限的 token 最长 90 天,推算 9 月 19 日上午就过期了。v0.4.32 是赶在过期前大约 10 个小时发出去的,所以“前几天还能发”。

Claude Code 在后台盯着这个 secret 等我换新 token,一等就是 10 个小时。我看到时回了一句:“仓库是被 NPM 授权的,不应该需要 token 了呀。”我在 npmjs.com 上已经配好了 Trusted Publishing。于是发布改走 OIDC(25228480),路上又依次碰到:

  • Node 22.14.0 自带的 npm 是 10.9.2,不支持 OIDC,要先装 npm 11.20.0;
  • provenance 要求 package.json 里的仓库地址和实际仓库一致,平台包里的地址还是旧的;
  • npm 换 token 成功时返回 HTTP 201,新写的发布前检查只认 200,把成功当成失败拦了下来(94a2346a),又多等了一轮大约 75 分钟的 CI。

发布流程文档写着不许重建 tag。这次的例外在于,v0.5.0 下还没有任何产物公开:GitHub 上没有 Release,npm 上也没有 0.5.0。我同意之后,tag 被移到了新的提交上。9 月 27 日凌晨 2 点 53 分 GitHub Release 出现,2 点 56 分 npm 上有了 0.5.0。这是 Orca 第一次走 OIDC 发布,每个包都带 provenance。

Linux 上的发布后校验仍然是红的。校验脚本拿 JSON 字符串逐字比较 optionalDependencies,而 npm registry 开始按键的长度重新排序返回。发出去的内容没有问题,但这次运行用的是 tag 所在提交里的旧脚本,npm 已经发布,tag 不能再动,只能留到下一个版本修(434c3038)。它还让工作流跳过了自动部署官网,官网是手动触发部署的。之后用修好的脚本在本地跑了一遍完整校验,覆盖 Release 资产、校验和、npm 包完整性,以及 npm 包和 Release 里的二进制是否一致,输出是 Published release verified: v0.5.0 94a2346a。

截图里的 edit 行没有路径

发版后我想给这一版发条推,需要两张截图。本机的 screencapture 被拦住了,截图走了另一条路:用 Ghostty 的 write_screen_file 导出带颜色的 VT 内容,转成 HTML,braille 画成 SVG 点阵,再用 headless Chrome 按 2 倍分辨率截图。

第一个演示场景就撞上两个 bug。一个在脱敏:回复里写了 “auth/token.py: token_valid”,脱敏逻辑把它当成了键值对,因为“键”里含有 token,于是把 token_valid 换成了 <redacted>;TUI 的 Markdown 渲染又把 <redacted> 当成 HTML 标签丢掉,回复里静悄悄少了一个词。另一个是工具目标缺失。后来换了一个分页计算的演示场景,发出去的是这张:

v0.5.0 发版时的对话截图:edit 行只有“✎ edit”,没有路径,也没有 diff;bash 行没有命令

和交互稿对比,差别在 edit 那一行。交互稿画的是 ✎ edit src/main.rs +1 −1,下面挂着 diff;v0.5.0 的真实会话里只有 ✎ edit,没有路径,没有 +1 −1,也没有 diff。bash 那一行同样没有命令。

两件事的原因都在 TUI 之外:

  • DeepSeek 流式返回的工具调用只有名字和参数,不带 target,runtime 把这个空值原样抄进了界面收到的请求。真实会话里每个工具行都只有 ✓ read、✎ edit,审批面板不说批准的是哪个文件,“Allow this exact call” 这个选项也从来没出现过。更要紧的是,权限规则按 target 匹配,像 write_file /etc/** deny 这样带路径的规则,从来没有匹配上一次真实的 DeepSeek 工具调用,一直是审批模式在替它做决定。mock provider 会给每个工具调用带上 target,所以测试从来没发现(05171ab0)。
  • edit 和 write 工具生成了 unified diff,但 runtime 提交结果时把它丢了,写的是 file_change: None。方案一做的 +N/−M 摘要和审批面板里的 diff 预览,在真实会话里从来没有出现过(09c38a99)。

把这一轮里同一类问题放在一起,形状是一样的:

画好的行拿不到数据:工具目标、edit 的 diff、resume 后的工具名、Plan 通道、子代理状态,都断在 runtime 的提交或投影里

TUI 上的每一行都是某份数据的投影。单元测试直接构造消息喂给渲染函数,数据是完整的;mock provider 给出的数据也比 DeepSeek 完整。它们能证明“数据到了会画成什么样”,证明不了“真实会话里数据会不会到”。

这几项里,diff 和 resume 后的工具名,在发版前的实测审查里已经列出来了,只是没有排进发布前的修复;工具目标是发版后做截图时才发现的。三项都在 v0.5.1 修好,它离 v0.5.0 不到 14 个小时。

还靠人工兜着的部分

v0.5.1 同时修了 macOS 26 的沙箱。Seatbelt 真正跑起来之后,又带出一个从 v0.4.6 起就存在的问题:macOS 上的工作区沙箱把读取也限制住了,git 读不到 ~/.gitconfig,装在家目录下的工具也找不到。v0.5.2 在第二天凌晨发出,又修了几项,其中一项是 bash 权限规则里的 * 现在能匹配 /,rm -rf * 这样的拒绝规则终于能拦住 rm -rf /tmp/build。

回头看,界面在真实会话里的问题,是靠我自己用、Ghostty 实测和发版时做截图看到的,这三样都不在 CI 里。能自动跑的部分,覆盖面和边界是这样的:

  • 视觉语言有 chrome.rs、颜色字面量扫描和四张黄金快照守着。但快照只有四个画面,悬挂缩进改了每一条消息的渲染,没有一张快照发生变化。
  • PTY 契约测试在 v0.5.0 有 12 个,跑的是 mock provider。mock 至今仍给每个工具调用带 target,真实 provider 不给的字段,这些测试替我发现不了。
  • 跑测试的 CI 只有 Linux 和 Windows,没有 macOS。macOS 26 的沙箱问题是在本机跑出来的。
  • Ghostty 实测需要图形会话和“允许控制 Ghostty”的授权,目前只在本机跑。
  • Ctrl+Enter 依赖 kitty keyboard protocol,终端不支持时退化成排队,应用里没有探测。

所以对 v0.5.0 来说,界面上新加的每一项,能算数的证据是在真终端、真实 provider 的会话里看到它拿到了数据,这一步现在还是手工的。能先往前挪的是两件具体的事:让 mock provider 像 DeepSeek 一样不带 target,再给黄金快照补上长行换行和折叠的画面。在那之前,这段路还得靠实测兜着。

Keep Reading

相关文章

评论