跳到主要内容

数据截至 (上游 commit 3667151744e3)

终端内核:PTY、内嵌 Ghostty VT、以及 state/runtime 的切分

30 秒导读: herdr 的服务端里,你看到的每一格终端(pane)不是一个"终端组件",而是四样东西拼起来的:一个被 actor 包住的 PTY 文件描述符、一份从 Ghostty 抠出来编进二进制的 VT 解析引擎、一对被刻意拆开的「纯数据 state」与「活资源 runtime」、以及一棵决定它画在屏幕哪块的 BSP 树。本章把这四层从上到下拆开讲。

本章不讲"怎么判断 agent 是在干活还是卡住了"——那是 03-agent-detection 的事。进程模型(为什么有个常驻服务端)见 01-server-client-runtime;帧怎么发给客户端见 05-render-pipeline


1. 一个 pane 背后到底有什么

先建立直觉,再下钻。

白话定义: pane 就是你在 herdr 界面上看到的一格终端。里面跑着 shell,或者跑着 Claude Code / Codex 这类 agent CLI。

一格 pane 背后有四层,各管一件事:

管什么核心类型主要文件
PTY 层和子进程之间那根字节管子:读、写、改窗口大小PtyIoActorHandlesrc/pty/actor/unix.rs
终端仿真层把字节流解释成"屏幕上第几行第几列是什么字符什么颜色"Terminal / RenderStatesrc/ghostty/mod.rs
状态/运行时层纯数据(可以离开 PTY 测试) vs 活资源(线程、fd、任务)TerminalState / TerminalRuntimesrc/terminal/
空间层这格画在屏幕哪个矩形里、属于哪个 tab / workspaceTileLayout / Tab / Workspacesrc/layout.rssrc/workspace.rs

一张全景图,从左往右是字节的流向:

子进程 (shell / agent CLI)
│ ▲
PTY │ │ 用户输入 + 终端应答
字节流 ▼ │
┌──────────────────────┐
│ ① PTY actor │ 一个 fd + 一个专属线程
│ poll(读/写/唤醒) │ src/pty/actor/unix.rs
└───────┬──────────────┘
│ on_read(&[u8]) 回调

┌──────────────────────┐ ┌────────────────────┐
│ ② 旁路嗅探器 │───▶│ cwd / 标题 / 键盘协议 │
│ OSC・kitty・xtgettcap │ │ (不进 VT 也要的事实) │
└───────┬──────────────┘ └────────────────────┘
│ 过滤后的字节

┌──────────────────────┐
│ ③ Ghostty VT 引擎 │ 内嵌静态库,解析出网格
│ Terminal / RenderState│ src/ghostty/mod.rs
└───────┬──────────────┘
│ 读屏(几种口径)

④ TerminalState(纯数据) + TileLayout(画在哪)

看懂这张图,本章剩下的部分就是逐个放大①②③④。


2. PTY 层:一个 fd、一个线程、一个信箱

2.1 它要解决的小问题

PTY(伪终端,pseudo terminal——内核提供的一对"假终端"设备,一端给子进程当 tty,另一端给你读写)本身很朴素:一个 fd,read 拿子进程的输出,write 送用户输入,ioctl(TIOCSWINSZ) 告诉它窗口多大。

难点在于并发。同一个 fd 上有五种互相打架的诉求:

  • 子进程随时可能吐字节(要一直读);
  • 用户随时敲键(要写);
  • 终端仿真器自己要回应答(比如收到 DA 查询要回一串,这也是写);
  • 布局变了要 resize;
  • 换二进制时要"冻住"这个 fd 交接出去(见 06-persistence-and-handoff)。

2.2 思路:把 fd 关进 actor

herdr 的做法是每个 pane 一个专属 OS 线程独占那个 fd,其他人只能通过 handle 发消息。这就是 actor 模型:资源单线程独占,外界只递消息。

线程创建时就带上 pane 编号,方便排查(src/pty/actor/unix.rs:401-404,线程名 herdr-pty-{pane_id})。

对外的把手是 PtyIoActorHandle(src/pty/actor/unix.rs:88),它是 Clone 的,方法面按"数据/控制/生命周期"分三类:

类别方法做什么
数据write_user_input / try_write_user_input用户键盘输入,走 tokio mpsc 队列(容量 1024)
数据write_terminal_responseVT 引擎产生的应答字节,走共享槽 + 顺序锁
控制resize存进共享槽,由 actor 线程真正下 ioctl
控制nudge_child_redraw_after_handoff故意抖一下尺寸,骗子进程重画
生命周期begin_handoff / duplicate_for_handoff / rollback_handoff / release_after_commit交接 fd 的四步
生命周期shutdown关闸,并拒绝后续用户输入

注意一个设计细节:resize 不走消息队列,而是写进一个共享槽(SharedPtyControls,src/pty/actor/unix.rs:59-64),字段是 Option<PtyResizeRequest>。原因很直白——队列会满、会积压,而"窗口现在多大"这件事只有最新值有意义,旧的 resize 请求丢掉反而是对的。写完槽再敲一下唤醒管道(wake_actor,src/pty/actor/unix.rs:344)。

2.3 主循环长什么样

PtyIoActorRunner::run(src/pty/actor/unix.rs:441)是一个手写的 poll 循环,不用 tokio 的异步 I/O,因为它要同时盯 PTY fd 和一根唤醒管道:

┌─▶ ① 收命令(control 优先于 data)──── Shutdown? ──▶ 退出
│ │
│ ▼
│ ② 应用共享槽:resize / nudge / 待发应答
│ │
│ ▼
│ ③ 有待写数据就先冲一次
│ │
│ ▼
│ ④ poll(PTY fd, 唤醒管道, 超时 1000ms)
│ │
│ ├─ 唤醒管道就绪 ──▶ 排干它,回到 ①(说明有人递了新工作)
│ ├─ PTY 可读 ──▶ read_once():读 8KB → 调 on_read 回调
│ └─ PTY 可写 ──▶ 继续冲待写队列
│ │
└────────┘

