我为什么选 WezTerm

1683 字
8 分钟
我为什么选 WezTerm

我在 macOS 上操作,计算在远程开发机上完成。训练、实验、长时间运行的 agent 任务都在远程开发机上。网络会断,但任务不能跟着断。

有一次,我在某地方改代码,WiFi 抖动了一下,或者我的位置移动了。SSH 断开,远程机上的训练任务收到 SIGHUP,直接终止。那批数据跑了六个小时。从那以后,远程开发机的会话保活成了硬需求。

flowchart LR A[macOS 本地] -->|SSH| B[远程开发机] B --> C[训练任务] B --> D[agent 任务] B --> E[长时间运行的实验] style A fill:#e1f5e1 style B fill:#fff2cc

过去这类需求默认指向 tmux。SSH 断开后,远程 shell 和进程仍然存活。tmux 保活会话,不管终端交互。

我的工作流约束#

那次事件之后,我列出了四条必须同时满足的条件:

  1. 远程任务在网络中断后继续活着
  2. 本地交互保持原生终端体验
  3. 可以按自己的工作流深度定制
  4. 不和 Codex 这类 agent 驱动的终端工作流打架

tmux 保活了会话,但劫持了交互#

tmux 把远程 shell 会话从 SSH 连接里解耦出来。本地断网,session 还在。

tmux 保活进程的同时,还接管了终端交互。

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

在 tmux 里复制日志时,我要先按前缀键加左方括号键进入 copy-mode,用 Space 开始选择,Enter 复制到 tmux 内部 buffer,再想办法把它弄到系统剪贴板。而在 iTerm2 里,Command+C 直接复制选中的文本。这是两套完全不同的复制语义。

回看历史也一样。在 iTerm2 里,我用鼠标滚轮直接翻历史输出;在 tmux 里,滚轮可能进入 copy-mode,也可能被 pane 内的应用程序消费,取决于 mouse 开关和当前程序状态。宿主终端的 scrollback 和 tmux 的 pane history 是两个独立的数据结构,数据不互通。

Codex 高频刷新界面、持续输出长上下文时,这种双层语义干扰更明显。底层原理见《终端的语义层叠》.

zellij 仍是 multiplexer#

zellij 站在 multiplexer 层,重做了界面和布局。它提供 pane 和 tab,同时把交互做成一套 modal 系统:Ctrl p 进 pane mode,Ctrl t 进 tab mode,Ctrl s 进 scroll mode,连 PageDown / PageUp 默认也挂在 scroll mode 里。

zellij 把终端输入收编进了自己的 mode 体系。Codex 读长输出时发送 PageUp,zellij 却把它映射为 scroll mode 切换,而不是直接滚动历史。agent 期望的输入语义和 zellij 的实际行为对不上。

cmux 是本地终端,不解决远程持久化#

cmux 基于 Ghostty,是原生 macOS 终端,主打垂直标签页、通知提醒环、分屏面板、内置浏览器和 socket API,官网直接把 Claude Code、Codex 当成核心使用对象。

它把 agent 工作流包装成本地终端产品,不管远程会话保活。cmux 重启后只能恢复布局、工作目录、回滚缓冲区这些元数据,活跃的终端应用会话不会恢复。

WezTerm 把远程会话做进终端#

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

WezTerm 是 terminal emulator。复制、滚动、文本选择、快捷键发生在终端自己的层,外部 multiplexer 不会先截获它们。它内置了 multiplexer/domain 模型,配置是 Lua。

我第一次注意到 wezterm-mux-server 这个二进制文件时,才意识到 WezTerm 不是在终端外面套了一层 multiplexer,而是把远程会话能力直接做进了终端系统。wezterm connect 连接的是 mux server,wezterm cli 操作的是 mux server,远程 attach/detach 是终端自身的语义,不是额外再套一个 TUI。

它在命令层提供 wezterm connect、wezterm cli、wezterm start —domain … —attach,系统里还有单独的 wezterm-mux-server。wezterm connect 连接 multiplexer,wezterm cli 和 mux server 交互。

“本地 terminal emulator + SSH + 远程 tmux” 把远程会话管理套成额外一层 TUI。WezTerm 把它收进终端自己的 domain/mux 层。

  • 会话持续,网络断开后还能重新 attach
  • 本地接近原生终端,copy mode 和 pane buffer 不会重写交互
  • 配置都在同一个 Lua 脚本里
  • 远程 host 是常驻开发机,服务器上需要安装 WezTerm

这套远程 mux 有成本。配置层里有 ssh_domains、multiplexing、remote_wezterm_path,用全远程 domain/mux 能力,远程机器上通常也要装一份 WezTerm。

WezTerm 不需要外部 multiplexer。terminal emulator 自己处理远程连接,复制、滚动和历史都在同一层。

实际配置#

远程连接、本地键位、domain 切换都在同一个 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" },
},
},
}

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

AI Agent 需要什么#

AI agent 读字节流,不读像素。输出和输入对不上,它推断的状态就错。

Codex 通过 Node.js 的 node-pty 模块操作伪终端:它写入字节流,读取输出,解析 ANSI 转义序列来推断界面状态。

agent 写入字节流,目标程序直接接收;屏幕内容与发送的序列一致;输出都在同一个 scrollback 里。这是 agent 推断界面状态的前提。

tmux 和 zellij 都在 agent 和终端之间加了一层,打破了这种对应关系:

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

WezTerm 的 damage-tracking RPC 和本地渲染缓存在本地 GUI 上保存了 pane 屏幕的副本。agent 操作远程 pane 时,外部 mode 系统不会拦截输入,输出历史都在同一个 scrollback 里,terminfo 定义终端行为。

什么时候选什么#

tmux 预装在大多数服务器上。纯 SSH、低安装成本的场景下,它不会增加部署负担。

WezTerm 更适合我的工作流:本地和计算分离,远程机常驻,任务常常长时间运行,日常交互密度高,同时用 Codex 这类 agent 工具工作。

我为什么选 WezTerm
https://axi.moe/posts/whywez01/
作者
NolanHo
发布于
2026-04-20
许可协议
CC BY-NC-SA 4.0

目录