终端的语义层叠:几种终端方案的底层原理

4975 字
25 分钟
终端的语义层叠:几种终端方案的底层原理

Unix 终端不是统一的设备。1970 年代的 VT100 和 2020 年代的 Kitty 在颜色数、光标控制方式、鼠标支持、键盘编码格式上都不同。程序查询 terminfo 数据库,确认自己运行在哪种终端上,然后发送对应的控制序列。

当我发现 Vim 在 tmux 里的颜色比在原生终端里少,鼠标滚轮在不同程序下行为不一致时,才开始理解终端、PTY、multiplexer 之间叠加了多少层语义。选型经历见《我为什么选 WezTerm》

计算发生在本地时,terminfo 查询和控制序列传输都在同一台机器上完成。计算发生在远程机器上时,网络会断,显示设备在本地。传统工具链把会话管理和显示输入拆分到不同工具;远程开发中,SSH 连接同时承载会话管理和显示输入。

TTY 与终端模拟器#

物理终端#

早期计算机没有图形界面。用户通过物理终端与机器交互——一台独立的硬件设备,有键盘和显示器,通过串口连接到主机。DEC VT100 于 1978 年推出,它定义了大量至今仍在使用的控制序列标准。物理终端内部有自己的处理器和内存,接收键盘输入,向主机发送字符,接收主机返回的控制序列并在屏幕上渲染。

TTY#

TTY 是 Teletypewriter(电传打字机)的缩写。最早的终端确实是电传打字机——一台电动打字机,通过纸带和电话线与计算机通信。Unix 把这类设备抽象为 TTY 设备,在文件系统中表现为 /dev/tty1/dev/tty2 等。

TTY 的核心功能:接收键盘输入传给连接的进程;接收进程的输出显示在屏幕上;处理基础编辑(行缓冲、回显、中断信号如 Ctrl+C)。

终端模拟器#

个人电脑普及后,终端模拟器取代物理终端——一个软件程序,模拟物理终端的行为。iTerm2、Alacritty、Kitty、macOS 自带的 Terminal.app 都是终端模拟器。

终端模拟器不做计算,只做三件事:接收键盘输入,转换成字节流发给 shell;接收 shell 输出的字节流,解析控制序列,在窗口中绘制字符;维护 scrollback 缓冲区保存历史输出。

shell、terminal emulator、multiplexer 的区别#

  • shell(bash、zsh、fish):命令解释器。输入的命令由它解析和执行。shell 不关心字符怎么画到屏幕上,只读写 TTY。
  • terminal emulator(iTerm2、Alacritty、Kitty):负责画字符、处理字体颜色、滚动、复制。它创建 PTY master,shell 运行在 PTY slave 上。
  • terminal multiplexer(tmux、zellij):在 shell 之上再套一层,管理多个会话、窗口、pane。它持有 PTY master,shell 运行在它创建的 PTY slave 上。

打开 iTerm2 运行 zsh:

iTerm2 (terminal emulator)
└── PTY master
└── zsh (shell) 运行在 PTY slave 上

在 iTerm2 里运行 tmux,tmux 里再运行 zsh:

iTerm2 (terminal emulator)
└── PTY master (iTerm2 持有)
└── tmux client
└── UNIX socket
└── tmux server
└── PTY master (tmux server 持有)
└── zsh (shell) 运行在 PTY slave 上

iTerm2 的 PTY master 连接的是 tmux client,不是 zsh;tmux server 持有另一个 PTY master,zsh 运行在它的 slave 端。

为什么需要 PTY#

物理终端时代,终端直接通过串口连接到主机。软件时代,终端模拟器和 shell 在同一台机器上运行,操作系统需要一种机制让它们通信,同时向 shell 呈现为一个真实的 TTY 设备。

PTY 就是为此设计的:一对虚拟设备(master 和 slave),slave 端对 shell 呈现为 /dev/pts/N 这样的 TTY 设备,master 端由终端模拟器持有。操作系统在内核中转发两端的数据。

PTY 是操作系统提供的虚拟设备对;终端和 shell 通过它转发字节流。shell 不必分辨物理终端和软件终端,终端模拟器也不必关心 shell 内部运行什么程序。

PTY 与网络断裂#

SSH 登录远程机器时,远程 sshd 分配一个 PTY slave,shell 运行在这个 slave 上,master 端通过 SSH 加密通道与本地终端关联。

flowchart LR A[本地终端] -->|SSH| B[远程 sshd<br/>PTY master] B -->|PTY| C[shell<br/>PTY slave] D[网络断开] -.->|sshd 退出| B B -.->|master 关闭| C C -.->|SIGHUP| E[进程终止]