怎么读这张图: ①②③是"先把手头的活干完",④才是"睡下去等事件"。1000ms 的超时只是兜底——注释明确写了它是"漏掉唤醒时的 fallback",正常响应靠 PTY 就绪和唤醒管道驱动(src/pty/actor/unix.rs:15-18)。

fd 在 spawn 时就被设成 close-on-exec + 非阻塞(src/pty/actor/unix.rs:362-363,调 fd::set_cloexec / fd::set_nonblocking),所以 read_onceWouldBlockInterrupted 都当"没事发生"处理,只有 Ok(0) 和真错误才算 PTY 关闭(src/pty/actor/unix.rs:676-685)。

2.4 读到字节之后:on_read 回调

actor 不认识终端,它只是把读到的切片交给一个回调 on_read: Box<dyn FnMut(&[u8]) -> PtyReadResult + Send>(src/pty/actor/unix.rs:42)。回调返回的 PtyReadResult 只有一个字段:terminal_responses: Vec<Bytes>——即"解析这段字节的过程中,终端需要回给子进程的应答"(src/pty/actor/unix.rs:29-31)。

这个回调在 src/pane.rs:1932 那一段闭包里组装,它做了六件事:

  1. content_seq 自增(奇数=正在改,偶数=改完了,给读方做撕裂检测,见 §4.5);
  2. terminal.process_pty_bytes(...) 把字节喂给 VT 引擎;
  3. 把响铃、检测序号、渲染脏标记发出去;
  4. 把 OSC 上报的 cwd 发成事件;
  5. 把 OSC 52 剪贴板写入发成事件;
  6. 返回 PtyReadResult { terminal_responses }

顺序锁的存在意义: read_once 在调回调时会先拿 response_order 锁(src/pty/actor/unix.rs:687-690),write_terminal_response 也拿同一把锁(src/pty/actor/unix.rs:162-166)。这保证"解析 A 段字节产生的应答"一定排在"解析 B 段字节产生的应答"前面——终端应答一旦乱序,子进程那边的状态机就会错乱。

2.5 Windows 是另一套实现

src/pty/actor.rs#[cfg] 把两套实现分开:Unix 在子模块 unix(src/pty/actor.rs:1-5),Windows 是同文件内的 mod windows(src/pty/actor.rs:8)。两边导出同名类型,上层代码不用改。

维度Unix(actor/unix.rs)Windows(actor.rs 内联模块)
持有什么master_fd: OwnedFd(裸 fd)master: Box<dyn MasterPty + Send>
并发模型1 个线程 + poll()4 个线程:writer / input / reader / control
唤醒机制自建 wake pipe各线程各自阻塞在自己的 channel 上
resizeioctl(TIOCSWINSZ)(src/pty/fd.rs:220)master.resize(PtySize{..})
handoff支持(begin_handoff 等四个方法)不支持(整组方法不存在)

之所以 Unix 要绕开 portable-pty 自己拿 fd:spawn_with_portable_pty(src/pty/backend/unix.rs:12)开完 pty 后立刻 dup 一份 cloexec fd 给 actor,然后把 portable-pty 的 pair 整个 drop 掉(src/pty/backend/unix.rs:26-36)。仓库里有专门的测试盯着这件事:portable_pty_setup_leaves_one_parent_pty_fd(src/pty/backend/unix.rs:73)断言父进程里只剩一个 pty fd。多余的 fd 会让"子进程退出了但 PTY 不 EOF"这种幽灵问题出现。

2.6 为什么要给 portable-pty 打补丁

Cargo.toml:34 固定 portable-pty = "=0.9.0",然后 Cargo.toml:50-51[patch.crates-io] 整个替换成 vendor/portable-pty。两个补丁都跟 Windows 有关,理由记在 vendor/portable-pty.patches.md:

补丁上游行为herdr 为什么不能接受
0001 控制 ConPTY 加载按 DLL 搜索路径去探测裸 conpty.dll等于允许从 PATH 加载别人的 DLL。herdr 改成:随包附一份钉死版本的 Microsoft ConPTY,校验 DLL 与 host 的哈希、拒绝重解析点,再用绝对路径加载并把依赖搜索限制在该目录和 System32
0002 暴露 Windows 原始命令尾命令一律按 argv 表示,ArgvQuote 会转义内嵌引号herdr 需要 cmd.exe /d /c <用户原样命令>,转义会改变 cmd.exe 的解析结果

这份 patches.md 不是随手写的文档:每条都必须写清为什么、对应 issue、基线版本、改了哪些文件、怎么验证、什么条件下可以删掉just check 里跑的维护脚本会验证补丁文件都在索引里、且能对着 vendored 树反向 apply(scripts/test_vendor_portable_pty.py,由 check recipe 调起,justfile:46-47;just testjustfile:6 跑的是同一批脚本)。这是"vendor 了就得管住"的工程纪律。


3. 终端仿真:把 Ghostty 的 VT 引擎搬进二进制

3.1 为什么不自己写 VT 解析器

先摆事实:一个"能用"的 VT 解析器和一个"对"的 VT 解析器差着数量级。要处理的东西包括——CSI/OSC/DCS/APC 各自的状态机、alt screen 切换、scroll region、DEC 私有模式几十个、SGR 颜色与下划线样式、宽字符与 grapheme cluster(ZWJ emoji、国旗)、soft wrap 标记、kitty 键盘协议、kitty 图形协议、超链接 OSC 8……写错一个模式位,agent 的 TUI 就花屏。

herdr 的选择是把 Ghostty 终端的 VT 引擎(libghostty-vt)整个 vendor 进仓库,自己只写一层 Rust 安全封装。代价是引入一个 Zig 构建步骤,收益是拿到一个被真实终端产品打磨过的解析器。

3.2 构建链:zig build → 静态库 → 链接进来

build.rs 干的就是这件事,顺序很直:

vendor/libghostty-vt/ (Zig 源码,含 VERSION)

│ build.rs 调 `zig build -Demit-lib-vt ...`
│ 参数:-Doptimize / -Dsimd / -Dtarget / -Dversion-string

