在终端里面一个非常日常的操作,就是选中上一个命令的输出内容。比如使用文本处理程序处理一些服务器的 IP,最后得到一个结果,将这个结果复制给同事,或者粘贴到工单系统中;在终端上使用 date
程序转换日期的格式,最后要将这个日期复制到别的地方去使用;比如最常见的一个操作,使用 date +%s
命令得到当前的时间戳,然后复制这个时间戳。如果你使用终端的话,基本上每天都要重复这个操作几十次。
本文就来讨论这个最简单的操作:复制上一个命令的输出结果。
虽然是一个看似很简单的操作,但是我却为了如何能在这个操作上节省几秒钟苦苦思索了多年。也发现了很多人同样在寻找一个方法来高效地执行这个操作。这篇文章将会讨论几种方法来实现这个动作,虽然最后我使用的方法并不是我发明的。发明它的人也同样花了很长的时间(按作者原话说 “Look, it’s still quarantine, okay?”),所以背后的奇技淫巧和神奇的思路也同样精彩!希望这篇文章能给读者每天节省几秒钟,也能在阅读的过程中带来一些乐趣。
笨蛋的方法
这是最显而易见的一种方法。为了复制上一个命令得到的内容,我们要将右手拿开,放到鼠标上,选中文本,然后按下 Command
+ C
(在其他的系统上是别的按键),将内容复制到剪切板里面。
也同样显而易见,这么做太浪费时间了。首先,所有需要将手从键盘上拿开的操作都是浪费时间的;其次,选中操作也不是那么简单,开头和结尾需要定位两次。使用键盘是一个 0 或 1 的操作。按下了就是按下了,没有按就是没有按,闭上眼睛也能操作。而鼠标需要精确地定位,闭上眼睛是绝对无法完成的。如果遇到要复制的命令有很长的输入(比如要复制一段 cat info.log | grep code=404 | tail
的日志输出),那么要同时使用鼠标进行定位和翻页,变成了一个超高难度的动作。
这样增加心智的东西,太反人类了。
朴素的方法
Unix 系统里面的管道真是一个伟大的发明!因为在终端里面程序的输出是一个 stdout, 所以理论上,我们就可以使用一个程序,将它的 stdout 导入到系统的剪切板里面去。比如在 Mac OS X 上面可以使用 pbcopy
将程序的输出内容导入到剪切板中,然后使用 pbpaste
粘贴出来,或者直接使用 Command
+ V
粘贴。
在其它的 Linux 系统中也可以做到类似的事情,比如 xsel 和 xclip 。其实原理非常简单,只需要调用系统提供的剪切板相关的 API,将 stdin 的内容写入到进去就好了。
类似这样使用管道的工具还有很多,比如 fpp 工具。可以自动地识别出来 stdout 中的 file path,然后提供一个 GUI 让你选择文件,按下 Enter 打开。比如 git stat | fpp
这样用。
这种方法的优点是可靠,不涉及鼠标操作。虽然也并不是特别高效,因为要敲很多字母(不过可以使用 alias)。
这类使用管道的最大的缺点就是不是所见即所得的。很多时候需要敲下命令,看到 stdout 确认没有问题,然后再敲一遍命令后面加上 | pbcopy
加到剪切板中,在遇到运行时间很长,或者需要消耗很大资源的时候,就有点不合适了。虽然可以一次性使用 tee
程序既输出到 stdout 又输出到 pipe 中,但是这样一来运行命令的心智负担又太大了。这么长的命令难以形成肌肉记忆,所以本质上来说,效率也算是特别高。
优雅的方法
另一个既简单又傻瓜的方法是使用 iTerm2 自带的功能,在 iTerm2 中选择 “Edit -> Select Output of Last Command” 即可选中上一条命令的输出,使用快捷键的话是 Command
+ Shift
+ A
.
如果你看到这个选项是灰色的,说明你没有安装 shell 集成。在菜单栏选择 “Install Shell Integration” 即可,iTerm2 会帮你执行一个 Curl xxx | bash
来安装相关的依赖。
这种方法的优点是使用足够简单,一个快捷键就够了,而且这是选中+复制,并不需要再按下 Command
+ C
。如果大部分时间使用的终端模拟器都是 iTerm2 的话,这个方法也足够了。
缺点也显而易见,这是 iTerm2 提供的功能,如果你要使用 Ubuntu,就不行了。另外,它的工作原理是,它知道你在 iTerm2 中运行的命令,所以可以捕获命令的输出信息。这样就带来一些很严重的问题,比如,如果你使用 Tmux 的,那么在 iTerm2 看来,无论你在 Tmux 里面开多少个 session 和 window, 对它来说都是一个程序,也就无法在 Tmux 里面成功捕获 stdout 了。
Tmux 可能有人不用,那还有一个场景应该无法避免,就是 ssh. 同理,你 ssh 到一台机器上去执行命令,对于 iTerm2 来说它都只看到一个 ssh 命令,所以如果这样复制的话,它会把你在 ssh 命令下看到的所有内容都复制下来。(其实上面提到的 pbcopy
同理,也无法在 ssh 远程机器上工作的。)
而要想在 ssh 下也工作,就必须不区别是在远程机器上执行的命令,还是本地执行的命令,从整个终端模拟器的 buffer 入手。使用正则匹配或许是个好的方法。
黑客的方法
由于没有一个方法能够省心省力地完成这个工作,我这几年来每天都过得郁郁寡欢。
某天在 hackernews 上看有人分享 Tmux 复制文本的操作方法,就点进去读了一下,稍微有些失望,因为这些东西我已经知道了。但是这时候网页突然载入完成了播客中的 gif,在 gif 中发现有一段竟然是在命令的 output 之间跳来挑去!这就是我苦苦寻找多年的东西!
在确认这并不是 Tmux 本身的功能之后,我发邮件问了作者是如何做到能在 Tmux 里面快速选择上一命令输出的。
没想到作者很快回复了我的邮件。
整个 idea 非常简单,使用一个脚本即可实现,只用到了 Tmux 自身的命令。核心思想是去复制当前 cursor 所在的 Shell Prompt 和上一个 Shell Prompt 之间的内容,使用 Tmux 的命令控制光标移动,选择文字。
脚本如下(现在作者有一篇博客,Quickly copy the output of the last shell command you ran ,很详细地介绍了这个脚本每一步都在干什么)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
bind -n S-M-Up { copy-mode send -X clear-selection send -X start-of-line send -X start-of-line send -X cursor-up send -X start-of-line send -X start-of-line if -F "#{m:*➜\u00A0*,#{copy_cursor_line}}" { send -X search-forward-text "➜\u00A0" send -X stop-selection send -X -N 2 cursor-right send -X begin-selection send -X end-of-line send -X end-of-line if "#{m:*➜\u00A0?*,#{copy_cursor_line}}" { send -X cursor-left } } { send -X end-of-line send -X end-of-line send -X begin-selection send -X search-backward-text "➜\u00A0" send -X end-of-line send -X end-of-line send -X cursor-right send -X stop-selection } } |
bind -n
的意思是将这个操作绑定到 root key table,默认是绑定到 Prefix table,改成绑定到 root 的话,这个操作就不涉及按下 Tmux 的 Prefix key 了。S-M-Up
是 Shift
+ Option
+ Up
这三个键一起按下的意思,即将这三个键一起按下绑定成下面这个脚本。
然后这个脚本进入 copy-mode,先控制光标到行头。之后分成两个 block,首先看 if
不满足的下面的那个 block,基本上就是向前寻找之前的一个 Shell Prompt,如果找到了,就从这里开始复制,这样,两个 Shell Prompt 之间的内容就被选中了。再来看 if
里面的内容,意思是当前行如果有 Shell Prompt 的话,就直接复制整行。这样就可以做到,依次往上选中上一个 output,上一个命令, 再上一个 output,再上一个命令,…… 缺点就是只能支持向上选择,不支持向下选择。不过其实也够用了。if
里面的那个嵌套的 if
是处理 Tmux 在 vi 的 copy-mode 下的一个 Corner case, 详细的解释可以去看原文。
这里有一个很 triky 的地方,就是如果你的 Shell Prompt 的格式里面有空格的话,比如以 $␣
来结尾,在 Tmux 的复制模式下,对于没有执行过命令的行,比如多按了几次回车,Tmux 会直接将这些行中 Shell Prompt 的空格删除,这样就造成我们的脚本无法匹配到空格。比如下面这个 Shell,复制模式下在 date
和 echo
命令中间的三行就没有空格了。
这里解决的方法是,将 Shell Prompt 最后的空格,改成 Non-breaking space, Unicode 码是 \u00A0
。(可以看到,上面的脚本匹配的其实就是这个 Unicode)。如果使用 Vim 的话,可以在输入模式下按下 Ctrl
+ V ,进入ins-special-keys mode, 然后依次输入 u 0 0 a 0,就可以输入这个 Unicode。
这样,对我来说几乎就是一个完美的方案了。如果去读作者的博客,就会发现这里面的坑实在太多了,Tmux 在 vi 的 copy mode 下的行为,去掉空格的行为,跳转行为(在行被 Wrap 的情况下必须执行两次 start-of-line
才能真正跳转到行头,等等。估计作者也是花了很多时间才写好这个脚本。
在 ssh 的情况下,理论上也可以做到,因为这个方法是针对 Tmux 显示的 buffer 进行操作。但是要改下这个脚本的匹配,因为远程的主机的 Prompt 可能和你本地电脑不一样。上面的脚本使用的 search-forward-text
,如果改成 search-forward
就可以按照正则搜索。
没有实现的方法
这个方法是我很久之前做的一个尝试,只不过到现在都还没有完成。
之前看到过这么一个项目:tmux-url-select。它能帮助你快速选择当前 Tmux 窗口中的 URL,复制或者打开。
我去看了一下代码,发现思路非常神奇。它是这么做的:
- 先 capture 下来当前窗口的全部内容;
- 打开一个新的窗口,覆盖掉了原来的窗口;
- 新的窗口其实是一个新的 GUI 程序,然后将老窗口的内容放上来,在这个程序内实现了选择、跳转、定义按键等工作;
由于用户实际上是进入了一个新的程序,但是这个新的程序通过修改 Tmux 窗口的名字,让用户还感觉自己在 Tmux 中一样。由于是一个新的程序,那它就可以不受限制的做到任何是事情了!
所以我看到这个东西,第一个想法就是将它 fork 过来,将选择 URL 改成选择上一个命令的 output. 实际上也应该是可行的。到现在没有完成的原因是…… 这个项目是 Perl 写的,Perl 看起来不像是人写的东西。
Ian (上文提到的作者)也说,思路很有趣,但是长期来看,不如花时间提升 Tmux 自身的 copy mode 收益更多。他准备提交 patch 给 Tmux.
其他的方法
在和作者 Ian 的交流中,他还告诉我其他一些他使用的工具,也非常实用。
tmux-thumbs 这个很有意思。这个就像是 vimium/vimperator 的操作模式一样,可以让你快速通过一些按键去选择当前 buffer 的文本块。
extrakto 和上面的工具类似,但是这个工具是用的 fzf 模式。可以通过模糊搜索,查找当前 buffer 出现过的文本,进行快速选中。但是好像不能复制出到剪切板。
2021年07月06日更新:
我发现在服务器上复制命令的时候,连同命令本身以及 prompt 一起复制更加实用,因为我们的 prompt 带有机器的 hostname,包括机器标签,ip 等。复制命令可以让同事知道这个 output 是怎么来的,打开的文件路径?awk?grep?都可以一眼就能看出来。所以我将这个脚本改了一下,← 是只复制 output,↑ 是向上复制 prompt、命令以及 output,↓ 也一样,只不过是向下选择。
详情见:https://twitter.com/laixintao/status/1412081667498332161
实现代码如下( commit ,可以关注下 myrc 这个仓库,我将我的所有配置文件都放在这里,从这里可以看到最新的版本):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
bind -n S-M-Up { copy-mode send -X clear-selection send -X start-of-line send -X start-of-line send -X cursor-left send -X begin-selection send -X search-backward "(\\$ )|(# )" send -X start-of-line send -X start-of-line send -X stop-selection } bind -n S-M-Down { copy-mode send -X clear-selection send -X end-of-line send -X end-of-line send -X search-forward "(\\$ )|(# )" send -X start-of-line send -X start-of-line send -X begin-selection send -X search-forward "(\\$ )|(# )" send -X search-forward "(\\$ )|(# )" send -X start-of-line send -X start-of-line send -X cursor-left send -X stop-selection } |
“由于没有一个方法能够省心省力地完成这个工作,我这几年来每天都过得郁郁寡欢” 看到这句话, 深有同感::joy::
哈哈,竟然有相同的人!