跳到正文
Hacker News· cs1996·· 8 天前AI 评分20

为还原 macOS Terminal 手感而构建 TabDance

Building TabDance to feel like macOS Terminal

AI 导读

Andrew Arrow 构建了 TabDance,目标是复刻 macOS Terminal 的日常操作手感,提供工作区与标签页。他此前试用 cmux、为 Rio 提交 PR #1954 加入左侧抽屉与工作区概念,并静态分析 Apple Terminal 二进制,发现其含 107 个类、2488 个方法实现,Command-K 的 clearAll: 路径只做本地屏幕记账、不向 shell 写入。

正文 · AI 翻译

← 工程博客

2026年10月10日 · Andrew Arrow

Command-K 是我复制文件流程的一部分。我会清空终端,运行 cat file.txt,然后按下 Command-A。我期望只选中文件内容,上下各只有两行,确信旧的构建日志不会跟着一起被选中。

这个小习惯解释了一段相当长的弯路:我试过 cmux,参与了一个 Rio 分支的开发,然后开始逆向工程 Apple 的 Terminal,以理解我一直缺失的那种行为。新项目是 TabDance。我想要工作区和标签页,同时保留我习惯的 macOS 终端的日常手感。

“手感”听起来很模糊,直到你把按键操作一一列出来。清空、复制、粘贴,以及看到字符出现,在代码中是各自独立的操作。在我手里,它们是一个连续的活动,其中任何一个小意外都足以让我犹豫。

我喜欢 cmux 的工作区

cmux 让组织终端会话变得很有吸引力。它的垂直工作区布局为相关会话提供了容身之处,而不是让我去记住哪个窗口属于哪个项目。它是一个使用 Swift 和 AppKit 的原生 macOS 应用,由 libghostty 提供终端引擎。源代码可用,所以有真正的机制可以学习。

我喜欢它,但我想改变一些东西。特别是,Command-K 和处理多行命令不符合我的习惯。然后我发现了 Rio,并尝试把工作区的想法带到那里。

9月22日,我提交了 Rio PR #1954,添加了一个左侧抽屉和受 cmux 启发的工作区概念。那个提案来自我的 aa 分支。后来的 spinner 分支包含了更多实验内容,包括修复终端应用重绘后复制选区的问题。它们是这项工作的不同快照。

The Rio workspace experiment: cubacadabra, forkalope, os, and groupicorn in the left drawer, with groupicorn tabs and a shell prompt beside them.
我的 Rio 工作区实验,来自9月22日的 PR。这是实际截图的裁剪,不是 TabDance 的模拟图。查看完整图片。

在 PR 描述中,我称 Rio“完美”。那是对找到一个有前途的基础的热情。随着我继续使用它,复制粘贴、清空,以及打字时的响应,仍然与 Apple Terminal 有一些细微差别。得到我想要的工作区布局并没有解决这些细节。

Command-K 到底清空了什​​么?

对于我的复制习惯,清空后的窗口需要建立一个有用的边界:我接下来打印的内容就是全选会收集的内容。如果更早的输出仍然可以被选中,那么视觉上干净的屏幕并没有给我想要的信心。

Schematic selection comparison. One buffer includes an old build log and error before cat file.txt. The desired buffer selects only alpha, beta, gamma and two surrounding rows on either side.
TabDance 的目标行为。三行文件和周围的几行是示意性的固定内容;这不是已发布终端之间的实测比较。提示符和换行设置可能会改变周围的文本。

终端有当前屏幕、保存的回滚缓冲区、光标和选区。清空对它们的影响各不相同。向 shell 发送控制字符又增加了一个参与者:shell 或前台程序决定如何响应。将视口移离旧文本并不一定将该文本从可选缓冲区中移除。

在我检查的 Rio 修订版中,正常的 macOS Command-K 绑定有两个匹配的操作:发送 0x0c(Control-L 生成的字符),以及清除保存的历史记录。历史记录操作作用于 Rio 的终端状态。另一个操作发送给正在运行的程序,后者通常会重绘提示符或屏幕作为响应。

这个区别给了我一些具体的东西可以调查。命令在空闲的 shell 中看起来可能没问题,但在有部分命令、不同的行编辑器或全屏程序时行为会不同。这些绑定解释了 Rio 所走的路径;它们本身并不能证明我感受到的每一个差异的原因。

可执行文件是一张有用的地图

我想看看 Apple 是如何处理同样的问题的。我最初打开的文件名为 term.bin.exe.mac。它的名字很奇怪,但 file 识别出一个 Mach-O 通用二进制文件,包含 arm64e 和 arm64e.x1 切片。它的 SHA-256 与已安装的 Apple Terminal 可执行文件逐字节匹配。分析清单记录了该比较及所用命令。

第一遍使用了 macOS 自带的检查工具:

file term.bin.exe.mac
dyld_info -arch arm64e -objc term.bin.exe.mac
dyld_info -arch arm64e -disassemble term.bin.exe.mac

Objective-C 元数据特别有用。它暴露了类和方法名称,为汇编代码提供了可读的轮廓:一个 shell 对象、一个 I/O 管理器、一个编码转换器、一个 VT100 模拟器、逻辑屏幕存储和一个视图。提取发现了 107 个类和 2,488 个方法实现。这足以追踪特定操作,而无需假装我们拥有原始源代码。

这就是“反编译 Terminal”需要加限定条件的地方。到目前为止,工作是静态检查、带注释的反汇编和选定路径的手动重建。我们还没有恢复 Apple 的原始 Objective-C、其注释或设计意图。像 clearAll: 这样的名称是待检查的线索,而不是完整的规范。