vendor/libghostty-vt/zig-out/lib/libghostty-vt.a

│ cargo:rustc-link-search / rustc-link-lib=static

herdr 可执行文件(静态链进去,无运行时依赖)

几个值得注意的点:

  • Rust target 要翻译成 Zig target。 zig_target()(build.rs:6-18)做映射表,遇到不认识的 target 直接 panic——宁可构建失败也不要悄悄产出一个没链上的二进制。
  • 链接方式三条分支(build.rs:88-95):macOS 传静态库的绝对路径给链接器;Windows MSVC 链 ghostty-vt-static;其余链 ghostty-vt
  • 版本字符串从文件读。 VERSION 文件内容(当前是 1.3.2-HEAD-+c5a21edfc)被读出来当 -Dversion-string 传进去(build.rs:58-61),不在 build.rs 里硬编码。
  • 重编触发点列全。 rerun-if-changed 覆盖了 vendor 目录下的 build.zigincludepkgsrcVERSION 和 vendor.json(build.rs:34-40)。

3.3 锁上游 commit,再往上打补丁

vendor/libghostty-vt.vendor.json 只有三个字段,核心是 source_commit: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3——这份 vendored 源码到底对应上游哪个提交,写死在文件里

在这个基线之上,herdr 有一个 active 补丁(vendor/libghostty-vt.patches.md,0001):把 DEC 私有模式 2027(grapheme clustering)设为新终端的默认值,并且让它在 RIS(ESC c)全量复位后仍然生效

为什么非改不可:herdr 是自己按 cell 渲染的(不是把字节转发给宿主终端),所以一个国旗 emoji、一个 ZWJ 家庭 emoji 必须被存进同一个 cell,否则渲染时会被拆成几个格子。而 libghostty-vt 当前只暴露了"改当前模式"的 C API,没暴露"设置默认模式",于是只能打补丁。移除条件也写清楚了:等上游给出配置默认模式的 C API,或者上游把它设为默认。

同样地,just check 会验证这个补丁在索引里且能反向 apply(scripts/test_vendor_libghostty_vt.py,同在 justfile:47 那一批)。

3.4 从 C ABI 到安全 Rust

src/ghostty/bindings.rsbindgen 0.72.1 的产物(文件首行 /* automatically generated by rust-bindgen 0.72.1 */),4240 行全是 C 类型和 extern "C" 声明。它是签入仓库的生成物——Cargo.toml 里没有 bindgen 依赖,build.rs 也不跑 bindgen。好处是普通构建不需要装 libclang。

src/ghostty/mod.rs 把它包成一层安全 API。核心类型只有几个:

类型是什么位置
Terminal一个终端实例:网格 + scrollback + 模式位src/ghostty/mod.rs:779
RenderStateTerminal 拍一帧"可渲染快照"src/ghostty/mod.rs:2403
Dirty这帧脏到什么程度:Clean / Partial / Fullsrc/ghostty/mod.rs:70
ActiveScreen当前是主屏还是 alt screensrc/ghostty/mod.rs:320
CursorVisualStyle光标形状:Bar / Block / Underline / BlockHollowsrc/ghostty/mod.rs:295
Error包一个 GhosttyResult 错误码src/ghostty/mod.rs:30
TerminalScrollbartotal / offset / len 三元组,滚动条几何src/ghostty/mod.rs:326

封装的几处手法值得学:

① 错误码统一转 Result。 一个 trait GhosttyResultExt::into_resultGHOSTTY_SUCCESS 之外的一切变成 Err(Error(code))(src/ghostty/mod.rs:59-66),之后每个 FFI 调用都是 unsafe { ffi::xxx(...).into_result()? },不用手写 if。

② 未知枚举值不 panic,取保守默认。 Dirty::from_raw 的兜底分支返回 Full(src/ghostty/mod.rs:94),CursorVisualStyle::from_raw 兜底返回 Bar(src/ghostty/mod.rs:314)。上游加了新枚举值也不会炸,只是退化。

③ 回调走 trampoline + 一个 Box 住的状态。 Terminal::new(src/ghostty/mod.rs:790)先创建实例,再把 callback_state(一个 Box<TerminalCallbackState>)的裸指针设成 userdata,然后逐个注册回调函数指针:size / bell / pwd_changed / clipboard_write / color_scheme。子进程触发的事件先落进这个 Box,Rust 侧再用 take_* 系列方法排空:

  • take_bell_count()(src/ghostty/mod.rs:998)
  • take_pwd_changes()(src/ghostty/mod.rs:1002)
  • take_clipboard_writes()(src/ghostty/mod.rs:1006)

这套"回调只入队、上层主动取"的模式,避免了在 C 回调里做任何复杂 Rust 逻辑。

④ 写入就一个方法。 Terminal::write(&mut self, bytes: &[u8])(src/ghostty/mod.rs:867)。整个 VT 解析的入口就这一行 FFI 调用,其余全是"读结果"的方法。

3.5 渲染快照:RenderState

RenderState 的用法是三步:new()update(&terminal) → 一堆 getter。update(src/ghostty/mod.rs:2417)让它对着当前 terminal 重新取一帧,之后 dirty()(:2426)、cursor_visual_style()(:2448)、cursor_viewport() 等都读的是这一帧。

这层间接不是多余的:它把"终端状态"和"这一帧要画什么"分开,渲染侧只依赖快照,不会在画的过程中被子进程的新输出改掉。具体怎么用于增量帧,见 05-render-pipeline


4. 数据 / 运行时的切分

4.1 铁律:纯数据不许碰活资源

herdr 的一条硬规矩是:能不带 PTY 测的东西,就绝不让它带 PTY。落到类型上就是每层都被劈成两半:

视口/身份(纯数据) 活资源(线程/fd/任务)
───────────────── ────────────────────
PaneState PaneRuntime
(src/pane/state.rs:6) (src/pane.rs:1033)
│ │
│ attached_terminal_id │ 被 TerminalRuntime 薄包一层
▼ ▼
TerminalState TerminalRuntime
(src/terminal/state.rs:120) (src/terminal/runtime.rs:17)
│ │
│ 存在 AppState.terminals │ 存在 TerminalRuntimeRegistry
▼ ▼
HashMap<TerminalId, TerminalState> HashMap<TerminalId, TerminalRuntime>
(src/app/state.rs:1375) (src/terminal/runtime_registry.rs:12)

