我的工作流:macOS 上用 Ghostty,SSH 连到一台远程 Linux 服务器(Kylin V11, aarch64),服务器上常驻 tmux,tmux 里跑 yazi 管理文件。
在 yazi 里选中文件按 cc,本该把文件路径复制到 Mac 本地剪贴板。但实际表现是:按了,没报错,没提示,Cmd+V 粘贴出来的还是旧内容——静默失败。
剪贴板数据要从最里层一路传到最外层:
yazi → tmux → SSH → Ghostty → macOS 剪贴板
任何一环吞掉数据,现象都一样。这篇记录完整的定位过程——最后不但修好了自己的问题,还顺手挖出 tmux 3.7 的一个真 bug,打了补丁提交上游。
背景:终端里怎么”复制到本地剪贴板”
远程服务器上没有显示服务器,xclip/wl-copy 无从谈起。终端程序想写用户本地机器的剪贴板,只有一条路:OSC 52 转义序列:
ESC ] 52 ; c ; <base64内容> ESC \
终端模拟器收到后解码写入系统剪贴板,SSH 只是透明搬运字节。
但中间隔着 tmux 时有两条路,各有一个开关:
- 裸发 OSC 52:由 tmux 拦截后转发给外层终端——受
set-clipboard控制 - passthrough 透传:包在
ESC P tmux; ... ESC \里让 tmux 原样放行——受allow-passthrough控制
第一章:tmux 3.3a 上的三重坑
服务器上的 tmux 是 3.3a。一番排查,发现这版本上叠了三层问题。
坑 1:allow-passthrough 默认关闭
tmux 3.3 引入了这个选项,默认 off,透传序列直接被吞。开启并做个注入测试——从外部往窗格的 pty 从属端写入构造好的序列,模拟程序输出:
printf "\033Ptmux;\033\033]52;c;$(echo -n test | base64)\007\033\\" > /dev/pts/N
本地剪贴板立刻有了内容。说明 tmux → Ghostty → macOS 传输链路没问题,问题在 yazi 一侧。
往 /dev/pts/X(从属端)写数据,会从 pty 主端被 tmux 当作”程序输出”读到,是测试终端转义行为的利器。
坑 2:yazi 用了新版本 tmux 的语法
YAZI_LOG=debug yazi 的日志里躺着答案:
ERROR yazi_emulator::mux: Failed to configure tmux passthrough and input buffer:
stderr: bad value: all
yazi 启动时执行 tmux set -p allow-passthrough all,而 all 是 tmux 3.4 才加的取值;它还要设置 input-buffer-size,那是 3.6 才加的选项。3.3a 上一个都不认识。
坑 3:set-clipboard external 不转发窗格内的 OSC 52
用 script 命令给 yazi 录下原始输出字节流,od -c 查看,发现 yazi 按 cc 时发的是裸 OSC 52(内容 base64 解码后正是悬停文件的路径,没毛病):
script -q -f -c yazi /tmp/yazi.raw # 按 cc,退出,分析 /tmp/yazi.raw
裸序列依赖 tmux 拦截转发,而实测 3.3a 的 set-clipboard external 不会转发窗格内应用的 OSC 52,改成 on 才通:
# ~/.tmux.conf
set -g set-clipboard on
set -g allow-passthrough on
配上一个把 all 改写成 on 的 tmux shim 脚本,3.3a 上终于能复制了。但这终究是打补丁——决定升级到最新 tmux 3.7c,源码编译安装,卸载 3.3a,重启 server。
第二章:升级到 3.7c,竟然还是不行
本以为故事到此结束。结果在新版上按 cc——还是复制不出来。
strace 拿到铁证
这次换个思路,不看剪贴板(避免和手工操作打架),直接 strace tmux server 进程,观察它到底往客户端写了什么:
strace -f -p <tmux-server-pid> -e trace=write,writev,sendmsg -s 400 -o /tmp/tm.strace
对照实验:
| 注入序列 | tmux 是否 writev 给客户端 |
|---|---|
| 裸 OSC 52 | ✅ writev(8, "\33]52;c;...\7", ...) |
2026l + OSC 52 + 2026h(yazi 的真实模式) | ❌ 什么都没有 |
2026l,等 2 秒,再 OSC 52 | ✅ 又通了 |
铁证如山:紧跟在 2026l 后面的 OSC 52 被 tmux 3.7c 吃掉了。
什么是 2026 同步输出
?2026 是”同步输出”协议(BSU/ESU):TUI 程序把一帧的绘制包在 ESC[?2026h … ESC[?2026l 之间,终端收到结束标记才原子性地整帧渲染,避免慢链路上的画面撕裂。Ghostty、kitty、WezTerm 都支持,yazi 每帧都发。
tmux 直到 3.7 才支持这个协议(issue #4744)——这就是 3.6 没事、3.7 中招的原因。
源码里的真凶
翻 3.7c 源码。input.c 里 2026l 的处理:
case 2026:
screen_write_stop_sync(ictx->wp);
if (ictx->wp != NULL)
ictx->wp->flags |= PANE_REDRAW; // ← 给窗格打上"待重绘"标记
break;
紧接着同一批数据里的 OSC 52 走到 screen_write_set_client_cb:
if (wp->flags & (PANE_REDRAW|PANE_DROP))
return (-1); // ← 待重绘的窗格,命令直接丢弃
剪贴板写入没有任何屏幕副作用,却被重绘守卫误杀。延迟 2 秒的测试之所以成功,是因为重绘发生后标记被清掉了。
一行补丁
screen_write_setselection 里给 tty 上下文加上 TTY_CTX_INVISIBLE_PANES,绕过重绘检查(顺带还支持了后台窗格的剪贴板写入):
--- a/screen-write.c
+++ b/screen-write.c
@@ -2865,6 +2865,7 @@ screen_write_setselection(struct screen_write_ctx *ctx, const char *clip,
struct tty_ctx ttyctx;
screen_write_initctx(ctx, &ttyctx, 0, 0);
+ ttyctx.flags |= TTY_CTX_INVISIBLE_PANES;
ttyctx.sel.clip = clip;
ttyctx.sel.data = str;
ttyctx.sel.size = len;
重新编译、安装、kill-server 重启,端到端验证:yazi 里 cc,Mac 剪贴板里出现了完整路径。✅
版本兼容性速查表
| tmux | allow-passthrough all | input-buffer-size | 2026 同步输出 | yazi cc |
|---|---|---|---|---|
| 3.3a | ❌ | ❌ | 不支持 | ❌ |
| 3.4 / 3.5 | ✅ | ❌ | 不支持 | ⚠️ yazi 启动报错 |
| 3.6 / 3.6b | ✅ | ✅ | 不支持 | ✅ 最佳原版 |
| 3.7+ | ✅ | ✅ | 支持(有 bug) | ❌ 需补丁 |
方法论:这次排障值得记住的几件事
- 分段验证,别端到端瞎猜。构造已知内容从中间环节注入,立刻能把”传输层故障”和”应用层故障”切开。
- 往 pty 从属端写入 = 模拟程序输出,测终端转义行为的神器。
script可以给任何 TUI 录下原始输出字节流,od -c一看便知程序到底发了什么,不靠猜。- strace 服务端进程,直接观察”它到底转发没转发”,比看客户端表现更可靠——尤其是剪贴板这种会被手工操作干扰的状态。
- TUI 的 debug 日志(
YAZI_LOG=debug)往往直接写着答案。 - 升级不一定解决问题,还可能引入新问题。3.3a 上是”版本太老”,3.7c 上是”版本太新”——最后落在版本矩阵里找准位置,配合源码阅读定位根因。
- 终端工具链的兼容性比想象中脆弱:tmux 的选项命名空间持续演进,新 TUI 程序毫不客气地用新特性,两边一错位就是静默失败。
最后把最小复现和补丁整理成了 issue 提交给 tmux 上游。如果你在 tmux 3.7+ 上也遇到”OSC 52 复制偶尔/总是失灵”,现在知道为什么了。