Better Shell History Search

我每天花大量时间在 Unix 终端中运行 shell 命令。 奇怪的是,不同的人在使用 shell 时的效率差异很大:我认识一些在这方面比我强很多的人,也见过不少专业人士甚至不使用“向上”键来检索之前的命令。

我特意选择了最后一个例子:我们大多数人在 shell 中运行的命令都高度重复。 我通常每个工作日运行大约 50-100 个唯一的(即语法上不同的)shell 命令 [1] —— 但我经常在一天内运行这些命令的一个小子集(例如 cargo test)数百次。

由于许多命令行工具都有难以记住的选项,如果我们能够搜索 shell 历史记录以找到以前运行过的命令,就可以节省大量时间——更不用说减少错误了。 在这篇文章中,我将展示如何通过少量努力,使搜索 shell 历史记录看起来像这样:

[视频]

搜索 shell 历史记录

像 Bash 这样的大型 Unix shell 长期以来允许用户通过按 Ctrl-r 并输入一个子字符串来搜索他们的 shell 历史记录。 如果我(按顺序)执行了命令 cat /etc/motd 然后 cat /etc/rc.conf,那么 Ctrl-r 之后输入 “cat” 将首先匹配 cat /etc/rc.conf; 再次按 Ctrl-r 将向后循环到下一个匹配项,即 cat /etc/motd。 我几乎从未使用过这个功能,因为子字符串匹配太粗糙了。 例如,我可能知道我想要的命令是 cat,我要查找的叶子名称是 motd 但我不记得目录了:子字符串匹配无法帮助我找到我正在寻找的内容。 相反,我经常使用 grep(带有通配符)来搜索我的 shell 历史记录文件。

对我来说,改变游戏规则的是将 Ctrl-rfzf 配对,这带来了两个变化。 首先,匹配是“模糊的”,所以我可以输入 “c mo”,cat /etc/motd 将被匹配。 其次,多个匹配项同时显示。 输入 “cat” 将向我展示几个 cat 命令,允许我快速选择正确的调用方式(可能不是最近的)。

我很难夸大这个功能有多么强大。 很少有事情能让我像按下 ctrl-R 然后输入 "l1" 并让一个 100 个字符的命令行执行出现在我的终端上那样高兴,这个命令行运行一个复杂的调试工具,设置了多个环境变量,其输出被放入 /tmp/l1

使用 Ctrl-r 和 fzf 大约在一夜之间使我在 shell 中的效率提高了一倍。 有趣的是,它产生了更大的长期影响:我成为一个更有雄心的 shell 命令用户,因为我知道我可以将我的记忆外包给 fzf。 例如,由于现在很容易回忆起过去的命令,我不再设置全局环境变量,这以前让我很烦恼 [2]。 现在我根据每个命令设置环境变量,我知道我可以使用 Ctrl-r 和 fzf 回忆起它们。

多年来,我最喜欢的 shell 是 zsh。 当我后来从 zsh 转移到 fish 时,Ctrl-r 和 fzf 是我配置的第一件事; 当我回到 zsh [3] 并从头开始重新配置时,Ctrl-r 和 fzf 再次是我首先让它工作的东西(紧随其后的是 autosuggestions)。 如果你从这篇文章中获得的只有 “Ctrl-r 和 fzf 对于 Unix 用户来说是一个重要的生产力提升”,那么我就做了一些有用的事情。

当然,没有工具是完美的。 几个月前,我不知怎么的偶然发现了 skim,一个类似于 fzf 的工具,开箱即用,恰好比 fzf 更适合我。 这些差异主要是次要的,使用这两个工具都不会出错。 也就是说,我发现 skim 的匹配更常更快地找到我想要的命令,我更喜欢 skim 的 UI,并且我发现更容易在随机盒子上安装 skim —— 也许是小优势,但足以让我觉得切换是值得的。

更进一步

找到 Skim 促使我快速环顾四周,看看这个领域的其他东西是否可以提高我的生产力。 我很快遇到了 Atuin,这是一种更复杂的 shell 历史记录机制:其首页上的视频显示了一个比我以前认为可能的更好的匹配 UI。