PaneState 小得惊人——只有三个字段(src/pane/state.rs:6-13):挂哪个终端、用户看没看过(seen)、右键要不要透传。它的 doc 注释直接点明:"终端身份、cwd、标签、agent 元数据都住在 TerminalState"。

TerminalState(src/terminal/state.rs:120)才是那个大结构:id / cwd / detected_agent / hook_authority / agent_metadata / terminal_title / state / revision …… 它是纯数据,可以在没有任何 PTY 的情况下构造和断言。

PaneRuntime(src/pane.rs:1035)是对立面:持有 Arc<PaneTerminal>PaneRuntimeIo(actor handle)、child_pid、若干 Arc<Atomic*> 计数器、以及一个检测任务的 AbortHandle。它的 doc 注释写着"Dropping this shuts down all background tasks and closes the PTY"。

4.2 两套 id,别混

id分配方式稳定性用途
PaneId(src/layout.rs:11)全局原子计数器自增,PaneId::alloc()(src/layout.rs:18)进程内唯一;持久化时用 from_raw 复原布局树的叶子标识、UI 定位
TerminalId(src/terminal/id.rs:10)微秒时间戳 + 计数器拼成 term_{micros:x}{counter:x}(src/terminal/id.rs:14-24)跨进程、跨重启可用的字符串服务端终端的对外身份

TerminalId 的 doc 注释有一句关键约束:调用方不得从 pane id 或布局位置推导出它(src/terminal/id.rs:5-8)。这是为了让"终端"这个概念以后能脱离 pane 独立存在(比如一个终端被多个视图观察)。

4.3 TerminalRuntime:一层"迁移用"的门面

TerminalRuntime(src/terminal/runtime.rs:17)现在其实是个 newtype:pub struct TerminalRuntime(crate::pane::PaneRuntime);,几乎每个方法都是一行转发。它的 doc 注释很坦白:"PTY 实现仍然委托给遗留的 pane runtime,但生产代码现在依赖这个 terminal 层类型,而不是 pane 模块的实现细节。"

这是典型的门面先行、实现后搬:先把调用方全部改成依赖新类型,以后换掉里面的 PaneRuntime 就不用动上层。

它的方法面按用途分四组:

代表方法(src/terminal/runtime.rs)说明
读屏visible_text(:340)、visible_ansi(:344)、detection_text(:348)、recent_unwrapped_text(:373)四种口径,见 §5
几何resize(:257)、scroll_metrics(:282)、current_size(:546)尺寸与滚动
渲染render(:399)、collect_dirty_patch(:403)、cursor_state(:328)交给 05
输入send_bytes(:433)、send_paste(:445)、encode_terminal_key(:429)、encode_mouse_*(:492/:501/:510)编码后交给 PTY actor

其中 resize(src/pane.rs:2609)的实现值得单独看,它做了三件事:

  1. 夹紧下限:rows.max(2)cols.max(4),别让终端小到崩;
  2. 幂等:尺寸没变直接 return(current_size 是个 Cell);
  3. 先仿真、后内核:先让 VT 引擎 resize(可能产生要回给子进程的应答),再把新尺寸和这些应答一起交给 actor(src/pane.rs:2617-2628)。

顺序不能反——如果先 ioctl 通知子进程,子进程立刻按新尺寸重绘,而 VT 引擎还停在旧尺寸,就会错行。

4.4 注册表:活资源住在 AppState 外面

TerminalRuntimeRegistry(src/terminal/runtime_registry.rs:12)就是一个 HashMap<TerminalId, TerminalRuntime>,方法面朴素得像标准库(get / insert / remove / values / len)。

关键在它的 doc 注释和摆放位置:它刻意放在 AppState 外面,挂在 App 上(src/app/mod.rs:106),而 AppState.terminals(src/app/state.rs:1375)只放 TerminalState。这样"纯状态"里就一个 PTY、一个线程、一个 channel 都没有,可以整体在测试里构造。

注册表上还有几个 #[cfg(unix)] 的批量操作,都是为 handoff 服务的:set_handoff_readers_pausedassume_handoff_ownershipdrain_for_handoff(src/terminal/runtime_registry.rs:47/53/69)。

4.5 一个小而妙的技巧:content_seq 撕裂检测

读屏和 PTY 写入在不同线程,读到一半可能被写覆盖。herdr 用的是序号奇偶法(和 seqlock 同源的思路)。

on_read 回调里,处理前 +1、处理后 +1(src/pane.rs:1933:1939),所以:

  • 序号是偶数 = 没人在改;
  • 序号是奇数 = 正在改。

读方 screen_text_snapshot_with_seq(src/terminal/runtime.rs:491-510)于是这么写:

# 示意,非源码
for _ in range(3): # 最多重试三次
before = content_seq()
if before % 2 == 1: # 奇数:正在写,直接重来
continue
snapshot = take_snapshot() # 拍快照
if content_seq() == before: # 前后序号一致 = 没被打断
return snapshot
return None # 三次都撞上,放弃

重点看:它不加锁,靠"读前读后序号一致"判断快照没被撕裂;三次都失败就返回 None,让调用方走降级路径,而不是死等。


5. 屏幕读取的几种口径

5.1 为什么"读屏"不止一种

"给我这个 pane 的文字"听起来只有一个意思,实际上至少有三种互相矛盾的需求:

  • 给人看的:用户滚上去了,就该给用户正在看的那一屏;
  • 给检测用的:agent 状态判定必须看缓冲区最底部,因为用户滚动不能影响判定;
  • 给 CLI / API 用的:要最近 N 行,还要能选"保留软换行"还是"拼回逻辑行"。

于是有了这张表:

