跳到主要内容

数据截至 (上游 commit efde31963f6f)

第 1 章 · 服务端 VTE:屏幕状态为什么在服务端解析

本章讲 dinotty 最核心、也最不寻常的一个决策:终端模拟不只发生在客户端,服务端也完整跑了一份。我们看这份「服务端屏幕」长什么样、怎么被字节流驱动、以及它如何支撑起「断网续跑、换机接力」。


1.1 为什么屏幕状态要在服务端

它要解决的小问题: 普通 web 终端(如 ttyd、wetty)把 PTY 字节直接转给浏览器里的 xterm.js。字节流是「增量指令」——想知道「屏幕现在长什么样」,必须从会话开始把每个字节都重放一遍。

这带来三个硬伤:

  1. 重连成本高:断线后要么重放全部历史字节(可能几百 MB),要么丢画面。
  2. 多端无法对齐:两台设备各自从连上那一刻开始渲染,晚来的那台永远看不到之前的内容。
  3. 程序读不了屏:一个外部 agent 想问「终端现在显示什么」,裸字节流回答不了。

dinotty 的思路:在服务端也解析一遍字节流,把「屏幕现在长什么样」变成一份随时可以取用的数据结构。README 把这叫「服务端 VTE,PTY 断网存活」(README.md:107-109)。

一句话直觉: 裸字节流是「录像带」,服务端屏幕是「暂停键下的那一帧」。重连时客户端要的是那一帧,不是整盘录像。


1.2 VirtualScreen:服务端屏幕的数据结构

本节看这张「屏幕」由什么组成。

VirtualScreen 定义在 src/vt_screen/screen.rs:11-29,关键字段:

字段作用
primary / alternate主屏幕 / 备用屏幕两块缓冲(见 1.3)
using_alternate当前应用正在用哪一块
scrollback滚出屏幕顶部的历史行(VecDeque<Vec<Cell>>)
parservte crate 的字节解析器(src/vt_screen/screen.rs:16)
saved_cursorDECSC/DECRC 保存的光标
command_state / pending_command / command_resultsOSC 133 命令跟踪(见 1.6)
sync_eventsDEC mode 2026 同步输出事件(见 1.7)
private_modes鼠标、括号粘贴等模式位的当前值(重放用)

屏幕的最小单位是 Cell:一个字符 + 最多 3 个组合字符(combining char)+ 一套属性 CellAttrs(前景/背景色、粗体、斜体、下划线、反色、删除线),见 src/vt_screen/data.rs:98-124

缓冲本身是 ScreenBuffer:cells: Vec<Vec<Cell>> 加上光标、滚动区(scroll_top/scroll_bottom)、行列数,见 src/vt_screen/data.rs:161-169


1.3 双缓冲:primary 与 alternate

它要解决的小问题: 全屏 TUI 程序(vim、htop、Claude Code 的界面)退出后,终端应该恢复你之前的命令行画面,而不是留下 TUI 的残影。

思路: 终端协议早有答案——备用屏幕(alternate screen)。应用发 CSI ? 1049 h 进入一块全新的空屏,画它的界面;退出时发 CSI ? 1049 l,主屏幕原样回来。VirtualScreen 忠实实现了这个模型:

  • 解析到 DECSET 1049 时不立刻切屏,而是记一个 pending_switch(src/vt_screen/performer.rs:207)。
  • feed() 在每字节处理完后检查这个标记,真正执行切换:进入时保存主屏光标、新建一块空 alternate;退出时切回 primary 并恢复光标(src/vt_screen/screen.rs:197-233)。

为什么这对快照重要: 客户端重连时如果正处在 alternate 屏,服务端不能只发 alternate 的内容——否则用户将来退出 TUI 会看到一块空的主屏。所以重连快照会先画主屏、再进备用屏画备用屏(顺序由测试钉死,见 src/vt_screen/mod.rs:421-433replay_snapshot_paints_primary_before_entering_alternate)。

滚动历史只属于主屏——全屏应用刷屏不该污染你的命令历史,VirtualScreen::resize 里只有 primary 的 resize 会收到 scrollback 参数(src/vt_screen/screen.rs:241-242)。


1.4 feed:字节流如何变成屏幕状态

数据通路(同一份字节,两份消费):

PTY reader 线程 (src/pty.rs)
│ 读到 n 字节
├──────────────────────► screen.feed(data) → 服务端屏幕(本章)
└──────────────────────► output_tx → broadcast → 客户端 xterm.js(第 3 章)

