我为什么选 WezTerm
我在 macOS 上操作,计算在远程开发机上完成。训练、实验、长时间运行的 agent 任务都在远程开发机上。网络会断,但任务不能跟着断。
有一次,我在某地方改代码,WiFi 抖动了一下,或者我的位置移动了。SSH 断开,远程机上的训练任务收到 SIGHUP,直接终止。那批数据跑了六个小时。从那以后,远程开发机的会话保活成了硬需求。
过去这类需求默认指向 tmux。SSH 断开后,远程 shell 和进程仍然存活。tmux 保活会话,不管终端交互。
我的工作流约束
那次事件之后,我列出了四条必须同时满足的条件:
- 远程任务在网络中断后继续活着
- 本地交互保持原生终端体验
- 可以按自己的工作流深度定制
- 不和 Codex 这类 agent 驱动的终端工作流打架
tmux 保活了会话,但劫持了交互
tmux 把远程 shell 会话从 SSH 连接里解耦出来。本地断网,session 还在。
tmux 保活进程的同时,还接管了终端交互。
在 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 把远程会话做进终端
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 工具工作。