方法锚点内容典型用途
visible_text视口(用户滚到哪就是哪)纯文本展示、选区
visible_ansi视口带 ANSI 转义需要保留样式的导出
detection_text缓冲区底部,取 rows纯文本agent 状态检测
recent_text_snapshot(n)缓冲区底部,取 n 行纯文本,保留换行herdr agent read
recent_unwrapped_text_snapshot(n)缓冲区底部,取 n 行纯文本,软换行拼回一行需要完整长行时
recent_unwrapped_ansi_snapshot(n)缓冲区底部,取 n 行带 ANSI,软换行拼回调试样式与 alt screen

差别的根在实现上,一眼可辨:

  • ghostty_visible_text(src/pane/terminal.rs:2510)走的是 render_state.update(terminal) 再迭代行——渲染快照 = 视口;
  • ghostty_recent_read_range(src/pane/terminal.rs:2727)算的是 end = total_rows - 1start = end + 1 - lines——总行数的末尾 = 缓冲区底部,和视口无关。

detection_text 更简单:它就是"取终端当前行数那么多行的 recent"(src/pane/terminal.rs:2542-2550),拿不到行数就退化成常量 DEFAULT_DETECTION_ROWS = 24(src/pane/terminal.rs:41)。

这条边界有测试盯着:detection_text_stays_at_bottom_when_viewport_is_scrolled(src/pane/terminal.rs:5134)。它为什么重要、下游怎么依赖它,见 03-agent-detection §3。

5.2 软换行怎么拼回去

snapshot_text(src/terminal/history_read.rs:83)是共用的格式化入口,四个参数:行数组、要几行、是否 unwrap、是否 truncated。unwrap 的规则在 unwrapped_text(:147)里:一行的 soft_wrapped 标记为真,就不换行、直接接到下一行后面

ScreenTextRow 带着 soft_wrapped / wrap_continuation 两个标记,由 Terminal::screen_text_rows(src/ghostty/mod.rs:1110)从引擎里取出来。也就是说"哪里是真换行、哪里是屏幕宽度导致的折行"这个信息是 VT 引擎给的,不是靠猜列宽还原的。

行文本的组装还有两个细节(row_text,src/terminal/history_read.rs:177-194):宽字符的尾格(CellWide::SpacerTail)跳过不输出;kitty 图形的占位符 codepoint 输出成空格,不会把私有区字符漏给上层。

5.3 alt screen:一块没有 scrollback 的地

alt screen(备用屏) 是终端的第二块画布,全屏 TUI(vim、less、以及大部分 agent CLI)会切进去。它的定义特性是:没有 scrollback。你退出去主屏,alt screen 上的历史就没了。

这带来一个真问题:用户想读 agent 更早的输出,但那些行根本不在终端缓冲区里——它们只存在于那个 TUI 程序自己的内部状态里。

herdr 的解法是 src/server/alt_screen_read.rs:假装用户在滚鼠标,一帧一帧把内容"骗"出来,再拼起来。这是一个五阶段状态机(Phase,src/server/alt_screen_read.rs:18-24):

SettleInitial ──▶ ProbeBottom ──▶ Harvest ──┬──▶ Restore ──▶ 完成
等画面稳定 确认在底部 循环: │
发滚轮事件 │ 攒够行数 / 到顶 / 对不齐
拍快照 │
merge 进历史 ─┘
└──▶ RestoreProbe(探测滚轮到底管不管用,不管用就走降级)

怎么读这张图: 左到右是正常路径;RestoreProbe 是岔路——如果发了滚轮事件画面纹丝不动,说明这个 TUI 不吃滚轮,直接返回降级结果(只有当前一屏)。这是这五个阶段的语义流图;04-agent-control-plane §7.2 按枚举声明顺序列的是同一组阶段,阶段之间的先后关系以本图为准。

拼接的核心是 merge_scrolled_up(src/terminal/history_read.rs:44),它要回答的问题是:"新旧两帧之间,到底往上滚了几行、哪些行是新的?"

思路分三步:

  1. 找位移:best_upward_shift(:99)对每个可能的 shift 值算重叠区的匹配率,取最高的;匹配率低于 30%(MIN_ALIGNMENT_RATIO_PERCENT,:4)直接不认。
  2. 找边界:找到第一个"旧帧非空行 == 新帧对应行"的位置,那之前的就是新增内容(:60-66)。
  3. 前插:新增行 splice 到历史开头(:79)。

对不齐就返回 Unaligned,不硬拼——宁可少给,不给错的

有两个测试直接说明它的分寸感:fixed_header_is_not_repeated_or_counted_as_scrolled_history(:249)——固定不动的表头不会被当成滚出来的历史重复记账;viewport_similarity_tolerates_small_dynamic_regions(:223)——similar_text(:14)用 70% 阈值(SIMILAR_VIEWPORT_RATIO_PERCENT,:5)判定"两帧算不算同一屏",这样"worked for 2s → worked for 3s"这种计时器跳动不会被误判成滚动。

整个过程有硬时限兜底:单步 120ms、整体最多 15s、恢复最多 5s(src/server/alt_screen_read.rs:11-16)。

5.4 host scrollback 的边界

另一条边界是:PageUp/PageDown 该给子进程,还是该拿来滚 herdr 自己的 scrollback?

判据在 InputState::plain_page_keys_use_host_scrollback(src/pane/terminal.rs:139-147),三个条件同时成立才归 herdr:

  1. 不在 alt screen;
  2. 子进程没开鼠标上报;
  3. 没开 application cursor 模式,或者开了但同时开着 bracketed paste。

第三条的注释解释得很好:bracketed paste 用来区分 zsh 的行编辑器(开着)和 less -X(没开)——两者都会打开 application cursor,但前者不该吃走 PageUp,后者该。这种"用一个副作用信号去区分两种同样表现的程序"的判据,是终端集成里典型的经验积累。


6. OSC / 键盘 / 鼠标旁路:不进 VT 也要知道的事

6.1 为什么要旁路

VT 引擎解析完只告诉你"屏幕长什么样"。但 herdr 还需要一些引擎不暴露、或者需要原始字节才能拿到的事实:子进程当前 cwd 是什么、终端标题变了没、agent 报了什么进度、键盘协议协商到第几层……