网络断裂时,sshd 进程退出,它持有的 PTY master 关闭。内核向 PTY slave 发送 SIGHUP。shell 及其子进程默认终止。未显式脱离控制终端的训练任务、agent 进程、长时间运行的编译会在网络抖动时终止。

tmux 的解法:让 daemon 持有 PTY master#

tmux 让独立的 daemon 进程持有 PTY master。SSH 断裂只切断 tmux client 与 daemon 之间的 UNIX domain socket 连接;daemon 继续运行,PTY slave 上的进程不会收到 SIGHUP,继续执行。

UNIX domain socket 是一种进程间通信机制,工作在同一台机器的文件系统命名空间内,不经过网络协议栈。tmux client 和 server 通过这个 socket 交换消息,速度比 TCP socket 快。

terminfo:终端能力的注册表#

Unix 程序用 terminfo 确认终端型号——数据库存储在 /usr/share/terminfo/ 下,每个终端型号有一个条目,记录该终端支持哪些控制指令。

程序启动时读取 $TERM 环境变量,在数据库中找到对应条目,然后决定发送哪种控制序列。比如 Vim 发送”清屏”字节序列之前,会先查 terminfo。

$TERM 的值决定了应用程序认为自己运行在哪种终端上。控制这个变量,就控制了下游程序发送的控制序列。

terminfo 的内部结构#

flowchart LR A[程序启动] --> B[读取 $TERM] B --> C[查询 terminfo<br/>/usr/share/terminfo/] C --> D[获取控制序列模板] D --> E[发送 CSI / OSC<br/>到终端] style C fill:#fff2cc

一个 terminfo 条目可以用 infocmp 命令查看:

$ infocmp xterm-256color
# Reconstructed via infocmp from file: /usr/share/terminfo/78/xterm-256color
xterm-256color|xterm with 256 colors,
am, bce, ccc, km, mc5i, mir, msgr, npc, xenl,
colors#256, cols#80, it#8, lines#24, pairs#32767,
bel=^G, blink=\E[5m, bold=\E[1m, clear=\E[H\E[2J,
csr=\E[%i%p1%d;%p2%dr, cub=\E[%p1%dD, cud=\E[%p1%dB,
cup=\E[%i%p1%d;%p2%dH, cuu=\E[%p1%dA,
dch=\E[%p1%dP, dl=\E[%p1%dM, ed=\E[J, el=\E[K,
...

二进制文件包含五个部分:

  1. Header:包含 magic number(0x11A 或 0x21E,标识字节序)、name section 大小、boolean count、number count、string count、string table size
  2. Name section:终端的主名称和别名,以 null 字节分隔
  3. Boolean section:每个标志占 1 byte,值为 0 或 1。例如 am(auto margin,自动换行)、bce(background color erase,清屏时保留背景色)、km(has meta key)
  4. Number section:每个数值占 2 bytes。例如 colors#256(支持颜色数)、cols#80(列数)、lines#24(行数)
  5. String section:每个能力对应 string table 中的一个偏移量,指向实际的控制序列模板。例如 cup(cursor_address,移动光标)、clear(清屏)、smcup/rmcup(进入/退出 alternate screen)、kmous(鼠标支持)、setaf/setab(设置前景/背景颜色)

curses 库(Vim、htop、mutt 等工具的底层)在初始化时读取 $TERM,解析 terminfo 条目;屏幕绘制操作——移动光标、改变颜色、清屏——都使用 terminfo 定义的序列。

tmux 的叠加架构与代价#

tmux 在终端之上再建一个虚拟终端层,让会话在网络断开后继续运行。

flowchart LR subgraph 本地 ["本地机器"] T[终端模拟器] end subgraph 远程 ["远程机器"] C[tmux client] -->|UNIX socket| S[tmux server<br/>PTY master] S -->|PTY| App[应用程序<br/>PTY slave] end T <-->|SSH| C style T fill:#e1f5e1 style S fill:#fff2cc

tmux server 从 tmux client 接收按键事件,处理后再向应用程序的 PTY slave 写入字节流;应用程序的输出从 PTY master 进入 tmux server,在 screen buffer 中渲染后整屏推送给 client。

TERM 截获与能力降维#

tmux 默认设置 TERM=screen*TERM=tmux-256color。下游程序查询 terminfo,读到的是 tmux 的能力集,而非 iTerm2、Alacritty 或 Kitty 的。

我第一次注意到这个问题,是在一台远程服务器上打开 Vim。本地 iTerm2 支持真彩色(set termguicolors 正常工作),但同一台机器的 tmux session 里,Vim 只能显示 256 色。查了一下,tmux 把 TERM 改成了 screen-256color,Vim 查到的能力集里没有真彩色支持。这就是 TERM 截获的直接后果。

终端控制序列是以 ESC(\x1b,ASCII 27)开头的字节流指令:

flowchart TD A[ESC 0x1b] --> B{下一字节} B -->|"0x5b ["| C[CSI<br/>光标 / 颜色 / 清屏] B -->|"0x5d ]"| D[OSC<br/>剪贴板 / 标题] B -->|"0x4d M"| E[鼠标报告<br/>SGR 1006] B -->|"0x49 I"| F[聚焦事件<br/>获得焦点] B -->|"0x4f O"| G[聚焦事件<br/>失去焦点]
  • CSI(Control Sequence Introducer):ESC [ 开头,用于光标移动、颜色设置、清屏。例如 ESC [ 2 J 清屏,ESC [ 31 m 设置红色前景
  • OSC(Operating System Command):ESC ] 开头,用于与操作系统交互。例如 ESC ] 52 ; c ; <base64数据> ESC \ 将数据写入系统剪贴板(OSC 52)
  • 鼠标报告:应用程序启用鼠标模式后,终端将点击事件编码为 ESC [ <按钮编码> ; <x> ; <y> M 发送给应用程序(SGR 1006 格式)
  • 聚焦事件:终端获得或失去焦点时,向应用程序发送 ESC [ IESC [ O

tmux 截断、翻译或降级这些序列:

能力原生终端支持经 tmux 后说明
Kitty 键盘协议完整修饰键降级为传统编码传统终端键盘编码无法区分 Ctrl+i 和 Tab,也无法报告多个修饰键同时按下。Kitty 扩展了编码格式,让程序收到完整的键值和修饰键掩码。tmux 作为中间层无法完整转发这些扩展编码
Sixel 图形像素数据直传通常被过滤DEC 在 1980 年代定义的位图协议,通过 ANSI 序列传输像素数据。tmux 的内部数据结构是字符网格,每个格子存一个字符和颜色属性,无法承载像素矩阵
聚焦事件原生支持需额外配置转发Vim 等编辑器用聚焦事件触发自动保存或状态刷新。tmux 默认不转发这些事件,因为焦点在 tmux client 和 server 之间多了一层传递
Unicode width终端特定处理受 tmux 网格限制East Asian Width 标准对某些字符的宽度定义存在歧义(如某些 emoji)。不同终端采用不同启发式规则。tmux 有自己的网格实现,可能与宿主终端规则不一致

双 buffer 结构#

tmux 维护的 screen buffer 基于字符网格,与宿主终端的 scrollback 无关。

  • scrollback:终端模拟器(iTerm2、Alacritty)维护的历史输出缓冲区。当屏幕上的内容被新输出推上去后,旧内容进入 scrollback。用户可以用鼠标滚轮、触控板或 Shift+PageUp 回溯查看,用鼠标选中后 Command+C 直接复制到系统剪贴板。
  • copy-mode:tmux 自己的历史浏览机制。用户按前缀键加左方括号键进入,此时键盘事件被 tmux 消费,而不是发给 pane 内的应用程序。用 tmux 的键位导航:Space 开始选择,Enter 复制到 tmux 内部 buffer。这个 buffer 与系统剪贴板不直接互通;靠 OSC 52 或外部脚本才能把内容复制到系统剪贴板。
flowchart TD A[用户想回看历史输出] --> B{操作方式} B -->|鼠标滚轮<br/>Shift+PageUp| C[宿主终端 scrollback<br/>本地终端模拟器管理<br/>Command+C 原生复制] B -->|前缀键 + 左方括号键<br/>进入 copy-mode| D[tmux screen buffer<br/>tmux server 管理<br/>Space 选择 / Enter 复制] style C fill:#e1f5e1 style D fill:#fff2cc

scrollback 由本地终端模拟器管理,copy-mode 由远程 tmux server 管理;数据不互通。

alternate screen 的模拟#

许多全屏 TUI 程序(Vim、less、htop)在启动时请求进入 alternate screen——独立的屏幕缓冲区。退出时切回主屏幕,原来的终端内容仍然可见。ESC [ ? 1049 hESC [ ? 1049 l 两个控制序列实现这个切换。

tmux server 在自己的 screen buffer 中模拟 alternate screen,不直接转发给宿主终端;鼠标滚动和文本选择由 tmux server 处理。

鼠标事件的多跳转发#

tmux server 先收到 X10 或 SGR 1006 鼠标事件,再决定消费、转发或进入自己的滚动逻辑。应用程序不再直接处理这些事件。

zellij 与 cmux#

zellij 的 modal 输入系统#

zellij 也是 client-server 架构,daemon 持有 PTY master,但在 Rust 中内嵌了一个 WASM 运行时,插件以 WebAssembly 模块形式加载,可以编程访问 pane、tab 和状态栏。

它的输入系统是一个显式的 modal 状态机。同一套输入设备在不同”模式”下执行不同功能。Vim 同样使用 modal 输入——normal mode 下 j 向下移动光标,insert mode 下 j 插入字母 j。用户切错 mode,输入就路由到错误目标。

  • Ctrl+p → pane mode
  • Ctrl+t → tab mode
  • Ctrl+s → scroll mode
  • PageDown / PageUp 默认挂在 scroll mode 里
flowchart TD Agent[Agent<br/>node-pty] -->|ANSI 序列| Z[Zellij modal 状态机] Z -->|当前处于哪个 mode| Z Z -->|消费或转发| App[应用程序] style Z fill:#ffcccc

像 Codex 这样通过 node-pty 注入 ANSI 序列的 agent 不跟踪 zellij 的 mode 状态。我在 zellij 里运行 Codex 时,它发送的 PageUp 被 zellij 的 scroll mode 拦截,pane 内的程序根本没有收到这个序列。

node-pty 是 Node.js 的一个库,它在操作系统中创建 PTY pair,JavaScript 程序通过它像真实终端一样与 shell 交互:写入字节流,读取输出。Codex 通过它向 shell 发送键盘输入和控制序列,期望目标程序直接消费这些字节。zellij 可能把序列收进自己的 mode 处理,不转发给 pane 内的应用程序。官方文档提到 Unlock-First preset:modal 系统在该 preset 下拦截流向应用程序的输入。

cmux:本地终端与 JSON-RPC 控制#

cmux 用 Ghostty 的 Metal 渲染管线,是一个原生 macOS 终端,提供垂直标签页和 JSON-RPC socket API。

cmux 暴露了一个 socket 接口,外部程序向这个接口发送 JSON 对象来创建窗口、切换标签、查询状态。cmux 控制本地终端,不在远程机器上保持会话存活。

cmux 重启后只还原布局、工作目录和环境变量等元数据;pty 中运行的活跃进程状态不会恢复。

WezTerm:把 multiplexing 做进终端本身#

flowchart LR GUI[WezTerm GUI client<br/>本地渲染] <-->|RPC<br/>damage tracking| Mux[wezterm-mux-server<br/>远程或本地] Mux -->|PTY| App[应用程序] style GUI fill:#e1f5e1 style Mux fill:#fff2cc

Domain:本地与远程 pane 的来源标记#

“domain”标记 pane 进程的来源:

  • local domain:在本地机器启动进程
  • ssh domain:通过 SSH 连接远程机器,启动 wezterm-mux-server
  • tls / unix domain:连接已运行的 mux server

GUI 用同一套接口处理所有 domain 的 pane。远程 pane 和本地 pane 由同一个输入分发器处理,没有区别。

Damage tracking:从整屏推流到增量更新#

flowchart TB subgraph tmux ["tmux 整屏推流"] T1[screen buffer 变化] --> T2[序列化整屏字符网格] T2 --> T3[通过 UNIX socket 发送] T3 --> T4[client 重新渲染虚拟屏幕] end subgraph wez ["WezTerm damage tracking"] W1[screen buffer 变化] --> W2{哪些单元格变了?} W2 --> W3[只发送变化的单元格<br/>坐标 + 字符 + 颜色] W3 --> W4[client 更新对应区域] end style tmux fill:#fff2cc style wez fill:#e1f5e1

damage tracking 来自图形渲染:屏幕内容变化时,只重绘变化区域。WezTerm 把它用于终端远程传输。

mux server 维护每个 pane 的内部字符网格。当应用程序输出导致网格中的某些单元格发生变化时,server 只把这些变化的单元格——坐标、字符、颜色属性——打包发送给 GUI client。client 收到后,只更新对应的屏幕区域。

维度tmuxWezTerm
传输内容整屏字符流序列化damage tracking 增量更新
带宽使用高(每次全屏重绘)低(只传变化的单元格)
渲染分离client 负责重新渲染虚拟屏幕GUI client 只管本地渲染,mux server 管理 pty
交互语义由 tmux server 二次定义由 GUI client 直接定义

tmux 的 screen buffer 每次变化时,tmux server 把整个字符网格序列化为字符串,通过 UNIX socket 发送给 client。client 收到后,在自己的虚拟终端状态机中重新渲染。

mux server 管理 pty 和进程;GUI client 用 OpenGL、Metal 或 WebGPU 做本地渲染。两端通过 RPC 交换 damage tracking 更新和输入事件。

TERM 端到端穿透#

在 WezTerm 的远程 domain 中,远程机器上的应用程序看到的 TERM 仍然是 wezterm(或配置的等效值),中间 multiplexer 不会替换它。Kitty 键盘协议、Sixel 图形、Unicode width 的特定行为——只要 WezTerm 原生支持——穿透时不经中间层翻译。

Lua 配置的统一命名空间#

return {
ssh_domains = {
{
name = "dev",
remote_address = "myserver",
remote_wezterm_path = "/home/user/.local/bin/wezterm",
},
},
keys = {
{
key = "d",
mods = "CMD",
action = wezterm.action.ShowLauncherArgs { flags = "DOMAINS" },
},
},
}

ssh_domains 定义远程连接,keys 定义本地键位,flags = "DOMAINS" 弹出的 launcher 同时列出本地和远程 domain。

部署代价#

远程 domain/mux 通常在远程机器上也要部署 WezTerm 二进制文件,配置包含 ssh_domainsmultiplexingremote_wezterm_path 等选项。

AI Agent:输入与输出的精确对应#

AI agent 看不到屏幕,只能凭输出推断状态;输入与输出错位,推断就会出错。

Codex 通过 Node.js 的 node-pty 模块操作伪终端。node-pty 在操作系统中创建 PTY pair,JavaScript 程序像真实终端一样与 shell 交互:写入字节流,读取输出,解析 ANSI 转义序列来推断界面状态。

flowchart LR A[Agent] -->|写入字节流| P[node-pty] P -->|ANSI 序列| T[终端] T -->|输出| P P -->|读取| A T -->|消费或转发| App[目标应用程序]

agent 的输入直达目标程序,屏幕状态与它发送的序列一一对应,输出历史都在同一个 scrollback 里。中间层打断这种对应关系:

  • 鼠标事件被截获:agent 发送的鼠标点击被 tmux copy-mode 消费,目标应用程序毫无感知
  • 滚动语义断裂:agent 期望 PageUp 直接滚动历史输出,zellij 的 modal 系统将其映射为 mode 切换
  • 历史输出歧义:宿主终端的 scrollback 与 tmux pane history 是两个独立的数据结构,agent 读取历史时无法确定面对的是哪一个。

WezTerm 用 damage-tracking RPC 和统一的本地渲染缓存在本地 GUI 中同步 pane 屏幕状态。我在 WezTerm 的远程 domain 里运行 Codex 时,它写入的字节流直达远程 pane,输出历史和本地终端共用一个 scrollback,没有 mode 系统拦截输入。控制序列由单一的 terminfo 定义。

终端 emulator 与 multiplexer 为何分离#

flowchart TB subgraph 本地时代 ["本地计算时代"] A[终端模拟器] -->|PTY master| B[multiplexer] B -->|PTY master| C[shell] end subgraph 远程时代 ["远程开发时代"] D[本地终端模拟器] -->|SSH| E[远程 sshd] E -->|PTY master| F[multiplexer] F -->|PTY master| G[shell] end style 本地时代 fill:#e1f5e1 style 远程时代 fill:#fff2cc

终端模拟器管显示和输入,multiplexer 管会话和窗格,PTY master 与二者在同一台机器上。

计算发生在另一台机器上时,PTY master 位于远程 sshd 进程中,显示设备在本地。SSH 在两端转发数据。网络断开后,本地终端收不到远程输出,multiplexer 无法向本地发送输出。

terminfo 假设程序直接访问本地终端。控制序列跨越网络、穿越多层中间件时,tmux 的字符网格承载不了 Kitty graphics protocol 的像素数据;多层 TERM 翻译后的 Sixel 序列可能解析失败;attach/detach 切换时聚焦事件可能丢失。

WezTerm 从本地 GUI 到远程 pty 使用同一套 terminfo 和同一套 RPC 协议,远程终端是单一设备。

什么时候选什么#

tmux 依赖少、安装广。如果只是需要 SSH 会话保活,不涉及 TERM 截获或 scrollback 分离,它是更轻量的选择。

当本地与计算分离、AI agent 直接操作终端时,部署 wezterm-mux-server 得到统一的 terminfo、单一的 scrollback,控制序列不经中间层截获。这对应了我从 iTerm2 + tmux 切换到 WezTerm 远程 domain 时解决的那些具体问题。

终端的语义层叠:几种终端方案的底层原理
https://axi.moe/posts/tstack01/
作者
NolanHo
发布于
2026-04-20
许可协议
CC BY-NC-SA 4.0

目录