Skip to main content

N 种方式复制上一条命令的输出

·550 words·3 mins
Table of Contents

在写技术 Blog 的时候,经常会遇到粘贴一段命令及其输出的情况。对于粘贴上一条命令有很简单的方法,读取 history 文件就好了,比如下面这条 alias

alias '@cpc'='echo -n $(fc -l -1 | awk "{\$1=\"\";print substr(\$0,2)}") | xclip -select clipboard'

对于命令的输出,因为默认没有写入文件。想要复制的时候总是处于事后的状态,本文分享几种方式去简化这一操作

The first Way #

重复执行上一条命令然后将输出通过管道发送到剪贴板,比如

$ !! | xclip -select clipboard

!! 是指代上一条命令,shell 会自动展开,同样的还有 !-2, !-3 分别指代向上第 N 条命令。

缺点:

  1. 需要重复执行命令,对于没有幂等或者有 side effect 的情况下,同一条命令多次执行输出可能都不一样

The second way #

第二种方式使用 PROMPT_COMMAND,这个是 Bash 独有的。对于 Zsh 的用户,可以通过 precmd 这个 hook 来做这件事情

precmd() { eval "$PROMPT_COMMAND" }

Bash 会在显示 PS1 变量之前执行 PROMPT_COMMAND,比如

[kumiko@misaka ~]$ echo $PS1
[\u@\h \W]\$
[kumiko@misaka ~]$ export PROMPT_COMMAND="date +%H:%M:%S"
13:01:50
[kumiko@misaka ~]$ echo 'hello'
hello
13:01:53

我们可以看到时间被显示在每次输出 PS1 中的内容 [kumiko@misaka ~]$ 之前(此处不能理解为每次命令执行后输出时间)。利用这个 hook 我们可以 Hack 掉 File Descriptors,代码如下

export PROMPT_COMMAND='LAST="`cat /tmp/cmd_output`"; exec 1>/dev/tty; exec 1> >(tee /tmp/cmd_output)'

这条命令由三部分组成,可能对于 shell 不熟悉的同学比较奇怪 exec >/dev/tty 是什么意思。因为大多数情况下,exec 的使用场景是替换当前进程空间,执行一条命令。这里是 exec 的另一个用法,打开指定的 FD 进行读写。比如

$ echo 'hahah' > a.txt

$ exec 0< a.txt; cat  # 0< 要连在一起,否则会判断 0 是一条命令字符串
hahah

对于一个进程的 FD ,默认情况下是

  • 0: STDIN - 标准输入
  • 1: STDOUT - 标准输出
  • 2: STDERR - 标准错误输出

当我们没有参数执行 cat 的时候,STDIN 实际上是打开的 /dev/tty,所以会从当前终端读取数据,然后进行回显。但是前面的 exec 0< a.txt 将默认的 0 号 FD 的指向替换成了一个读取 a.txtFD,就相当于我们平常使用的重定向。知道了这点,上面的命令便不难理解了。LAST 变量保存的是一个执行过程,对应着 /tmp/cmd_output 文件的内容。exec 1>/dev/tty 将标准输出定向到 /dev/tty ,所以我们可以在终端看到命令的输出,exec 1> >(tee /tmp/cmd_output) 相当于 dupFD,使得标准输出还会在 /tmp/cmd_output 中写入一份

然后我们的 $LAST 就是上一条命令的输出了,效果如下

如果需要同时支持 STDOUTSTDERR ,可以使用下面命令,原理相同

export PROMPT_COMMAND='LAST="`cat /tmp/cmd_output`"; exec 1>/dev/tty; exec 1> >(tee /tmp/cmd_output); exec 2>/dev/tty; exec 2> >(tee /tmp/cmd_output);'

缺点:

  1. 如果你有多个 Shell 进程同时执行命令,会出现单文件同时写入的情况。这里需要每个 Shell 分配一个独立文件名称,比较麻烦

The third way #

个人目前使用的方式。我们先来观察一下终端中,执行命令后的输出有什么特点。我这里是 starship 和 Zsh 的组合,其他的可以照抄然后自己改动一下

两个 PS1 之间就是我们的命令 + 输出部分。命令正常执行成功后显示的是绿色的 ,如果上一次命令的 ExitCode 不为 0,那么显示的是红色的 。此符号在 starship 中可以进行配置,参考文档 https://starship.rs/config/#character

  • success_symbol: The format string used before the text input if the previous command succeeded.
  • error_symbol: The format string used before the text input if the previous command failed.