feed(src/vt_screen/screen.rs:160-236)做三件事:

  1. 命令输出收集:如果正在跟踪一条命令(见 1.6),把可打印字节攒进 output_buf,上限 1MB,超了砍掉前面 512KB(src/vt_screen/screen.rs:165-177)。
  2. 逐字节解析:构造一个 ScreenPerformer,对每字节调 self.parser.advance(&mut performer, byte)(src/vt_screen/screen.rs:193-194)。vte crate 负责状态机,ScreenPerformer 负责把解析结果落到缓冲上。
  3. 切屏结算:每字节之后检查 pending_switch,执行主/备屏切换(上节)。

注意 feed 被包在 catch_unwind 里调用(src/pty.rs:470-477)——解析器 panic 不允许杀掉 PTY reader 线程,这是「服务端解析」特有的防御:客户端 xterm.js 崩了只影响一个人,服务端解析崩了影响所有端。

Performer 处理哪些指令: src/vt_screen/performer.rscsi_dispatch(197-453 行)覆盖了光标移动(CUU/CUD/CUF/CUB/CUP)、擦除(ED/EL/ECH)、插删行/字符(IL/DL/ICH/DCH)、滚动(SU/SD)、滚动区(DECSTBM)、SGR 颜色属性;osc_dispatch(117-194 行)处理 OSC 133;esc_dispatch(455-481 行)处理 DECSC/DECRC/RI/RIS。

两个值得一提的解析细节:

  • 宽字符:CJK 字符占两格。printunicode-width 判宽,宽字符的「后半格」写一个 ch: '\0' 的占位 Cell;覆盖写半格宽字符时会清理另一半孤儿格(src/vt_screen/performer.rs:47-83)。
  • 私有标记防误判:CSI > 4 ; 2 m(modifyOtherKeys)不是 SGR。performer 对带非空 intermediates 的 CSI 一律不派发(src/vt_screen/performer.rs:252-260),避免把键盘配置序列当成下划线/暗色画到屏幕上——注释里点名这是 Claude Code 触发过的真实回归(src/vt_screen/mod.rs:237-248)。

1.5 快照:把「屏幕状态」重新编码回字节流

它要解决的小问题: 新设备连上,怎么把服务端屏幕「搬」过去?

思路: 客户端自己就是 xterm.js——一个终端模拟器。所以服务端把内存里的屏幕反向编码成一段 ANSI 序列,客户端的 xterm.js 照常解析,就画出了和服务端一模一样的画面。快照有四种,各有用途:

方法产物用途
snapshot_scrollback_chunks滚动历史,按行数分块重连时先发历史(每块 200 行)
snapshot_for_replay整屏重绘序列(重连版)历史之后画当前屏
snapshot整屏重绘序列(通用版)其他需要整屏的场景
snapshot_plain纯文本,无转义agent API 读屏(第 4 章)

重绘怎么编码: render_buffer(src/vt_screen/render.rs:6-35)逐行输出 CSI row;1 H(光标移到行首)+ CSI 2 K(清行),然后只写到最后一个非空 Cell 为止,Cell 属性变化时插入 SGR 序列。也就是「绝对寻址、整屏覆盖」——不依赖客户端之前有任何内容。

snapshot_for_replay 的两个细节(src/vt_screen/screen.rs:300-333):

  1. 先把视口里的历史尾巴「推」进客户端 scrollback。客户端刚写完滚动历史块,那些行还停在视口里;如果直接绝对寻址重绘,会把最后一屏历史覆盖掉、永远进不了客户端的回滚缓冲。所以快照开头先把光标移到底行、按待处理行数输出同样多个 \n(src/vt_screen/screen.rs:309-315)。
  2. 模式位也要重放。鼠标协议、括号粘贴等 PrivateModes 的当前值会被重新编码进快照(write_replay,src/vt_screen/data.rs:69-95);编码方式(1006/1016)先于协议(1000 等)写入,避免重放期间的鼠标事件用错线格式。唯独 focus event(1004)刻意不重放——注释说明:重连可能触发焦点上报的反馈风暴(src/vt_screen/data.rs:93-94)。

