Better Shell History Search

我每天花费大量时间在 Unix 终端上运行 shell 命令。 不知道为什么,人们在使用 shell 时的效率差异很大:我认识一些比我厉害得多的人,也遇到过不少专业人士甚至不使用“向上”键来检索之前的命令。

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

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

[视频]

搜索 Shell History

诸如 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 纪元以来的秒数。 我们可以使用 -t '%s'fc 为我们做这件事。 现在我们得到的是 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

然后我们可以通过除以 86,400(24h * 60m * 60s == 86,400s)将 delta 秒转换为天数。 然后是一系列简单的 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 feed;或 订阅电子邮件更新: 无法订阅:请加载页面并重试 发送...

脚注

[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 端口(即“包”)。 这不是 Atuin 的错,也不一定是阻碍因素 —— 我后来为 Skim 制作了一个 OpenBSD 端口 —— 但是当我快速试验新软件时,这是一个障碍。

[5] Atuin 的网络方面也让我感到不安。 理性的人可能会对此事有所不同。

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

[7] 我确信有一种方法可以在 awk 中做到这一点,但是我尝试过的方法似乎无法在不同 awk 版本之间移植,因此我退回到 sed