Apple 的快捷键指南将 Command-K 描述为清除到开头。在二进制文件中,视图有分别用于清除屏幕、回滚和全部内容的方法。它的 clearAll: 方法清除选择,并要求逻辑屏幕清除回滚,位于一对开始/结束内容变更之间。

跟随那个逻辑屏幕调用显示的不只是删除旧行列表。它移动活动行范围、移除字符、截断保存的文本和偏移缓存,并将行标记为脏以进行绘制。这是本地屏幕簿记。在视图的 clearAll: 路径中没有 shell 写入。

我还没有追踪菜单接线到足以声称这就是确切的 Command-K 处理程序。检查确实确立的是,Apple 有一个显式操作来协调选择、保存的文本和活动行。这就是我的清除–打印–选择习惯在 TabDance 中需要定义的水平。

复制我选中的文本

第二个问题用五个字符更容易说明。假设一个终端应用程序显示 hello,我选中它,然后应用程序在我按下 Command-C 之前擦除了那一行。复制应该读取当前单元格还是一刻前我选中的文本?

我的 Rio 分支在选择变化时存储文本快照。复制路径在回退到实时网格之前使用该保存的字符串。可见行可以改变,而等待复制的文本不变。

Five selected cells spell hello. An erase-line sequence blanks the live cells, while a separate saved selection still contains hello and supplies that string to the clipboard.
Rio 修复的示意图。复制的字符串具有独立于应用程序正在重绘的单元格的生命周期。

该分支包含一个针对这一确切序列的回归测试:写入 hello,选中它,然后将回车和擦除行 \r\x1b[2K 送入解析器。终端的实时选择消失,而保存的字符串保持为 hello。显式清除选择会移除快照。我检查了该测试;作为本次调查的一部分,我还没有运行 Rio 测试套件。

最后一条规则很重要。能在重绘后存活的快照是有用的。能在我显式清除后存活的快照,可能会复制我本打算丢弃的内容。Command-K 和 Command-C 在同一个选中文本的生命周期中相遇。

粘贴有不同的边界。使用括号粘贴时,终端会用起始和结束序列包裹粘贴的文本,以便接收程序能区分粘贴和键入。Rio 的粘贴实现在程序启用该功能时采用这种方式;否则普通粘贴会将行尾转换为回车符。Shell 及其行编辑器也是结果的一部分。比较粘贴行为意味着要让这些参与者和它们的模式保持一致。

键入字符背后的往返过程

键入是另一件让我感觉不一样的事。这个二进制文件给了我们一条可检查的路径,但还没有给出延迟结果。

Apple Terminal 通过forkpty启动其子进程。伪终端(PTY)为 shell 提供一个终端设备,而应用程序读写另一端。一个按键事件变成字节,这些字节到达 shell,输出返回以更新屏幕。根据程序和终端设置的不同,回显来自终端驱动或程序。

Reconstructed Apple Terminal path: main-thread key encoding queues a write; the I/O thread writes to the PTY, reads returning output, and hands it to main-thread decoding, VT100 parsing, and drawing.
根据此可执行文件的反汇编重建。箭头表示交接,而非测量的时长;绘制被压缩到最终步骤。

I/O 线程使用 kqueue,处理缓冲写入,并按块读取输出。它安排将数据送回主线程。在那里,shellDidReceiveData:将数据通过编码转换器传入输出解码器。在此路径中,解析返回主线程,而不是留在 I/O 线程上。

编码层还处理读取之间不完整的字符数据。一次读取可能在 UTF-8 字符中途结束;下一个块会补全它。然后 VT100 解码器解释光标移动、擦除和其他控制序列以更新屏幕。输出既是指令流也是文本,这就是为什么同一个解析器能让选中的行消失。

高帧率或快速转储大文件并不能告诉我从按键事件到我所看到的字符的全部时间。排队的写入、shell 处理、送达主线程以及下一次绘制都在这条路径上。在声称 TabDance 更快——或某个特定的 Apple 设计选择解释了我的偏好——之前,我需要用相同的 shell、提示符、窗口大小、字体和工作负载进行受控比较。

TabDance 目前的进展

10 月 10 日的快照包含检查产物和一个小型原生原型。它使用 Swift、AppKit 和 SwiftTerm 1.19.0 进行终端仿真和渲染。它启动一个真实的登录 shell,支持选择和复制/粘贴,调整 PTY 大小,并打开多个窗口。应用包仍然叫做 TerminalLab.app。

两个本地测试通过了:一个测试真实 PTY 的输入、输出、调整大小和子进程清理;另一个检查解析器对光标定位、颜色和备用屏幕的行为。构建说明和范围在仓库中。这些检查建立了一个可用的基础。标签页、工作区以及匹配清除–打印–选择行为仍在这个原型的前方。

我开始下一次比较时,故意用一个无聊的文件:

printf 'alpha\nbeta\ngamma\n' > /tmp/tabdance-selection.txt
# Press Command-K, then run:
cat /tmp/tabdance-selection.txt
# Press Command-A, Command-C, and inspect the pasted text.

首先我想记录在 Apple Terminal 中复制的确切文本和周围换行符,使用固定的提示符和窗口大小。然后我希望 TabDance 产生相同的结果。之后是一个换行行、一个被重绘擦除的选择,以及一个多行粘贴。这些案例足够小,能让分歧变得可见。

工作区让我接触到了 cmux 和 Rio。继续使用 TabDance 的原因是在按下 Command-K 之后的那一刻,我可以不再纠结 Command-A 即将选择什么。

来源:Hacker News · mainbrella.com