Atuin: 终端历史配置指北

Atuin: 终端历史配置指北

Terminal history, done left.

前言:为什么需要终端历史软件

首先简述下目前的终端配置,oh-my-zsh + starship (https://starship.rs),GUI 是 KDE Plasma 自带的 Konsole。这套实际上用着还挺方便,但唯一的问题是比较神秘的历史记录。

何谓神秘呢?具体而言是一些很久以前的命令会随机丢失(将 HISTSIZEHISTSIZE 调整后也无法避免,经观察并非截断因为遗忘的命令之前的命令依旧存在于历史文件中),以及有时会随机往命令里插入一些神秘字符。此外,默认的命令搜索(Ctrl+R)是全文精确匹配,实为一种不足(这一点可以使用 fzf 相关插件解决,但不在本文讨论范畴内)。

上面可能都是一些潜在的 issues,但还有一些个人希望终端历史拥有的新特性。例如,可以记录命令的上下文(执行目录、次数、机器等)并据此检索,以及作为强迫症,有时会希望防止打错的命令污染 history,以及。。算了我编不下去了。主要就是 zsh 随机忘命令太折磨人了。

嗯对总之下面掌声欢迎我们的 Rust 精致小软件 - Atuin 登场!注:这里后期补充下 BGM

这嘛

别管这的那的总之是 Rust 写的用就完了

呃 Atuin 大概就是用 SQLite 把终端命令历史记录实现了,然后加了我上面说的上下文什么的,支持多种搜索和对应的 TUI。然后他们还 feature 一个跨机器加密同步方案但我觉得现实中有高达零点作用。

安装

我们的 Atuin 在 Arch 上归属于这个 Extra Repo 啊,相当高贵。Arch 用户直接 sudo pacman -S atuin 就起飞了。其他用户自己看 官方指南 吧。

在紧张刺激的安装过后呢我们需要一点配置(如果是用官方脚本装的可以略去这一步)。以 zsh 为例需要

1
echo 'eval "$(atuin init zsh)"' >> ~/.zshrc

然后重启 shell 就 ok。什么导入历史啊注册同步账号啊还是看上面官方指南吧。

使用

如果只介绍上面的部分想必是没有必要整一篇 blog 出来。下面还有一些可能遇到的问题以及一些自定义的部分。

为什么按方向上键会整出一坨 TUI

这个设计个人感觉是有点脑瘫在里面,项目也在 atuin#798 有讨论,几十条评论也是美美的 open 哈。我用了脚本改成和 zsh 默认的方向上键类似的行为,如果你觉得用得惯也行。

首先需要把初始化的 eval "$(atuin init zsh)" 改成 eval "$(atuin init zsh --disable-up-arrow)",禁用默认的方向上键行为。然后需要一段神秘小脚本用 Atuin 的 API 模拟出 zsh 的行为。需要把下面这玩意丢进 ~/.oh-my-zsh/custom/atuin-history-arrow.zsh 里面。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/usr/bin/env zsh
##############################################################################
#
# Copyright (c) 2023 Sophie Tyalie
# Copyright (c) 2023 @Nezteb
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# * Neither the name of the FIZSH nor the names of its contributors
# may be used to endorse or promote products derived from this
# software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
##############################################################################

#----------------------------------
# main
#----------------------------------

# global configuration
: ${ATUIN_HISTORY_SEARCH_FILTER_MODE='global'}

# internal variables
typeset -g -i _atuin_history_match_index
typeset -g _atuin_history_search_result
typeset -g _atuin_history_search_query
typeset -g _atuin_history_refresh_display
typeset -g _atuin_local_last_cmd="" # 用于缓存本 Session 最后一条命令

# Load hook functionality
autoload -Uz add-zsh-hook

# 定义 Hook:在命令执行前,强行缓存该命令
_atuin_save_local_history() {
_atuin_local_last_cmd="$1"
}
add-zsh-hook preexec _atuin_save_local_history

atuin-history-up() {
_atuin-history-search-begin

# iteratively use the next mechanism to process up if the previous didn't succeed
_atuin-history-up-buffer ||
_atuin-history-up-search

_atuin-history-search-end
}

atuin-history-down() {
_atuin-history-search-begin

# iteratively use the next mechanism to process down if the previous didn't succeed
_atuin-history-down-buffer ||
_atuin-history-down-search # ||
# zle atuin-search

_atuin-history-search-end
}

zle -N atuin-history-up
zle -N atuin-history-down

bindkey '\eOA' atuin-history-up
bindkey '\eOB' atuin-history-down

#-----------END main---------------

#----------------------------------
# implementation details
#----------------------------------

_atuin-history-search-begin() {
# assume we will not render anything
_atuin_history_refresh_display=

# If the buffer is the same as the previously displayed history substring
# search result, then just keep stepping through the match list. Otherwise
# start a new search.
if [[ -n $BUFFER && $BUFFER == ${_atuin_history_search_result:-} ]]; then
return;
fi

# Clear the previous result.
_atuin_history_search_result=''

# setup our search query
if [[ -z $BUFFER ]]; then
_atuin_history_search_query=
else
_atuin_history_search_query="$BUFFER"
fi

# reset search index
_atuin_history_match_index=0
}

_atuin-history-search-end() {
# if our index is <= 0 just print the query we started with
if [[ $_atuin_history_match_index -le 0 ]]; then
_atuin_history_search_result="$_atuin_history_search_query"
fi

# draw buffer if requested
if [[ $_atuin_history_refresh_display -eq 1 ]]; then
BUFFER="$_atuin_history_search_result"
CURSOR="${#BUFFER}"
fi

# for debug purposes only
#zle -R "mn: "$_atuin_history_match_index" / qr: $_atuin_history_search_result"
#read -k -t 1 && zle -U $REPLY

}

_atuin-history-up-buffer() {
# attribution to zsh-history-substring-search
#
# Check if the UP arrow was pressed to move the cursor within a multi-line
# buffer. This amounts to three tests:
#
# 1. $#buflines -gt 1.
#
# 2. $CURSOR -ne $#BUFFER.
#
# 3. Check if we are on the first line of the current multi-line buffer.
# If so, pressing UP would amount to leaving the multi-line buffer.
#
# We check this by adding an extra "x" to $LBUFFER, which makes
# sure that xlbuflines is always equal to the number of lines
# until $CURSOR (including the line with the cursor on it).
#
local buflines XLBUFFER xlbuflines
buflines=(${(f)BUFFER})
XLBUFFER=$LBUFFER"x"
xlbuflines=(${(f)XLBUFFER})

if [[ $#buflines -gt 1 && $CURSOR -ne $#BUFFER && $#xlbuflines -ne 1 ]]; then
zle up-line-or-history
return 0
fi

return 1
}

_atuin-history-down-buffer() {
# attribution to zsh-history-substring-search
#
# Check if the DOWN arrow was pressed to move the cursor within a multi-line
# buffer. This amounts to three tests:
#
# 1. $#buflines -gt 1.
#
# 2. $CURSOR -ne $#BUFFER.
#
# 3. Check if we are on the last line of the current multi-line buffer.
# If so, pressing DOWN would amount to leaving the multi-line buffer.
#
# We check this by adding an extra "x" to $RBUFFER, which makes
# sure that xrbuflines is always equal to the number of lines
# from $CURSOR (including the line with the cursor on it).
#
local buflines XRBUFFER xrbuflines
buflines=(${(f)BUFFER})
XRBUFFER="x"$RBUFFER
xrbuflines=(${(f)XRBUFFER})

if [[ $#buflines -gt 1 && $CURSOR -ne $#BUFFER && $#xrbuflines -ne 1 ]]; then
zle down-line-or-history
return 0
fi

return 1
}

# 辅助函数:判断本地缓存的命令是否有效(匹配当前的搜索前缀)
_atuin_is_local_cmd_valid() {
[[ -n "$_atuin_local_last_cmd" && "$_atuin_local_last_cmd" == "${_atuin_history_search_query}"* ]]
}

_atuin-history-up-search() {
_atuin_history_match_index+=1

# 1. 尝试使用本地缓存的“上一条命令”(解决空格开头不记录的问题)
if [[ $_atuin_history_match_index -eq 1 ]]; then
if _atuin_is_local_cmd_valid; then
_atuin_history_refresh_display=1
_atuin_history_search_result="$_atuin_local_last_cmd"
return 0
fi
# 如果本地命令无效(不匹配前缀)或为空,index 1 实际上应该对应 Atuin 的 offset 0
# 代码将继续向下执行,逻辑在计算 offset 时处理
fi

# 2. 计算 Atuin 的 offset
local offset
if _atuin_is_local_cmd_valid; then
# 如果显示了本地命令,那么 Atuin 的搜索应该从 Index 2 开始(Offset 0)
offset=$((_atuin_history_match_index - 2))
else
# 如果没有本地命令,Atuin 从 Index 1 开始(Offset 0)
offset=$((_atuin_history_match_index - 1))
fi

search_result=$(_atuin-history-do-search $offset "$_atuin_history_search_query")

# 3. 简单的去重处理
# 如果 Atuin 返回的结果和我们刚刚展示的本地命令完全一样,说明该命令也被记录在 DB 里了。
# 为了不让用户按两次上看到同样的内容,我们跳过这个结果,自动取下一条。
if [[ -n "$_atuin_local_last_cmd" && "$search_result" == "$_atuin_local_last_cmd" ]]; then
offset=$((offset + 1))
# 增加全局 index 以保持同步
_atuin_history_match_index+=1
search_result=$(_atuin-history-do-search $offset "$_atuin_history_search_query")
fi

if [[ -z $search_result ]]; then
# if search result is empty, there's no more history
# so just show the previous result
_atuin_history_match_index+=-1
# 如果是因为刚跳过了重复项导致为空,这里需要回退两步吗?
# 简单处理:只要为空,就停止移动
return 1
fi

_atuin_history_refresh_display=1
_atuin_history_search_result="$search_result"
return 0
}

_atuin-history-down-search() {
# we can't go below 0
if [[ $_atuin_history_match_index -le 0 ]]; then
return 1
fi

_atuin_history_refresh_display=1
_atuin_history_match_index+=-1

# 1. 如果回退到了 index 1,且本地命令有效,则显示本地命令
if [[ $_atuin_history_match_index -eq 1 ]]; then
if _atuin_is_local_cmd_valid; then
_atuin_history_search_result="$_atuin_local_last_cmd"
return 0
fi
fi

# 2. 否则,从 Atuin 搜索
local offset
if _atuin_is_local_cmd_valid; then
offset=$((_atuin_history_match_index - 2))
else
offset=$((_atuin_history_match_index - 1))
fi

# 如果 offset < 0,说明我们回到了 index 0 或 1(且无本地缓存)的状态
# 但由于 index 0 在 search-end 会处理(显示原始 query),这里只需要处理需要查库的情况
if [[ $offset -lt 0 ]]; then
# 这种情况通常是 index=0,search_result 在 _atuin-history-search-end 会被覆盖为 query
# 但如果在 index=1 且无 local cmd,我们需要查 offset 0
if [[ $_atuin_history_match_index -eq 1 ]]; then
offset=0
else
return 0
fi
fi

search_result=$(_atuin-history-do-search $offset "$_atuin_history_search_query")

# 去重逻辑同 Up
if [[ -n "$_atuin_local_last_cmd" && "$search_result" == "$_atuin_local_last_cmd" ]]; then
# 在 Down 的时候,如果遇到重复,说明我们正从更老的历史往下翻
# 实际上这里的逻辑比较复杂,为了简化体验,我们直接显示即可,
# 或者为了严格对应 Up 的行为,我们不应该在这里处理跳过,因为用户是往下翻。
# 保持原样即可。
:
fi

_atuin_history_search_result="$search_result"

return 0
}

_atuin-history-do-search() {
local offset=$1
local query="$2"

# 防御性编程:offset 小于 0 不执行
if [[ $offset -lt 0 ]]; then return; fi

local mode="prefix"
local final_query="$query"

# 【核心修改】:检测大写字母以启用 Smart Case
# 如果包含 A-Z,则切换到 fuzzy 模式,并转义正则字符,最后加 ^ 锚定行首
if [[ "$query" =~ [A-Z] ]]; then
mode="fuzzy"

# 开启 extendedglob 以支持 (#m) 替换语法
setopt localoptions extendedglob

# 1. 先转义反斜杠,避免后续转义产生双重转义
local escaped="${query//\\/\\\\}"

# 2. 转义常见的正则/Fuzzy特殊字符: ^ $ . * + ? ( ) [ ] { } |
# (#m) 表示在替换中使用匹配到的字符 $MATCH
escaped="${escaped//(#m)[\^\$\.\*\+\?\(\)\[\]\{\}\|]/\\$MATCH}"

# 3. 添加行首锚点,模拟 Prefix 行为
final_query="^$escaped"
fi

atuin search --filter-mode "$ATUIN_HISTORY_SEARCH_FILTER_MODE" --search-mode "$mode" \
--limit 1 --offset $offset --cmd-only \
"$final_query"
}

#------END implementation----------

这个代码来自原帖下面的 gist,然后我用 Gemini 辅助改了下,主要包括:

  1. 大小写敏感:神秘的 Atuin 在前缀匹配搜索模式(也就是方向上键用的模式)下会突然变得大小写不敏感,因此把前缀查询改成了对应的 fuzzy 查询,并只在检测到命令中有大写字母时启用。
  2. Session 级别的上一条指令:默认的行为,在每个 session 按方向上键会从全局 history 读取上一条,在许多场景下还是不好用。同时如果命令前面加了空格(即不计入历史),那么按上键根本不会出来这条指令。这和 zsh 默认的行为还是有点出入。因此改了下脚本使其对每个 session(终端会话)维护了一条 last command,空白状态下按方向上键会优先用这个。

如何利用上下文

最直接的:在 Ctrl+R 打开搜索界面后,继续按 Ctrl+R 可以切换过滤模式,包括同机器、同文件夹、同 session 等。当然这还是略显麻烦,可以用下面的配置把 PageUp 绑定到 搜索同文件夹下执行的命令。配置文件在 ~/.config/atuin/config.toml

1
filter_mode_shell_up_key_binding = "directory"

然后再用下面的命令绑定 PageUp 键(.zshrc):

1
bindkey '^[[5~' atuin-up-search

回车执行命令

默认情况在搜索界面按回车会显示这条命令但不执行,可以加下面的配置来更改这一行为(~/.config/atuin/config.toml):

1
enter_accept = true

搜索界面简化

隐藏搜索界面的相关提示。

1
show_help = false

没了。

Atuin: 终端历史配置指北

https://mivik.moe/2026/note/atuin-config/

作者

Mivik

发布于

2026-02-17

更新于

2026-02-17

许可协议

评论