于是 process_pty_bytes(src/pane/terminal.rs:1216)里,同一段字节被扫描多遍:先给各路旁路嗅探器看,再喂给引擎。

处理顺序(src/pane/terminal.rs:1237-1306):

原始 bytes

├─▶ default_color_tracker.observe 默认前景/背景色 OSC
├─▶ osc_debug_tracker.observe 调试用,按环境变量开
├─▶ agent_osc_state.observe OSC 0/2 标题、OSC 9 进度


过滤:maybe_filter_primary_screen_scrollback_clear
│ (只在主屏 + 前台是 droid 时,剥掉 CSI 3 J)

filtered_bytes

├─▶ kitty_keyboard.observe CSI u 协议栈
├─▶ default_color_event_tracker.observe
├─▶ xtgettcap_query_tracker.observe DCS +q 查询
├─▶ decscusr_tracker.observe 光标形状覆盖


write_pty_bytes_with_ordered_responses → Terminal::write


排空引擎侧回调:take_bell_count / take_clipboard_writes / take_pwd_changes

注意顺序有讲究:过滤发生在旁路嗅探之后、写入引擎之前——嗅探器看到的是真实原始流,引擎看到的是过滤后的流。

还有一处防串味的处理:写入前先把 take_pwd_changes / take_bell_count / take_clipboard_writes 排空一遍(src/pane/terminal.rs:1237-1241),注释说明原因——恢复历史时可能已经触发过这些回调,那些副作用不能被当成本次 live 输出交付出去

6.2 四个旁路各管什么

文件抓什么为什么不交给 VT 引擎
src/pane/osc.rscwd 上报、终端标题、agent 进度、默认色、scrollback clear需要原始 OSC body;且部分序列要在进引擎前被拦掉
src/pane/kitty_keyboard.rskitty 键盘协议的 flags 和 push/pop 栈引擎只告诉你当前 flags;handoff 需要重放整个栈
src/pane/xtgettcap.rsDCS +q terminfo 能力查询herdr 要代替引擎回答,且只答自己敢担保的那几条
src/pane/cursor.rsDECSCUSR 光标形状、光标位置抖动渲染层需要"稳定后的"光标位置,引擎给的是瞬时值

6.3 OSC:一个通用收集器 + 几个消费者

OscStreamCollector(src/pane/osc.rs:329)是个状态机,职责单一:从字节流里切出完整的 OSC body,交给回调。注释写得明确——"消费者只收到 body,让分帧状态机和具体 OSC 命令解耦"。

AgentOscStateTracker(src/pane/osc.rs:459)是它的主要消费者,只认三个命令:

  • OSC 0 / OSC 2 → 终端标题;空 payload(如 \x1b]0;\x07)表示清空;
  • OSC 9 → agent 进度(形如 4;3;)。

两个细节值得学:

① 不信任的输入要截断。 AGENT_OSC_MAX_CHARS = 256(src/pane/osc.rs:448),注释直说"标题文本是不受信任的模型输出,截断以限制内存和日志体积"。sanitize_agent_osc_string(:536)还会滤掉所有控制字符。

② 换 agent 时清历史但不清解析状态。 clear_retained(:519)只丢标题和进度,保留 in-flight 的解析状态,注释解释:一条跨越 agent 切换的序列应当正常解析完,并算在新 agent 头上。

cwd 的解析在 parse_reported_cwd(src/pane/osc.rs:317),两种形式都吃:file:// URI(会做 percent-decode,且拒绝非本机 host)和裸路径(会剥掉包裹的引号,兼容 "C:\my proj")。

6.4 那个 droid 补丁:为什么要拦 CSI 3 J

CSI 3 J 的标准含义是"清空 scrollback"。问题是有个 agent CLI(droid)会用它来重绘自己的主屏 TUI——在真终端里无所谓,在 herdr 里就等于每次重绘都把 pane 的历史抹掉

herdr 的处理很克制,maybe_filter_primary_screen_scrollback_clear(src/pane/osc.rs:742)有三重限定,任一不满足就原样放行:

  1. 必须不在 alt screen;
  2. 字节里确实含 CSI 3 JCSI ? 3 J(contains_scrollback_clear_sequence,:713);
  3. 前台进程确实是 droid(foreground_job_uses_droid_scrollback_compat,:698,按进程名和 cmdline 匹配)。

源码注释自己承认这是个 hack,并明确写了"把这个 hack 限定在 droid + 主屏,好让别处正常的清历史行为不受影响"。这是处理兼容性 hack 的正确姿势:范围收到最窄,并把理由写在代码旁边

而且这个探测是惰性的——只有当字节里真含清屏序列时,才去查前台进程(src/pane/terminal.rs:1270-1272.then(|| ...))。每字节都去查进程树,那是灾难。

6.5 xtgettcap:只答自己担保得了的

XtgettcapQueryTracker(src/pane/xtgettcap.rs:4)是个 12 状态的 DCS 解析器,它要在字节流里找出 DCS + q <hex> ST 这种 terminfo 能力查询。

有意思的是 xtgettcap_value(:176)——一张白名单表,注释写着"只镜像这个 pane 路径能够担保的那些 Ghostty terminfo 能力"。表里只有 8 条(TcRGBsetrgbfsetrgbbMsSuSmulxSetulc),命中就答,不命中返回 None(表示"不认识,不答")。

对比"照抄一份完整 terminfo 回去",这种做法更诚实:答了就要能兑现

6.6 kitty 键盘:为 handoff 而记的账

KittyKeyboardTracker(src/pane/kitty_keyboard.rs:2)维护三个字段:pending(跨 read 边界的半截序列)、stack(push/pop 栈)、flags(当前值)。

