herdr worktree 弹窗 Esc 失效排查与自救
事件时间:2026-07-01 herdr 版本:v0.6.8(Rust,static-pie ELF,
/home/zbc/.local/bin/herdr) 受影响仓库:/home/zbc/pangu/GeneralAndroid关联调研 → herdr-终端AI Agent多路复用器深度调研 类型:复盘案例(case)
TL;DR(太长不看)
- 现象:herdr 执行创建 worktree 时弹出
new worktree确认框(底部提示create and open/esc cancel),按 Esc 完全无反应,Ctrl+C/Ctrl+D在弹窗里直接变成字母c/d,前端 UI 卡死。 - 根因:系统输入法为 fcitx(
GTK_IM_MODULE=fcitx/QT_IM_MODULE=fcitx/XMODIFIERS=@im=fcitx)。fcitx 在激活态会拦截 Esc 与部分 Ctrl 组合键(用于清候选/状态切换),不透传给 herdr 的 TUI 弹窗组件,导致”按键像消失了一样”。终端与 Ctrl 键本身正常。 - 解法:另开终端用 PID 精确
kill卡死进程树 → 清理半成品 worktree → 下次进 herdr 弹窗前先切英文输入法。 - 一句话经验:TUI 程序”快捷键失灵但终端里
Ctrl+C正常”,先查输入法拦截,不要先怀疑程序本身。
一、问题现象
执行 herdr 创建 worktree,弹出确认弹窗,UI 内容如下:
new worktree
branch
_ ← 文本输入框(光标在此)
checkout
/home/zbc/.herdr/worktrees/GeneralAndroid/worktree creating.
create and open esc cancel ← 两个操作按钮
弹窗自己声明了 esc cancel(Esc = 取消)。但实际表现:
| 按键 | 预期 | 实际 |
|---|---|---|
Esc | 关闭弹窗 | 完全没动静,弹窗不消失,输入框也不出现乱码 |
Ctrl+C | 中断/取消 | 直接在输入框打出字母 c |
Ctrl+D | EOF/取消 | 直接在输入框打出字母 d |
前端 UI 完全卡死,无法退出。但(事后排查发现)herdr 后台 server 仍在继续创建 worktree,并已 fork 出多个下载子进程——这是 herdr 作为 tmux 式多路复用器的架构特性(见根因 §三)。
二、排查过程与根因证据
2.1 第一步:证明终端和 Ctrl 键本身没问题
在普通 shell 里执行:
zbc@zbc-OptiPlex-7080:~$ cat # 回车进入 cat
^C # 按 Ctrl+C,显示 ^C 并退出 cat
zbc@zbc-OptiPlex-7080:~$ # 回到提示符结论:Ctrl+C 在终端层正常产生 ^C(SIGINT),说明:
- 物理按键和 Ctrl 修饰键工作正常;
- 终端模拟器正确把 Ctrl 编码后发给前台程序;
- 问题不在终端,只在 herdr 的弹窗里。
2.2 第二步:定位到输入法层(决定性证据)
检查输入法环境:
$ echo "GTK=$GTK_IM_MODULE QT=$QT_IM_MODULE XMOD=$XMODIFIERS"
GTK=fcitx QT=fcitx XMOD=@im=fcitx
$ ls /usr/bin/ | grep -iE 'ibus|fcitx|sogou'
fcitx
fcitx4-config
fcitx-autostart
fcitx-config-gtk3
fcitx-configtool系统使用 fcitx 输入法框架。这与现象完全吻合:
| 现象 | fcitx 行为解释 |
|---|---|
Esc 完全没动静(无乱码) | fcitx 在激活态(有候选词 / 中文输入进行中)会消费 Esc,用于”清空候选词 / 回到英文状态”,不透传给下层 TUI。herdr 根本收不到按键事件,所以连乱码都没有。 |
Ctrl+C 变字母 c | fcitx 拦截/改写了 Ctrl 组合键的处理,只把字面字符 c 透传进 herdr 的文本输入框。 |
主对话界面 Ctrl+C 能用 | herdr 主输入框与弹窗输入框是不同组件;且焦点/输入法状态在弹窗打开瞬间可能不同,导致拦截表现差异。 |
置信度说明:这是”高置信度最可能根因”。严格意义上没有做”切到英文输入法后 Esc 是否恢复”的对照实验(因为当时优先解决卡死)。验证方法见 §四 4.1。fcitx 拦截 Esc 是其设计行为,并非 bug;herdr 作为 TUI 程序对此无能为力。
2.3 第三步:摸清 herdr 在卡死期间到底干了什么
卡死期间用 pgrep -fa 查看 herdr 进程树,发现前端 UI 虽然卡死,后台却在”静默干活”:
PID 进程
12100 /home/zbc/.local/bin/herdr server ← 后台守护进程(tmux 式 server)
19463 herdr ← 前端 TUI(卡住的弹窗就是它)
19282 git -C /home/zbc/pangu/GeneralAndroid worktree add -b worktree/green-river-87c3 \
/home/zbc/.herdr/worktrees/GeneralAndroid/worktree-green-river-87c3 HEAD
20373 python3 .../download_assets.py --all ← 拉取项目资源
20418 mc cp uploadrw/.../AGENT使用分享.webm ┐
20453 mc cp uploadrw/.../Franklin1 GPU内存统计.webm │ 4 个 minio client
20484 mc cp uploadrw/.../系统UI内存泄露报告.webm │ 正在下载大文件
23033 mc cp uploadrw/.../libfrida-core.a ┘
关键认知:herdr 是 Rust 写的 tmux 式多路复用器(详见 herdr-终端AI Agent多路复用器深度调研),采用 server + client 架构:
- 前端
herdr(PID 19463)只负责渲染 UI(弹窗)和接收键盘事件; - 后台
herdr server(PID 12100)才是真正执行 worktree 创建、调度下载的实体; - 二者解耦——所以前端 UI 被输入法卡死,后台 server 仍按既定流程继续
git worktree add+ 拉资源。
这导致一个隐蔽副作用:用户以为”弹窗还在等确认,什么都没发生”,实际上 worktree 已经在被创建、大文件已经在被下载。直接拔终端会在仓库里留下半成品 worktree + 残留分支 + 不完整资源。
三、本次实际执行的解决步骤
3.1 杀掉卡死的进程树(用 PID 精确 kill)
kill 12100 19463 19282 20373 20418 20453 20484 23033⚠️ 为什么不用
pkill -f herdr? 这是本次最重要的一个坑:
pkill -f匹配的是完整命令行,凡是命令行里含字符串herdr的进程都会被杀;- worktree 目录路径是
/home/zbc/.herdr/worktrees/...,路径里就含herdr,于是git worktree add、python download_assets.py、4 个mc cp这些子进程的命令行全部含.herdr,都会被一并命中——这倒正好是我们想要的;- 但更危险的是:你正在终端里执行的那条
pkill -f herdr命令本身、以及包裹它的bash -c '...herdr...'shell,命令行也含herdr,pkill会杀到自己的执行 shell,命令可能被中途打断,行为不可预期。因此优先用 PID 列表
kill,最精确、无副作用。PID 可通过pgrep -fa herdr或ps -ef | grep herdr提前拿到。
确认全部退出:
$ ps -p 12100,19463,19282,20373,20418,20453,20484,23033 -o pid,stat,cmd --no-headers
(无输出 = 这些 PID 均已不存在)3.2 清理半成品 worktree
杀进程后,仓库里留下了卡死过程的副产品:
$ git -C /home/zbc/pangu/GeneralAndroid worktree list
/home/zbc/pangu/GeneralAndroid cec17583 [master]
/home/zbc/.herdr/worktrees/GeneralAndroid/worktree-green-river-87c3 cec17583 [worktree/green-river-87c3] ← 残留
$ git -C /home/zbc/pangu/GeneralAndroid branch --list 'worktree/*'
+ worktree/green-river-87c3 ← 残留分支按顺序清理(必须先移除 worktree,否则被 checkout 占用的分支删不掉):
# 1. 移除 worktree 注册(--force 是因为目录里有未跟踪的下载文件)
git -C /home/zbc/pangu/GeneralAndroid worktree remove --force \
/home/zbc/.herdr/worktrees/GeneralAndroid/worktree-green-river-87c3
# 2. 删除残留分支
git -C /home/zbc/pangu/GeneralAndroid branch -D worktree/green-river-87c33.3 验证仓库恢复干净
$ git -C /home/zbc/pangu/GeneralAndroid worktree list
/home/zbc/pangu/GeneralAndroid cec17583 [master] ← 只剩主仓库,干净
$ git -C /home/zbc/pangu/GeneralAndroid branch --list 'worktree/*'
(空) ← 无残留分支四、后续遇到同样问题的解决办法
4.1 预防(优先级最高)
进入 herdr 任何弹窗之前,先切到英文输入法:
- fcitx 默认切换键:
Ctrl+Space(中/英切换)或Shift(临时切英文); - 或直接在 fcitx 配置里关掉对终端类程序的接管(见 §五 5.3);
- 习惯:用 TUI 程序(herdr / Claude Code / lazygit / helix …)一律先确认输入法是英文。
验证 fcitx 假设(如果你想确认根因,而不是直接动手):
- 切到英文输入法;
- 重新触发 herdr worktree 弹窗;
- 按 Esc——若能正常关闭,则 fcitx 拦截假设成立。
4.2 自救(已经卡住时)
按”破坏性从小到大”排序:
方案 A —— 另开终端,精确杀 herdr(推荐)
# 只杀 herdr 可执行,命令行精确匹配,绝不误伤
pkill -f '/home/zbc/.local/bin/herdr'注意:这条命令匹配的是
/home/zbc/.local/bin/herdr(server 进程的完整路径),不会匹配到那些命令行里只是路径含.herdr/worktrees/...的 git/python/mc 子进程,也不会匹配到自己正在执行的 shell(因为 shell 命令行里是pkill -f '/home/zbc/.local/bin/herdr'……等等,这句命令行本身也含这个字符串)。更稳妥的写法是先查再按 PID 杀:
pgrep -af '/home/zbc/.local/bin/herdr' # 拿到 PID kill <PID> # 精确杀
方案 B —— Ctrl + \(SIGQUIT)
命令行里既然 Ctrl 生效,在 herdr 弹窗里试 Ctrl+\(反斜杠),SIGQUIT 比 SIGINT 更强,常能把 TUI 程序直接打断退出。(本次未实测,因为优先用了方案 A。)
方案 C —— Ctrl+Z 挂起 + kill
# 卡住的终端里:
Ctrl+Z # SIGTSTP 挂起 herdr,回到 shell
kill %1 # 杀掉后台 job方案 D —— 暴力
直接关闭终端窗口。100% 能出来,但可能留下半成品 worktree(需按 §4.3 清理)。
4.3 清理半成品 worktree(通用)
每次强杀 herdr 后,都要检查并清理仓库里的半成品:
REPO=/home/zbc/pangu/GeneralAndroid
# 1. 检查残留
git -C $REPO worktree list
git -C $REPO branch --list 'worktree/*'
ls /home/zbc/.herdr/worktrees/GeneralAndroid/
# 2. 移除所有残留 worktree(路径从上面 worktree list 里取)
git -C $REPO worktree remove --force <残留worktree路径>
# 3. 如果 worktree 目录已经被强删但 git 还登记着,用 prune
git -C $REPO worktree prune
# 4. 删除残留分支
git -C $REPO branch -D worktree/<名字>五、经验总结
5.1 TUI”快捷键失灵”的诊断顺序
遇到 TUI 程序(herdr / Claude Code / lazygit / vim / helix …)某个快捷键不响应,按这个顺序排查,避免误判:
- 输入法(最常被忽略,也最常见):
echo $GTK_IM_MODULE $QT_IM_MODULE $XMODIFIERS;切英文再试。 - 终端 Ctrl 键:
cat+Ctrl+C,看是否产生^C。能 = 终端正常;打出字母 = 终端/Ctrl 键码问题。 - tmux/screen 前缀键冲突:是否在多路复用器里,前缀键吃掉了组合键。
- 终端模拟器键绑定:某些终端把
Ctrl+C/Ctrl+V绑成复制粘贴,或把 Esc 当 Alt 前缀。 - 最后才怀疑程序本身:前 4 项都正常,再去查程序的键映射配置或提 issue。
本次一开始误判为”Claude Code 弹窗”(用户最初的 “herdr” 被当成拼写错误),浪费了几轮。教训:用户消息里的陌生词先当专有名词查证(路径
/home/zbc/.herdr/一秒钟就证伪了误判)。
5.2 pkill -f 的三个陷阱
pkill -f <pattern> 匹配完整命令行,由此带来三个坑:
- 路径误伤:pattern 若是某目录名片段,所有命令行里带该路径的进程都会被杀(本次的
.herdr路径)。 - 自杀:你正在执行的
pkill -f xxx命令、以及它的bash -c外壳,命令行里就含xxx,可能杀到自己导致命令中断。 - 子进程连带:父进程被杀时,命令行含 pattern 的子进程也会被一起命中。
结论:在交互式排障场景,优先用 PID kill;必须用 pkill 时,pattern 尽量写完整可执行路径(如 /home/zbc/.local/bin/herdr)而非裸名。
5.3 让 fcitx 不再干扰终端程序
根治思路(任选其一,按需采用):
- 临时:用终端前
Ctrl+Space切英文。 - 按程序豁免:fcitx 配置工具里,把终端模拟器(gnome-terminal / konsole / alacritty 等)加入”不使用输入法”或设置默认英文。
- 环境变量级:在终端模拟器的启动环境里
export GTK_IM_MODULE= QT_IM_MODULE= XMODIFIERS=(仅对终端禁用输入法,影响最小)。可写入~/.config/alacritty/alacritty.toml的 env 段,或包一层 wrapper 脚本。 - 换 fcitx5 + 调整 Esc 行为:fcitx5 的”在程序内禁用”和 Esc 透传策略更可控。
5.4 herdr 架构认知(来自本次实战)
- herdr = server(守护进程) + client(前端 TUI) 的 tmux 式多路复用器;
- 前端卡死 ≠ 整体卡死:server 会继续执行已下发的任务(本次是 worktree 创建 + 资源下载);
- 强杀前端后务必检查
git worktree list和~/.herdr/worktrees/,清理半成品; - 详细架构见 herdr-终端AI Agent多路复用器深度调研。
附录 A:环境信息快照
| 项目 | 值 |
|---|---|
| 机器 | zbc-OptiPlex-7080 |
| herdr 版本 | v0.6.8 |
| herdr 二进制 | /home/zbc/.local/bin/herdr(13MB,static-pie ELF,Rust) |
| 输入法 | fcitx(GTK_IM_MODULE=fcitx / QT_IM_MODULE=fcitx / XMODIFIERS=@im=fcitx) |
| 受影响仓库 | /home/zbc/pangu/GeneralAndroid |
| worktree 根目录 | /home/zbc/.herdr/worktrees/ |
| 卡死 worktree | worktree-green-river-87c3 / 分支 worktree/green-river-87c3(已清理) |
附录 B:一键自救脚本
把以下内容存为 ~/bin/herdr-kill-and-clean.sh,下次卡住直接跑(路径按需改):
#!/usr/bin/env bash
# 用途:杀掉卡死的 herdr 进程树,并清理 GeneralAndroid 的半成品 worktree
set -u
REPO=/home/zbc/pangu/GeneralAndroid
WT_ROOT=/home/zbc/.herdr/worktrees/GeneralAndroid
echo "==> 1. 杀掉 herdr 进程树"
PIDS=$(pgrep -f '/home/zbc/.local/bin/herdr')
[ -n "$PIDS" ] && kill $PIDS && echo "已 kill: $PIDS" || echo "无 herdr 进程"
echo "==> 2. 清理半成品 worktree"
for wt in $(git -C "$REPO" worktree list --porcelain | awk '/^worktree / && $2 != "'"$REPO"'"{print $2}'); do
git -C "$REPO" worktree remove --force "$wt" && echo "移除 worktree: $wt"
done
git -C "$REPO" worktree prune
echo "==> 3. 删除残留 worktree 分支"
for b in $(git -C "$REPO" branch --list 'worktree/*' | sed 's/[*+ -]//g'); do
git -C "$REPO" branch -D "$b" && echo "删除分支: $b"
done
echo "==> 完成。当前 worktree 列表:"
git -C "$REPO" worktree list作者复盘记录 · 2026-07-01 · 关联 herdr-终端AI Agent多路复用器深度调研