但是,我很快意识到 Atuin 不适合我,或者至少不容易适合我。 这些天我经常 ssh 进入许多不同的服务器:随着时间的推移,我已经将我的 shell 配置简化为一个 .zshrc 文件,我可以将其 scp 到一台新机器上,并立即让我高效工作。 Atuin —— 这不是批评,因为它是一个更强大的工具 —— 更难安装 [4] 和设置 [5](我也不确定 Atuin 的 ‘模糊’ 方面是否与 fzf/skim 的高度相匹配)。 也就是说,一些读者可能会发现它是一个值得研究的有用工具。

但是,我立即从 Atuin 视频中意识到的是,我希望我的模糊匹配器向我显示关于它正在匹配的命令的更多有用信息。

特别是,fzf 和 skim 默认都在我匹配的命令之前显示一个(对我而言!)毫无意义的整数:这总是让我有点困扰,但我从未想过要弄清楚它是什么意思。 例如,如果我使用 zsh + fzf + Ctrl-r 我会看到:

5408 是什么意思,为什么它占据了宝贵的屏幕空间? Skim 试图更好一些:它会显示 5408 today'21:26 [6],但这占据了更多的屏幕空间!

适配 zsh 和 fzf/skim

幸运的是,事实证明,改进 Ctrl-r 和 fzf/skim UI 很容易。 我现在看到的是以下内容,而不是在对我来说毫无意义的整数上浪费空间(其中 11d 表示 “11 天前”,依此类推):

我将展示我是如何适配 zsh 和 skim 来做到这一点的。 我猜想将此适配到其他 shell 需要很少的创造力(并且将此适配到 fzf 主要涉及将 sk 命令替换为 fzf)。

我需要做的第一件事是让 zsh 记录命令的 执行时间。 我将此添加到我的 ~/.zshrc

setopt EXTENDED_HISTORY
setopt inc_append_history_time

EXTENDED_HISTORY 更改 .zsh_history 的格式以记录命令执行的时间(以自 Unix 纪元以来的秒为单位)和(使用 inc_append_history_time)它运行的时间。 好消息是这些选项自然地迁移 “传统格式” 历史记录文件:任何非扩展历史记录命令都将被赋予当前日期,以便所有 .zsh_history 都采用相同的格式。

然后我需要了解当我按下 Ctrl-r 时,zsh 的历史记录是如何被询问和显示的。 fzf 和 skim 在这里共享几乎完全相同的代码:我将使用 skim 的 zsh 键绑定 作为我的例子。 本质上,这两个工具都定义了一个函数 history-widget,然后将它们绑定到 Ctrl-r

history-widget() { ... }
zle   -N  history-widget
bindkey '^R' history-widget

你可以通过将上面的代码放在你导入它们正常键绑定之后的 ~/.zshrc 中来覆盖 fzf 和 skim 提供的版本。

让我们看看 skim 的 history-widget

skim-history-widget() {
local selected num
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases 2> /dev/null
local awk_filter='{ cmd=$0; sub(/^\s*[0-9]+\**\s+/, "", cmd); if (!seen[cmd]++) print $0 }' # filter out duplicates
local n=2 fc_opts=''
if [[ -o extended_history ]]; then
local today=$(date +%Y-%m-%d)
# For today's commands, replace date ($2) with "today", otherwise remove time ($3).
# And filter out duplicates.
awk_filter='{
   if ($2 == "'$today'") sub($2 " ", "today'\''")
   else sub($3, "")
   line=$0; $1=""; $2=""; $3=""
   if (!seen[$0]++) print line
  }'
fc_opts='-i'
n=3
fi
selected=( $(fc -rl $fc_opts 1 | awk "$awk_filter" |
SKIM_DEFAULT_OPTIONS="--height ${SKIM_TMUX_HEIGHT:-40%} $SKIM_DEFAULT_OPTIONS -n$n..,.. --bind=ctrl-r:toggle-sort $SKIM_CTRL_R_OPTS --query=${(qqq)LBUFFER} --no-multi" $(__skimcmd)) )
...

首先要注意的是 —— 感谢 EXTENDED_HISTORY —— 在我的上下文中,-o extended_history 检查总是返回 true,所以 if 的主体总是被执行。

然后我们可以跳到前面:fc -rli 1 使 zsh 以比直接通过 .zsh_history 更容易消化的形式输出其历史记录:

$ fc -rli 1
  4 2025-02-07 15:05 pizauth status
  3 2025-02-07 15:03 cargo run --release server
  2 2025-02-07 15:03 email quick
  1 2025-02-07 14:59 rsync_cmd bencher16 ./build.sh cargo test nested_tracing