observe(:11)最值得注意的是跨切片续接:如果一段 CSI 序列在这次 read 里没读完(比如只读到 \x1b[>),就存进 pending,下次读到的字节拼上去再解析(:12-24:32:47)。PTY 的 8KB 读取边界完全可能切在序列中间,不处理就会漏掉状态。

replay_ansi(:130,#[cfg(unix)])是它存在的理由:把整个协议栈重新编码成一串 ANSI——栈底用 CSI = {flags} u,后续每层用 CSI > {flags} u。换二进制时,新进程拿这串字节喂给新终端,就能把键盘协议状态原样恢复。引擎只能告诉你"当前 flags 是多少",恢复不了栈的形状。

6.7 光标:形状与"稳定后的位置"

src/pane/cursor.rs 两个东西:

  • DecscusrTracker(:9)记录子进程有没有用 DECSCUSR 覆盖过光标形状;
  • CursorPositionSettleState(:89)做去抖:光标位置要稳定 20ms(CURSOR_POSITION_SETTLE,:5)才对外报告,最多憋 100ms(CURSOR_POSITION_MAX_HOLD,:6)。

去抖只在 Windows 上启用(CURSOR_POSITION_SETTLE_ENABLED = cfg!(windows),src/pane/terminal.rs:43)——ConPTY 会把一次逻辑重绘拆成多次写,中间态的光标位置乱跳。


7. 空间模型:BSP 树 + 三层容器

7.1 BSP:一棵只有"分割"和"叶子"的树

BSP(binary space partitioning,二叉空间分割) 在这里的意思很朴素:一块矩形要么整个给一个 pane,要么被一条线切成两半,每半继续递归。

Node(src/layout.rs:73)就两个变体:

Split(Horizontal, 0.5)
/ \
Pane(#1) Split(Vertical, 0.4)
/ \
Pane(#2) Pane(#3)

对应屏幕:
┌──────────┬──────────┐
│ │ #2 │
│ #1 ├──────────┤
│ │ #3 │
└──────────┴──────────┘

TileLayout(src/layout.rs:84)在树之外只多存两样:focus(当前焦点 pane)和 prev_focus(上一个焦点)。prev_focus 的注释点出一个坑:只有真实的焦点移动才写它,树编辑走的是"取目标"的原语(split_paneclose_pane、非聚焦的 insert_pane_near),这样内部的临时焦点跳转不会污染它。

布局计算是两个纯递归函数:

函数位置产出
TileLayout::panes(area)src/layout.rs:126collect_panes(:483)Vec<PaneInfo>,每个 pane 的矩形
TileLayout::splits(area)src/layout.rs:133collect_splits(:509)Vec<SplitBorder>,每条分割线,供鼠标拖拽调整

PaneInfo(src/layout.rs:34)里有个细节:inner_rectcollect_panes 阶段先等于 rect,注释说明"inner_rect 在 render 时才设置,因为那时才知道边框显不显示"(src/layout.rs:489)。布局只管切矩形,UI 装饰另算。

SplitBorder(src/layout.rs:50)带一个 path: Vec<bool>(false=第一个孩子,true=第二个),这就是从根到该分割节点的路径——鼠标拖到某条线上时,靠这个路径回到树里改 ratio

7.2 三层容器:workspace → tab → pane

Workspace(src/workspace.rs:178) ← 一个项目/一个 worktree
│ id / custom_name / identity_cwd / git 缓存
│ tabs: Vec<Tab>, active_tab: usize

Tab(src/workspace/tab.rs:38) ← 一棵 BSP 树
│ layout: TileLayout
│ panes: HashMap<PaneId, PaneState>

PaneState ← attached_terminal_id


TerminalId ──▶ TerminalState(纯数据) + TerminalRuntime(活资源)

四点值得留意:

① Workspace 用 Deref 把 active tab "抬"上来。 impl Deref for Workspace { type Target = Tab; }(src/workspace.rs:211-226),取的是 active_tab(),拿不到就 panic("workspace must always have at least one active tab")。这让 workspace.split_focused() 这类调用可以直接写,不用每次先取 tab。代价是这条不变量必须被严格维持。

② Tab 只存 PaneState,不存 runtime。 Tab.panes 的字段注释写着"Pane viewport state — always present, testable without PTYs"(src/workspace/tab.rs:44-45)。真正的 runtimes 字段带着 #[cfg(test)]——生产路径下 tab 里根本没有 runtime,它们在 §4.4 那个注册表里。

③ 新建 pane 是三件套一起交付。 NewPane(src/workspace/tab.rs:20)同时带 pane_idterminal: TerminalStateruntime: TerminalRuntime——调用方拿到后各自归位:id 进树、state 进 AppState.terminals、runtime 进注册表。

④ 公开编号和内部 id 是两回事。 Workspace.public_pane_numbers(src/workspace.rs:200-201)另存一份给用户看的编号,注释明确"关闭的 pane 编号不会被复用"。内部 PaneId 是自增计数器,不适合直接给人念。


8. 巧妙之处(可以直接借鉴的)

① resize 用"共享槽"而不是队列。 只有最新值有意义的控制量,不要塞进 FIFO——SharedPtyControls.resize: Option<...>(src/pty/actor/unix.rs:59-64)天然做到了"后来的覆盖先来的"。

② 序号奇偶做无锁撕裂检测。 写前 +1、写后 +1,读方比对前后值(src/terminal/runtime.rs:491-510)。三次失败就降级,不死等。

③ 用 dup 一份 fd 换掉整个第三方 pty 对象。 src/pty/backend/unix.rs:26-36 拿到 fd 就 drop 掉 portable-pty 的 pair,并且用测试断言父进程只剩一个 pty fd(:73)。fd 泄漏是最难查的一类 bug,靠测试钉死比靠 review 靠谱。

④ 兼容性 hack 要三重限定 + 惰性探测。 droid 的 CSI 3 J 过滤(src/pane/osc.rs:742)只在"主屏 + 确实含该序列 + 前台确实是 droid"时生效,且只有含序列时才去查进程。

⑤ 只回答自己担保得了的 terminfo 能力。 xtgettcap_value(src/pane/xtgettcap.rs:176)是白名单不是全表。

⑥ 未知枚举值退化而不 panic。 Dirty::from_raw 兜底 Full(src/ghostty/mod.rs:94)、CursorVisualStyle::from_raw 兜底 Bar(:314)——FFI 边界上,上游新增枚举值不该让你的进程死掉。

⑦ vendor 了就要管住。 两份 *.patches.md 强制每个补丁写明理由/issue/基线/验证命令/移除条件,just check 里的脚本验证补丁能反向 apply(justfile:46-47)。这把"vendor 后逐渐腐烂"变成了可检查的状态。

⑧ 跨读边界的解析状态要留 pending。 kitty 键盘追踪器把半截序列存进 pending 下次续上(src/pane/kitty_keyboard.rs:12-24)。任何逐块扫描字节流的解析器都得处理这件事。


9. 边界与局限

诚实地列几条代码里看得出来的限制:

  • Windows 不支持 handoff。 begin_handoff / duplicate_for_handoff / rollback_handoff / release_after_commit 整组方法只在 Unix 分支存在;Windows 的 PtyIoControlCommand 只有 ResizeShutdown(src/pty/actor.rs:46-49)。
  • 构建需要 Zig。 build.rs:63-84 直接调 zig build,失败就 assert! 炸掉。ZIG 环境变量可以指定路径,但没有"跳过 VT 构建"的开关。
  • TerminalRuntime 只是转发壳。 目前 TerminalRuntime 每个方法都转发给 PaneRuntime(src/terminal/runtime.rs:17),类型注释自己承认这是迁移中间态。想理解真实实现仍然得看 src/pane.rs
  • alt screen 历史提取是启发式的,会失败。 它靠"发滚轮事件 + 帧对齐"工作。TUI 不吃滚轮就走 RestoreProbe 降级;帧对不齐就返回 Unaligned 放弃;还有 15 秒总时限(src/server/alt_screen_read.rs:14)。返回的历史不保证完整
  • similar_text / best_upward_shift 的阈值是经验值。 70% 和 30%(src/terminal/history_read.rs:4-5)没有理论依据,是调出来的。
  • grapheme cluster 依赖一个本地补丁。 如果这个补丁在下次 vendor 更新时漏了重新应用,国旗和 ZWJ emoji 的渲染会静默退化(所以 patches.md 里列了三个验证测试)。
  • WorkspaceDeref 会 panic。 没有 active tab 时直接 expect(src/workspace.rs:213-215)。这是一条靠约定维持的不变量,不是类型保证的。

10. 代码地图(导航索引)

按主题查,每行给出文件 + 符号名——符号名比行号抗上游漂移,可以直接 grep。

主题文件符号
PTY 平台分派src/pty/actor.rsmod unix / mod windows
PTY actor 句柄src/pty/actor/unix.rsPtyIoActorHandlePtyIoActorConfigPtyReadResult
PTY actor 主循环src/pty/actor/unix.rsPtyIoActorRunner::runread_onceapply_pending_controls
PTY 生命周期状态src/pty/actor/unix.rsActorStatebegin_handoffrelease_after_commit
fd 原语src/pty/fd.rscreate_wake_pipepoll_pty_and_wakeresize_pty_fdduplicate_cloexec_fd
PTY 创建src/pty/backend/unix.rsspawn_with_portable_ptySpawnedPty
VT 静态库构建build.rszig_targetmain
vendored VT 版本锁vendor/libghostty-vt.vendor.jsonsource_commit
vendored 补丁账本vendor/libghostty-vt.patches.mdvendor/portable-pty.patches.md
C ABI 绑定(生成物)src/ghostty/bindings.rsghostty_terminal_*ghostty_render_state_*
VT 安全封装src/ghostty/mod.rsTerminalRenderStateDirtyActiveScreenCursorVisualStyleError
VT 读屏原语src/ghostty/mod.rsscreen_text_rowsread_text_screenread_ansi_viewportscrollbartotal_rows
VT 回调排空src/ghostty/mod.rstake_bell_counttake_pwd_changestake_clipboard_writes
字节处理主路径src/pane/terminal.rsPaneTerminal::process_pty_bytesGhosttyPaneCoreProcessBytesResult
读屏口径实现src/pane/terminal.rsghostty_visible_textghostty_detection_textghostty_recent_read_range
输入模式判据src/pane/terminal.rsInputState::plain_page_keys_use_host_scrollback
pane 运行时src/pane.rsPaneRuntimePaneRuntimeIoPaneRuntime::resize
pane 视口状态src/pane/state.rsPaneState
终端门面src/terminal/runtime.rsTerminalRuntimescreen_text_snapshot_with_seq
终端纯状态src/terminal/state.rsTerminalStateTerminalStateMutation
终端注册表src/terminal/runtime_registry.rsTerminalRuntimeRegistry
终端身份src/terminal/id.rsTerminalIdTerminalId::alloc
快照与历史合并src/terminal/history_read.rsScreenSnapshotsnapshot_textmerge_scrolled_upbest_upward_shift
alt screen 历史提取src/server/alt_screen_read.rsPendingAltScreenReadPhase
OSC 解析与过滤src/pane/osc.rsOscStreamCollectorAgentOscStateTrackerparse_reported_cwdmaybe_filter_primary_screen_scrollback_clear
kitty 键盘协议src/pane/kitty_keyboard.rsKittyKeyboardTrackerreplay_ansi
terminfo 查询代答src/pane/xtgettcap.rsXtgettcapQueryTrackerxtgettcap_value
光标形状与去抖src/pane/cursor.rsDecscusrTrackerCursorPositionSettleState
BSP 布局src/layout.rsNodeTileLayoutPaneId::allocPaneInfoSplitBordercollect_panes
workspace 容器src/workspace.rsWorkspaceDeref for Workspace
tab 容器src/workspace/tab.rsTabNewPaneTab::terminal_id
活资源挂载点src/app/mod.rssrc/app/state.rsApp::terminal_runtimesAppState::terminals

接着读: 这一章的 TerminalRuntime::detection_text03-agent-detection 的输入;render / collect_dirty_patch 通向 05-render-pipeline;begin_handoff 那组方法在 06-persistence-and-handoff 里展开。