06 · PTY、Ghostty VT 与终端流水线¶
终端不是“读取子进程 stdout 然后显示字符串”。交互程序连接的是伪终端;输出字节会改变一个有状态的 VT,输入编码又取决于这个 VT 当前启用的 keyboard/mouse mode。Herdr 的 terminal runtime 正是这条双向协议的宿主。
6.1 从子进程到 cell grid¶
sequenceDiagram
participant Agent as Shell / Agent
participant OS as PTY master
participant Actor as PTY I/O actor
participant Pane as PaneTerminal
participant VT as Ghostty Terminal
participant CB as Callbacks
participant Server as App/HeadlessServer
Agent->>OS: bytes + escape sequences
OS->>Actor: read readiness
Actor->>Pane: output chunk
Pane->>VT: write(chunk)
VT->>VT: parse + mutate screen/history/modes
VT-->>CB: DSR reply / OSC / bell / clipboard / title
CB-->>Actor: ordered terminal response
CB-->>Server: metadata events
Pane-->>Server: content sequence + coalesced render signal
反向输入:
sequenceDiagram
participant Client as Client input
participant Server as Mode router
participant VT as PaneTerminal / VT modes
participant Actor as PTY input actor
participant Agent as Shell / Agent
Client->>Server: normalized key/mouse/paste
Server->>VT: terminal event
VT->>VT: encode Kitty / legacy / SGR / bracketed paste
VT->>Actor: bounded input command
Actor->>Agent: write PTY master
6.2 为什么要 PTY¶
直接 pipe stdin/stdout 会让许多 TUI/Agent 行为不同:
- 子进程通过
isatty判断交互能力; - shell job control 需要 controlling terminal 和 process group;
- window size 通过 PTY ioctl/ConPTY 传播;
- Ctrl-C 等按键可能由 line discipline 解释;
- alternate screen、cursor query 和 mouse tracking 都假设终端对端。
Herdr 因此在 Unix 使用 PTY master/slave,在 Windows 使用基于 vendored portable-pty 的 ConPTY 路径。平台入口位于 src/pty/。
6.3 Actor 化 I/O¶
PTY handle 有多个逻辑生产者:用户输入、终端自动响应、resize、pause/handoff、shutdown。若它们各自直接抢锁写 master,顺序和关闭语义会变得模糊。
Herdr 把 I/O 包进 actor/线程边界,典型消息分为:
| 通道/命令 | 例子 | 约束 |
|---|---|---|
| 用户数据 | key、paste、mouse encoded bytes | 有界,固定快路径 |
| terminal response | DSR/DA/OSC query reply | 必须保持解析产生的顺序 |
| control | resize、pause、resume、shutdown | 不能被大量输入饿死 |
| lifecycle | EOF、child exit、error | 转成上层事件一次处理 |
用户输入通道容量为 1024。它表达“输入是有限实时流”,而不是允许客户端无限领先于阻塞子进程。控制和终端响应分路,则避免 resize/shutdown 排在巨量 paste 后面。
6.4 Unix 与 Windows 的共同契约¶
平台实现不同,但上层依赖相同语义:
- spawn 带 rows/cols 的 child;
- 异步/线程化读取 output;
- 有序写 input 与 terminal responses;
- resize;
- 观察 child exit;
- shutdown;
- 在支持的平台暂停 reader 并交接底层 PTY。
Unix¶
Unix 路径能复制 master FD,这让 Live Handoff 成为可能。交接前必须先暂停旧 reader,否则旧新进程会竞争消费同一字节流。
Windows¶
ConPTY handle 和进程环境的所有权不同,不能直接套用 FD transfer。因此平台 capability 会明确 Live Handoff 不可用,更新策略回落到有损重启/用户指引,而不是伪装支持。
6.5 PaneTerminal 是协议适配器¶
src/pane/terminal.rs 把上层 pane/runtime 语义和 Ghostty VT 接起来。它负责的维度包括:
- 把 PTY output 喂入 parser;
- 读取当前可见/历史/检测文本;
- 键盘、鼠标、paste 编码;
- alternate screen、cursor、scrollback;
- dirty rows 与 render patch;
- title、cwd、progress、bell、clipboard 回调;
- Kitty graphics 等扩展;
- 终端查询响应回写 PTY。
文件很大并不意外:terminal emulator 本身就是双向状态机,而 Herdr 又在其上叠加检测和多客户端呈现。不过这也是架构演进中最值得继续切分的热点之一。
6.6 Ghostty VT 封装¶
src/ghostty/mod.rs 通过 FFI 包装 vendored libghostty-vt。封装层把底层 API 转成 Rust 可控的:
- terminal create/free;
write、resize、scrollback;- cell/style/cursor 读取;
- dirty row tracking;
- keyboard/mouse encoding;
- text 与 ANSI 序列化;
- OSC callback 与 terminal responses;
- Kitty image storage。
选择成熟 VT 内核的收益是避免自行实现数十年累积的 escape 兼容;代价是 FFI 生命周期、vendored patch、跨平台构建和 unsafe 边界必须被测试锁住。
6.7 一次 write 不只是画字符¶
给 Ghostty 写入一批字节,可能发生:
printable UTF-8 -> 修改 cell / cursor / dirty row
CSI movement -> 只移动 cursor
SGR -> 修改后续 cell style
DEC private mode -> 切换 alternate screen / mouse / keyboard mode
DSR / DA query -> 产生必须回写 child 的响应
OSC 0/2 -> 更新 title
OSC 7 -> 更新 cwd
OSC progress -> 更新 Agent 检测证据
OSC 52 -> clipboard request
BEL -> notification/bell
APC Kitty graphics -> 增删图像与 placement
因此“PTY output event”不能只携带新文本。正确结果可能是无可见字符但需要回包、更新 metadata 或触发 full graphics repaint。
6.8 终端自动响应要走专用顺序¶
应用可能查询 cursor position 或 device attributes,然后等待终端响应。如果响应混在普通用户输入后面,长 paste 会让应用超时;如果从 callback 直接重入写 PTY,又可能造成锁顺序和 parser 重入问题。
Herdr 的做法是 callback 只捕获 response,再交给 PTY actor 的 terminal-response 路径有序写入。这保持:
- parser 调用栈不重入 OS I/O;
- 多个查询响应顺序稳定;
- shutdown/handoff 可以统一暂停写入;
- user data 与协议响应可以有不同优先级。
6.9 四种读取视角¶
同一个 VT 至少有四种“读文本”的需求。
| 读取视角 | 服务对象 | 特点 |
|---|---|---|
| visible screen | UI/API 当前画面 | 受 viewport/alternate screen 影响 |
| recent history | output 等待、诊断 | 包含近期 scrollback |
| detection text | Agent 检测 | 从缓冲区底部取证,不跟用户滚动 |
| ANSI snapshot | handoff/history/terminal client | 尽量保留样式和控制状态 |
把它们合成一个 get_text() 会产生隐蔽 bug。例如用户向上滚动查看旧输出时,Agent 检测若读取“当前视口”,就会把几分钟前的权限提示当作仍在阻塞。Herdr 专门使用底部近期内容作为 detection source。
6.10 Alternate screen 与 scrollback¶
许多 Agent/TUI 在 alternate screen 中运行。VT 需要同时处理:
- main screen 与 history;
- alternate screen 通常不写普通 scrollback;
- 进入/退出时 cursor、mode、内容恢复;
- 用户 scroll offset 与底部 follow;
- resize 时 reflow 或裁剪语义。
上层不能只根据“屏幕文本为空”推断 Agent 没输出,因为光标移动和全屏 redraw 可能擦除旧 cell,却仍有 content sequence 和状态变化。
6.11 键盘编码取决于对端模式¶
同一个 Ctrl+Enter 或方向键可能编码为:
- legacy escape sequence;
- xterm
modifyOtherKeys; - Kitty keyboard protocol;
- 应用 cursor mode 的不同 CSI 前缀;
- Windows raw console 转换后的等价输入。
客户端负责把物理事件规范化,服务器端 PaneTerminal 根据当前 VT mode 编码给 child。这使远程 client 不需要复制 terminal parser 状态,也避免客户端按过时 mode 自行编码。
6.12 Mouse 与 paste¶
Mouse¶
鼠标事件先受 Herdr 自身 UI 命中测试影响:sidebar、border、overlay、context menu 可能消费它。只有路由到 pane 后,才按终端启用的 tracking mode 编码,常见为 SGR mouse。wheel 还可能在“发给应用”和“滚动本地 history”之间切换。
Bracketed paste¶
若 child 启用 bracketed paste,payload 需要包在 begin/end 控制序列中,让 shell/editor 区分粘贴和逐键输入。大 paste 仍受输入队列和消息大小约束,不能一次无界分配。
6.13 Dirty rows 与快照¶
Ghostty 能报告脏行,使 renderer 不必每帧扫描/复制整个历史。大致流程:
VT write
-> dirty row set + cursor/mode changes
-> renderer 请求当前 viewport patch
-> 转换为 ratatui/semantic cells
-> 成功纳入 frame 后清理对应 dirty 基线
但局部 patch 并非永远安全。出现以下情况时应 full snapshot:
- resize/reflow;
- overlay 覆盖与撤销;
- hyperlink/style state 可能跨 patch 边界;
- graphics placement 改变;
- client 刚连接或丢失 baseline;
- alternate screen 切换。
性能优化最重要的品质不是“总走增量”,而是能判断何时增量不再正确。
6.14 图形与内存上限¶
Kitty graphics 和 clipboard image 都能携带大 payload。固定快照中可见的防线包括:
- Kitty image storage 总量限制约 64 MiB;
- APC/图形输入有独立大小限制;
- clipboard image 限制约 16 MiB;
- client graphics frame 上限约 32 MiB;
- 普通 wire frame 使用更小上限。
分层上限比一个全局上限更合理:控制消息应很小,图像可以较大,但任何来源都不能无限占用 server。
6.15 生命周期与 EOF¶
PTY read 返回 EOF 可能表示 child 退出、slave 全部关闭或平台错误。上层需要:
- 只发布一次 lifecycle event;
- 更新 terminal state/session generation;
- 让 Agent hook 的迟到报告失效;
- 保留最后画面供用户查看;
- 决定 pane 是标记 exited、可重启,还是按策略关闭;
- 有序 shutdown actor,避免 double join/close。
“进程退出”不应立刻丢掉 TerminalState 和画面,否则用户无法查看失败原因。
6.16 源码锚点¶
| 主题 | 入口 |
|---|---|
| runtime 包装 | src/terminal/runtime.rs |
| 传统 pane runtime | PaneRuntime |
| terminal/VT 适配 | src/pane/terminal.rs |
| Ghostty FFI | src/ghostty/mod.rs |
| Unix/Windows PTY | src/pty/ |
| 终端状态 | src/terminal/state.rs |
6.17 本章结论¶
Herdr 的 terminal pipeline 有四个值得迁移的原则:
- PTY I/O 用 actor 明确数据、响应、控制和生命周期顺序;
- VT parser 是双向协议端点,output 可能产生必须回写的 input;
- UI、检测、等待和持久化需要不同的 terminal 读模型;
- 脏行优化必须配套可靠的 full-fallback 条件和内存上限。
下一章把这条流水线两端补完整:输入如何先经过 mode/overlay,再由 VT 编码;服务器又如何先计算 view、后做纯 render,并为多个客户端生成正确增量帧。