跳转至

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 的共同契约

平台实现不同,但上层依赖相同语义:

  1. spawn 带 rows/cols 的 child;
  2. 异步/线程化读取 output;
  3. 有序写 input 与 terminal responses;
  4. resize;
  5. 观察 child exit;
  6. shutdown;
  7. 在支持的平台暂停 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 全部关闭或平台错误。上层需要:

  1. 只发布一次 lifecycle event;
  2. 更新 terminal state/session generation;
  3. 让 Agent hook 的迟到报告失效;
  4. 保留最后画面供用户查看;
  5. 决定 pane 是标记 exited、可重启,还是按策略关闭;
  6. 有序 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 有四个值得迁移的原则:

  1. PTY I/O 用 actor 明确数据、响应、控制和生命周期顺序;
  2. VT parser 是双向协议端点,output 可能产生必须回写的 input;
  3. UI、检测、等待和持久化需要不同的 terminal 读模型;
  4. 脏行优化必须配套可靠的 full-fallback 条件和内存上限。

下一章把这条流水线两端补完整:输入如何先经过 mode/overlay,再由 VT 编码;服务器又如何先计算 view、后做纯 render,并为多个客户端生成正确增量帧。