这里顺便提一下 starship 的原理,其本质是 Hack 掉PROMPT 变量,更改为执行 starship 的二进制文件,并将 Shell 中的上下文作为参数传入,比如 STARSHIP_CMD_STATUS 就是上一条命令的 ExitCode。通过一番组合后输出 PS1,参考 starship.zsh#L95

根据以上信息我们可以将 success_symbolerror_symbol 作为锚点,然后模拟文本选择。下面有两种方式

  • 对于支持快捷键选区的 Terminal,比如 Alacritty。可以通过 xdotool 来发送按键指令,通过脚本替代人工选择
  • 对于 Tmux 下,我们可以编写 Tmux Script 来做

这里我选择更加通用的 Tmux 来进行说明:

第一步,为了让我们可以移动光标进行区块选择,我们要先进 copy 模式

copy-mode

然后根据清除当前的已有的选区,这步是避免环境不干净,确保在一个当前没有任何选区的情况下执行命令

send -X clear-selection

下面我们开始移动光标,先移动到行首,因为我们当前有可能已经输入了文字

send -X start-of-line
send -X start-of-line

需要注意,这里需要执行两次,因为有逻辑行和物理行的概念。比如我的宽度只有16个字符,如果我输入了 17 个字符那么会产生折行,但是逻辑上我没有换行的。执行两次我们能够到达真正的行首。之后我们需要向上移动光标

send -X cursor-up
send -X cursor-up
send -X cursor-up

因为默认的 starship 配置有一项 add_newline = True 导致每次命令执行后会换行一次,所以我们着力需要执行 3 次才能到达上一条命令输出的最后一行。目前光标还在最后一行输出的行首,我们需要移动到行尾,同样移动两次

send -X end-of-line
send -X end-of-line

开始进行选区

send -X begin-selection

我们向前搜索,找到上一条命令的开始处。这里不是大于符号,是一个 unicode 字符,可以直接复制过来

send -X search-backward-text "❯"

我们向下一行就是输出结果的开始处,这里是移动到行尾然后向右一个光标就自动到下一行的行首了

send -X end-of-line
send -X end-of-line
send -X cursor-right

结束选区,并复制

send -X stop-selection
send -X copy-selection-and-cancel

上面还有点小瑕疵,如果我的命令输出里面包含了 怎么办,选区就不完整了。我目前采用的方法使用零宽字符来当作锚点。关于零宽字符这个以前有写过 文章进行介绍。比如 \u200b 。需要配置一下 starship

[character]
success_symbol = "[❯](bold green)\u200b"
error_symbol = "[❯](bold red)\u200b"
vicmd_symbol = "[❯](bold green)\u200b"

然后将查找条件改为

send -X search-backward-te "❯\u200b"

以上完整代码如下,根据自己的 Prompt 做修改后放到 tmux.conf 种就可以了

bind -n <your key binding> {
    copy-mode
    send -X clear-selection
    send -X start-of-line
    send -X start-of-line
    send -X cursor-up
    send -X cursor-up
    send -X cursor-up
    send -X end-of-line
    send -X end-of-line
    send -X begin-selection
    send -X search-backward-text "❯\u200b"
    send -X end-of-line
    send -X end-of-line
    send -X cursor-right
    send -X stop-selection
    send -X copy-selection-and-cancel
}

上述脚本也可以通过 shell 的函数执行

alias '@cpo'='tmux_copy_cmd_output >> /dev/null 2>&1'

tmux_copy_cmd_output() {
    tmux copy-mode
    tmux send -X clear-selection
    tmux send -X start-of-line
    tmux send -X start-of-line
    tmux send -X cursor-up
    tmux send -X cursor-up
    tmux send -X cursor-up  
    tmux send -X cursor-up  # 这里多一次,因为现在我们执行的是一条命令了,需要多敲一个 Enter
    tmux send -X end-of-line
    tmux send -X end-of-line
    tmux send -X begin-selection
    tmux send -X search-backward-te "❯\u200b"
    tmux send -X end-of-line
    tmux send -X end-of-line
    tmux send -X cursor-right
    tmux send -X stop-selection
    tmux send -X copy-selection-and-cancel
}

最终效果如下:

缺点:

  • 对其他组件具有入侵性,依赖 Tmux 或者 Alacritty 之类的 Terminal
  • 有 Buffer 限制,比如你复制一个 seq 1 10000 的输出就失败了

Reference #