首页
/ Ghostty vtStream API 实战:用 ghostty-vt Zig 模块构建只读终端流解析器

Ghostty vtStream API 实战:用 ghostty-vt Zig 模块构建只读终端流解析器

2026-09-06 18:36:58作者:姚月梅Lane

Ghostty 仓库提供了 ghostty-vt 这个生产级终端模拟器的 Zig 模块,其中的 vtStream API 专为“只读”场景设计:把终端输出流逐段喂给解析器,实时维护终端状态,而忽略所有需要回应的查询序列。本文基于官方示例 example/zig-vt-stream/,带你完整跑通一个 VT 序列解析程序,并深入 src/terminal/ 源码,弄清 vtStream 背后 TerminalStreamplainString 之间的协作关系。

vtStream 解决什么问题

vtStream 面向的是只消费终端输出、不产生回显的应用。官方示例的 README 明确列出了典型场景:

  • 回放工具(Replay tooling):解析录制下来的终端输出并重建屏幕;
  • CI 日志查看器:把构建日志中的 ANSI 转义序列渲染成真实网格状态;
  • PaaS 构建输出处理:解析平台下发的终端流;
  • 以及其它“只读终端”应用。

这类应用的关键特征是:程序不需要像交互式终端那样回应 DSR(设备状态请求)、DA(设备属性查询)等需要回写的序列。因此 vtStream 的处理策略是——正常处理所有会更新终端状态的转义序列,但对需要回应的序列直接忽略。这一点在 Terminal 的实现注释中有直接印证(见 Terminal.zig#L366-L385):

Return a terminal.Stream that can process VT streams and update this terminal state. The streams will only process read-only data that modifies terminal state. Sequences that query or otherwise require output will be ignored.

与之相对,如果应用需要处理副作用(例如收到 DSR 后要把响应写回 PTY),则应使用 vtHandler 并自行设置 effects 回调,而不是 vtStream

示例工程结构

example/zig-vt-stream/ 是一个完全独立的 Zig 项目,遵循 example/AGENTS.md 中定义的示例工程规范:每个 example 目录自带 build.zigbuild.zig.zonREADME.mdsrc/,CI 通过 example/*/build.zig.zon 自动发现,无需修改 workflow 文件。

依赖声明:通过路径依赖引用仓库本身

build.zig.zon 声明了对仓库根目录的依赖和最低 Zig 版本:

.{
    .name = .zig_vt_stream,
    .version = "0.0.0",
    .fingerprint = 0x34c1f71303690b3f,
    .minimum_zig_version = "0.15.1",
    .dependencies = .{
        .ghostty = .{ .path = "../../" },  // 指向 Ghostty 仓库根目录
    },
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
    },
}

注意 .ghostty = .{ .path = "../../" }:示例工程直接以相对路径依赖整个 Ghostty 仓库,因此必须在仓库内运行,且要求本机安装的 Zig 版本不低于 0.15.1(即 README 中“Requires the Zig version stated in the build.zig.zon file”的具体所指)。

构建脚本:把仓库导出的 ghostty-vt 模块导入进来

build.zig 的核心是三件事:创建 run/test 两个步骤、通过 lazyDependency 引入 Ghostty 模块、注册名为 zig_vt_stream 的可执行文件(注意示例规范要求可执行文件名使用下划线而非连字符):

const exe_mod = b.createModule(.{
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});

if (b.lazyDependency("ghostty", .{})) |dep| {
    exe_mod.addImport(
        "ghostty-vt",
        dep.module("ghostty-vt"),   // 仓库对外导出的 Zig 模块名
    );
}

ghostty-vt 模块的公共 API 定义在 src/lib_vt.zig,它重新导出了 TerminalStreamTerminalStreamScreenCell 等类型,并附带一个重要的官方声明:功能极其稳定(直接提取自 Ghostty 本体的终端实现),但 API 本身(函数、类型等)不保证稳定,可能无警告地变化。同时该模块还提供 TinyIo——一个为减小二进制体积优化的 std.Io 实现,供没有自己 Io 实现的嵌入方使用;Terminal.init 要求传入 std.Io,正是用于 Kitty Graphics 文件传输等触及文件系统的能力。

运行示例

example/zig-vt-stream/ 目录下执行:

zig build run

程序会依次处理以下 VT 序列,最后输出处理完毕的整个终端状态(纯文本):

  • 普通文本输出(含 \r\n);
  • ANSI 颜色码(SGR 属性);
  • 光标定位(CUP);
  • 光标移动(CUU/CUD 等);
  • 整行擦除(EL);
  • 多行连续输出。

示例源码逐段解读

example/zig-vt-stream/src/main.zig 全文仅 30 余行,却覆盖了 vtStream 的完整生命周期。逐段看:

1. 初始化 80×24 终端

const ghostty_vt = @import("ghostty-vt");

pub fn main(init: std.process.Init) !void {
    var t: ghostty_vt.Terminal = try .init(init.io, init.gpa, .{ .cols = 80, .rows = 24 });
    defer t.deinit(init.gpa);

Terminal 需要一个 std.Io(这里直接复用进程自带的 init.io)、一个分配器 init.gpa,以及初始尺寸 80 列 × 24 行

2. 创建只读流并逐段喂入数据

// Create a read-only VT stream for parsing terminal sequences
var stream = t.vtStream();
defer stream.deinit();

// Basic text with newline
stream.nextSlice("Hello, World!\r\n");

// ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset
stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n");

// Cursor positioning: ESC[1;1H = move to row 1, column 1
stream.nextSlice("\x1b[1;1HTop-left corner\r\n");

// Cursor movement: ESC[5B = move down 5 lines
stream.nextSlice("\x1b[5B");
stream.nextSlice("Moved down!\r\n");

// Erase line: ESC[2K = clear entire line
stream.nextSlice("\x1b[2K");
stream.nextSlice("New content\r\n");

// Multiple lines
stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n");

vtStream() 每次调用都会创建一个带有全新解析器状态的 Stream,然后 nextSlice 就是标准的“流式写入”入口——每次喂入一段字节,解析器立即消费其中完整的序列并更新 Terminal 状态。示例覆盖的序列语义:

序列 含义
\x1b[1;32m / \x1b[0m SGR:加粗绿色前景 / 属性重置
\x1b[1;1H CUP:光标移动到第 1 行第 1 列
\x1b[5B CUD:光标下移 5 行
\x1b[2K EL:擦除整行

3. 读取最终终端状态

// Get the final terminal state as a plain string
const str = try t.plainString(init.gpa);
defer init.gpa.free(str);
std.debug.print("{s}\n", .{str});

plainString 的定义在 Terminal.zig#L4891-L4897:它转储当前活动屏幕的视口区域,换行统一编码为 \n,且丢弃所有格式信息(前景/背景色等),返回的字符串由调用方负责释放。若需要保留行回绕语义,还有对应的 plainStringUnwrapped

源码级原理:vtStream、Stream 与 Terminal 的分工

vtStream 的实现非常简洁(Terminal.zig#L380-L390):

pub fn vtStream(self: *Terminal) Stream {
    return Stream.init(.{
        .allocator = self.gpa(),
        .handler = self.vtHandler(),
    });
}

/// This is the handler-side only for vtStream.
pub fn vtHandler(self: *Terminal) Stream.Handler {
    return .init(self);
}

可以看出 vtStream 本质是 Stream.init + 一个绑定到本终端的 Handler。真正干活的是 src/terminal/stream.zig 中的 Stream:它内部持有一个解析器,把输入字节流拆分成一个个 Stream.Action(该 Action 是一个大 union,覆盖 print_slicecursor_poserase_line_completeset_modedevice_statuskitty_keyboard_* 等几乎所有 VT 操作),再由 Handler 把 action 落到 Terminal 状态上。由于 vtStream 创建时不挂接输出类副作用,查询型序列(DA、DSR、尺寸报告等)只会被解析、被忽略,这正是 README 所说“ignoring sequences that require responses”的机制来源。

源码注释中有三条对使用者非常重要的约束,值得直接记住:

  1. 状态持久性vtStream 每次调用都创建全新解析器状态。如果转义序列可能被拆在两次写入边界上(例如网络分段、管道分块读取),必须保存并复用同一个 stream 实例,跨写操作持久化解析器状态;
  2. 生命周期:stream 必须由调用方 deinit(示例中用 defer stream.deinit() 处理);
  3. 调试钩子stream.zig 顶部有一个 debug 开关(stream.zig#L25-L31),打开后会输出更详细的调试日志并禁用 SIMD 优化路径,便于逐字节排查解析问题——需要提醒的是,开启后性能特征会变化,只用于排障。

与交互式场景的边界

需要划清边界的是:vtStream 只适合“无回应”的只读解析。仓库中 Ghostty 本体在格式化、渲染等路径里同样大量复用 vtStream(例如 formatter.zigc/render.zig 中的测试均以 t.vtStream() 构造输入流),可以推断该 API 在 Ghostty 内部也被当作“把字节灌入终端状态”的标准工具。如果你的应用需要双向交互(如实现一个真正的 PTY 代理),应改用 TerminalStream / vtHandler 路线,自行实现 effects 回调。

小结与延伸

  • 只读解析终端流(回放、CI 日志、构建输出)→ 用 vtStream + nextSlice 流式喂入,最后用 plainString 提取纯文本状态;
  • 跨写入边界必须复用同一个 stream 实例,且记得 deinit
  • 交互场景不要套用 vtStream,改用 vtHandler 并挂接副作用;
  • API 处于“功能稳定、接口不保证稳定”的状态,升级 Ghostty 时留意 src/lib_vt.zig 顶部的声明。

同一目录下的 example/zig-vt 展示了直接以 ghostty-vt 模块驱动终端的最小用法,可作为本例的对照阅读材料。

登录后查看全文
热门项目推荐
相关项目推荐