我们现在还可以看到之前那些神奇的整数是什么:它们是来自 fc 的行号,其中 1 是我的 ~/.zsh_history 中最旧的命令! 这些在某些情况下用作标识符,因为你可以要求 zsh “返回我命令 5408”。

awk 代码流式传输此输出,将今天的日期替换为文字字符串 today,删除前几天的 hours/minutes 输出,并删除重复项。

虽然很容易错过,但在代码片段的最后一行是 -n$n..,..,它告诉 skim 要模糊匹配和打印哪些以空格分隔的列。

在这一点上,我们现在需要决定如何根据我们的目的来调整事物。 我们需要对 fc 的输出做的第一件事是将时间转换为自 Unix 纪元以来的秒数。 我们可以让 fc 使用 -t '%s' 为我们做到这一点。 我们现在得到 1742595052,而不是输出 2025-03-21 22:10。 请注意,现在两个字段已合并为一个! 因为 fc 向行号添加了前导空格,所以我们将通过将 fc 的输出通过管道传递到 sed -E "s/^ *//" 来删除它 [7]

然后我需要决定如何格式化 “命令在过去运行了多长时间”。 经过几次尝试,我决定一个好方法是为过去 20 小时的命令提供绝对 hour:minute 时间,以及为过去 1 天或更长时间的命令提供 1d2d(等等)。 为什么是 20 小时? 好吧,事实证明,如果我在 08:00 开始工作,按下 Ctrl-r 并看到一个 08:01 的条目,我不会意识到那是 昨天 的 08:01(今天的 08:01 只有 60 秒后!)。 20 小时解决了这个歧义:这意味着,在 08:00,昨天下午的命令显示为 16:33,但昨天早上的命令显示为 1d

我们现在需要切换到 awk。 我承认我最初对使用 awk 感到犹豫,这是一种我以前从未用过的语言。 在意识到代码为什么使用 awk 之前,我很快探索了替代方案:每台 Unix 机器都安装了 awk。 对于那些不熟悉 awk 的人来说,我们正在编写的程序会迭代输入中的每一行,按空格分隔该行,并将分隔的字段放入变量 $1$2(等等)中。 我们将保留上面 awk 代码中的重复检测,但更改其余大部分代码。

我们需要在 awk 中做的第一件事是将命令的 Unix 纪元时间(在字段/变量 $2 中)转换为整数,并使用 systime 计算它过去多少秒(它返回相对于 Unix 纪元的当前时间):

ts = int($2)
delta = systime() - ts

然后,我们可以通过将 delta 秒除以 86,400(24h * 60m * 60s == 86,400s)将其转换为天数。 然后是一系列简单的 if/else 来很好地格式化它,记住:

  1. 20h == 72,000s
  2. awk 中的字符串连接和 int-to-string 转换是隐式的

转换代码如下所示:

delta_days = int(delta / 86400)
if (delta_days < 1 && delta < 72000) { $2=strftime("%H:%M", ts) }
else if (delta_days == 0) { $2="1d" }
else { $2=delta_days "d" }

可以选择进一步细分,或许显示超过一周的命令,带有 “1w” 等等:我还没有发现这值得担心。

然而,有一个小小的不足之处:时钟偏差。 这可能会导致命令看起来在未来执行。 我还没有在实践中看到这种情况发生,但与计算机和时钟的痛苦经历告诉我它会在某个时候发生。 我已经防御性地处理了这将给我造成的不可避免的混乱,方法是对这种情况使用 + 前缀:

delta_days = int(delta / 86400)
if (delta < 0) { $2="+" (-delta_days) "d" }
else ...

请注意,我必须将 (-delta_days) 放在括号中,否则 —— 因为我太懒了无法调查的原因 —— awk 不会以我想要的方式连接整数和字符串。

由于我们比以前少了一个字段,我们可以稍微简化我们的输出:

line=$0; $1=""; $2=""
if (!seen[$0]++) print line

这就是 awk 代码的完成。 然后我们需要对 selected=... 行做一个更改,将 -n$n..,.. 更改为 --with-nth $n..。 这告诉 fzf 和 skim 抑制行号的输出,也不使其成为模糊匹配的一部分。