教学示例(把 1.4 + 1.5 串起来,# 示意,非源码):

# 服务端每个会话常驻一份
screen = VirtualScreen(cols=80, rows=24)

# 在线时:PTY 每来一段字节,两边各吃一份
for chunk in pty_read_loop():
screen.feed(chunk) # 服务端屏幕(本章)
broadcast_to_clients(chunk) # 客户端 xterm.js(第 3 章)

# 新设备连上:不重放历史字节,直接「拍快照」
payload = screen.snapshot_scrollback_chunks(200) # 先历史
payload.append(screen.snapshot_for_replay()) # 再当前屏
send_to_new_client(payload) # xterm.js 解析即复原

1.6 OSC 133:服务端顺便知道「一条命令跑完了」

它要解决的小问题: 想对终端编程(第 4 章的 agent API、通知、历史),光知道「屏幕长什么样」不够,还得知道「一条命令什么时候开始、什么时候结束、退出码多少」。

思路: 现代 shell 集成(VS Code / FinalTerm / iTerm2 风格)会在提示符和命令边界发 OSC 133 序列。VirtualScreen 顺带解析它(osc_dispatch,src/vt_screen/performer.rs:117-194):

序列含义服务端动作
OSC 133 ; A提示符出现回到 Idle,丢弃待跟踪命令
OSC 133 ; B命令开始执行PendingCommand(开始时间 + 输出缓冲)
OSC 133 ; D ; <code>命令结束产出 CommandResult(退出码、耗时、method="shell_integration")

没有 shell 集成怎么办: 有兜底——连续 100ms 无输出且光标所在行匹配提示符正则(#$%> 等),就当命令结束了(should_check_prompt / detect_prompt,src/vt_screen/screen.rs:79-136),method 记为 "prompt_detection"。

这些 CommandResult 随后被 PTY reader 取出(src/pty.rs:471-477)、经广播任务发到事件总线和同步通道(src/pty.rs:137-151)——通知「命令跑完了」、记录历史、agent API 等下游都靠它。


1.7 DEC mode 2026:同步输出的服务端半边

TUI 应用重绘时不想让用户看到「画了一半」的帧,会发 CSI ? 2026 h(开始同步输出)包住一整帧,再发 CSI ? 2026 l(结束)。performer 遇到这两个序列不动屏幕,而是往 sync_events 里压 SyncEvent::Start/Stop(src/vt_screen/performer.rs:216src/vt_screen/performer.rs:236)。

PTY reader 每轮 feed 后取出这些事件,调用 Session::set_sync_mode(src/pty.rs:484-492),后者控制「广播给客户端的字节要不要先攒着」。缓冲、冲刷、看门狗都在会话层,属于多端同步协议的一部分——第 3 章 3.5 节细讲。这里只需记住:解析在 vt_screen,缓冲在 session。


1.8 resize:缩小窗口时内容去哪了

服务端屏幕随客户端窗口 resize。最容易写错的是行数缩小时哪些行消失ScreenBuffer::resize(src/vt_screen/data.rs:183-237)的顺序:

  1. 先裁掉光标以下的空白尾行——大半空屏缩小不该污染历史(src/vt_screen/data.rs:195-205)。
  2. 还放不下的行,从顶部移入 scrollback——底部是最新输出和提示符所在,绝不能截掉(src/vt_screen/data.rs:209-218)。
  3. 光标行号跟着内容上移。

scrollback 总量封顶 10000 行,超出从队头丢弃(src/vt_screen/data.rs:243-245)。


1.9 本章小结

  • 服务端为每个会话维护 VirtualScreen:两块屏幕缓冲 + 滚动历史 + 模式位 + 命令跟踪,由 vte 解析器逐字节驱动。
  • 快照 = 把屏幕状态重新编码成 ANSI 序列发给客户端的 xterm.js;重连路径要处理历史尾巴、主备屏顺序、模式位重放三类细节。
  • OSC 133 让服务端知道命令边界,这是通知和 agent API 的基础;DEC 2026 事件则在第 3 章驱动同步输出缓冲。

1.10 代码地图

主题文件路径符号名
虚拟屏幕结构src/vt_screen/screen.rsVirtualScreenfeedresize
屏幕缓冲/Cellsrc/vt_screen/data.rsScreenBufferCellCellAttrsPrivateModes
CSI/OSC/ESC 派发src/vt_screen/performer.rsScreenPerformercsi_dispatchosc_dispatchapply_sgr
快照编码src/vt_screen/render.rsrender_bufferrestore_cursor_stateencode_sgr
重连快照src/vt_screen/screen.rssnapshot_for_replaysnapshot_scrollback_chunks
命令检测src/vt_screen/screen.rsdetect_promptbegin_command_trackingtake_command_output
同步输出事件src/vt_screen/data.rsSyncEvent
feed 调用点src/pty.rsbroadcast_task 之上的 PTY reader(438-530 行)