将所有这些放在一起,更新后的 history-widget 代码块现在如下所示(你可以在 这里 找到整个代码块):

local n=1 fc_opts=''
if [[ -o extended_history ]]; then
awk_filter='
{
 ts = int($2)
 delta = systime() - ts
 delta_days = int(delta / 86400)
 if (delta < 0) { $2="+" (-delta_days) "d" }
 else if (delta_days < 1 && delta < 72000) { $2=strftime("%H:%M", ts) }
 else if (delta_days == 0) { $2="1d" }
 else { $2=delta_days "d" }
 line=$0; $1=""; $2=""
 if (!seen[$0]++) print line
}'
fc_opts='-i'
n=2
fi
selected=( $(fc -rl $fc_opts -t '%s' 1 | sed -E "s/^ *//" | awk "$awk_filter" |
SKIM_DEFAULT_OPTIONS="--height ${SKIM_TMUX_HEIGHT:-40%} $SKIM_DEFAULT_OPTIONS --with-nth $n.. --bind=ctrl-r:toggle-sort $SKIM_CTRL_R_OPTS --query=${(qqq)LBUFFER} --no-multi" $(__skimcmd)) )

这个简单的改变足以让我在按下 Ctrl-r 并开始输入时给出这个输出: [视频]

总结

我已经使用上述更改大约 6 周了,我发现它有意义地提高了生产力。 事实证明,我经常记住我想要回忆的命令的足够信息,以至于看到匹配是否在过去的 “1d” 或 “7d” 就足以立即排除它,而无需向右扫描。 偶尔我甚至会搜索时间增量本身:如果我以 “2d” 开始匹配,fzf 或 skim 自然会搜索 2 天前的命令。

但是,或许,从这篇文章中可以得到一个更大的观点。 如果像我一样,你一生中有很多时间都在 Unix 终端中度过,那么很容易陷入 1970 年代 shell 用户可以识别的使用模式。 我们不仅可以做得更好,而且很容易做到,并且生产力的提高可能是巨大的!

致谢:感谢 Edd Barrett 的评论。 2025-03-25 11:50 Older 如果你想获得新博客文章的更新:在 MastodonTwitter 上关注我; 或 订阅 RSS 提要; 或 订阅电子邮件更新: 订阅失败:请加载页面并重试 发送中...

脚注

[1] 可以使用 fc 命令来找出这一点,但在处理日期格式时存在一些跨平台烦恼。 我使用此 Python 代码的变体来快速分析我的 shell 历史记录:

from datetime import datetime
days = {}
for l in open(".zsh_history"):
    if len(l.split(":")) < 3: continue
    _, t, cmd = [x.strip() for x in l.split(":", 2)]
    d = datetime.fromtimestamp(int(t)).strftime("%Y-%m-%d")
    cmd = cmd.split(";")[1]
    cmds = days[d] = days.get(d, set())
    cmds.add(cmd)
print(len(days["2025-03-19"]))

[2] 事实上,当其他人在 shell 中出现 “怪异” 行为时,我首先要求他们做的是检查已设置了哪些全局环境变量。

[3] 我喜欢 fish 的开箱即用行为,但出于两个原因离开了它。 首先,与 POSIX shell 的差异并不是明显的改进,但是编写 “正常” 的 shell 脚本然后需要我的大脑切换模式。 其次,fish 默认使某些设置(如路径调整)全局且永久。 我鼓励很多人使用 fish:每个人(毫不夸张)都最终犯了我犯过的相同安全隐患错误。 因此,我发现自己不再能够向他人推荐它,在那时,我想知道我为什么要自己使用它。 生活就是如此。

[4] 我必须承认,Atuin 安装脚本 的长度是我遇到的第一个意外。 Atuin 目前也没有 OpenBSD 端口(即 “package”)。 这不是 Atuin 的错,也不一定是无法接受的 —— 我后来为 Skim 制作了一个 OpenBSD 端口 —— 但当我快速试验新软件时,这是一个障碍。

[5] Atuin 的网络方面也让我感到不安。 理性的人可能对此类问题意见不一。

[6] 不,我不确定为什么那里只有 ' 标记。 这最终可能是我使用的 awk 实现的一个怪癖。

[7] 我确信有一种方法可以在 awk 中做到这一点,但我尝试的东西似乎在不同的 awk 版本中不具有可移植性,所以我退回到 sed