SLOPSHOPPER

footer

Puts git status and usage meters on the prompt hint row, replacing the status line

newbandspinnerguardprocesstimer
v0.1.0no licenseupdated 2026-10-02NihilDigit/cc-dot-files/mods/footer
A shopper browsing a rack in a slop shop
README

cc-dot-files

Claude Code 的配置:全局 CLAUDE.md、两个 mod(替代 statusline 的 footer、Windows 上的完成通知),以及一套硬门控。

硬门控是两个 PreToolUse hook 加一个 PATH 替身,把四条约束变成 Claude Code 绕不过去的机制:改文件前必有备份、删除一律进回收站、破坏性 git 命令执行前必有快照、全局安装一律拒绝。这些规则由 hook 强制执行,不依赖模型自觉,也不依赖 CLAUDE.md 里写没写。

四条规则

改文件前留备份。guard-edit.py 在 Edit、Write、NotebookEdit 前检查目标是否被 git 跟踪。已跟踪的直接放行,出错可用 git diff 恢复;未跟踪的先复制一份 <文件名>.<时间戳>.bak。

仓库内的备份集中放在仓库根的 .claude-bak/,按相对路径存放,并写入 .git/info/exclude,不出现在 git status 中。不放在原文件旁:Android 的 res/ 由 aapt2 逐个编译,多一个 .bak 即构建失败,集中存放就不必维护一份构建敏感目录的名单。写 info/exclude 而非 .gitignore,不改动仓库本身。仓库外的文件没有构建与 git 的问题,备份仍放在原文件旁。

内容与最新备份一致时跳过,同一秒内重名时向后编号。软链会先解析到真实路径再判断,因此软链进 dotfiles 仓库的配置文件不会重复留备份。

删除转回收站。shim/rm 前置在 PATH 中,把 rm 转为 trash-put,脚本和 Makefile 里的 rm 一并覆盖。rm -f 对不存在路径静默成功的语义被保留,否则安装脚本会失败。三种绕过替身的写法由 guard-shell.py 拒绝:

  • 绝对路径调用,如 /bin/rm
  • sudo rm,sudo 使用 secure_path,不解析用户 PATH
  • find -delete,删除在 find 进程内完成,不经过外部命令

PowerShell 工具走另一套策略,见下节。

git 破坏性命令前快照。工作区存在未提交改动时,reset、checkout、restore、clean、rebase、merge、switch、cherry-pick、revert、am 执行前先生成快照:已跟踪的改动由 git stash create 写入 refs/claude-autobak/<时间戳>,未跟踪文件打包至 ~/.claude/backups/git-autobak/。stash drop|clear|pop 前将现有 stash 记录到独立 ref。

用 git stash create 而非 git stash push:前者只生成一个提交对象,不修改工作区和索引,失败时也不会改变现场。代价是它不含未跟踪文件,因此未跟踪文件单独打包,git clean 针对的正是这部分。

拒绝全局安装。npm/pnpm 带 -g、yarn global add、pip install、python -m pip install 由 guard-shell.py 拒绝,提示改用 deno x/uvx 一次性执行,或 npm add/uv add/pixi add 装到项目本地。全局安装落在机器级目录,不随项目走,版本也无法在仓库里声明。

逃生口是 CLAUDE_ALLOW_GLOBAL_INSTALL=1 前缀,与删除的逃生口相互独立。

PowerShell 工具:只能拒绝,不能转投

Claude Code 在 Windows 上除 Bash 工具外还有一个 PowerShell 工具,同一个 hook 经 Bash|PowerShell matcher 覆盖两者,但删除策略必须不同。

替身在 PowerShell 里没有介入的机会。rm、del、ri、rd、erase 都是 Remove-Item 的内建别名,而 PowerShell 的名字解析顺序是别名 → 函数 → cmdlet → 外部命令,PATH 排在最后。函数覆盖那条路也走不通:Claude Code 以 -NoProfile 启动 pwsh,profile 里定义的 rm 函数根本不加载。

因此 PowerShell 侧一律拒绝文件删除,提示改用 trash。这要求本机有一个 PowerShell 能解析的 trash —— 无扩展名的 shell 脚本不行,PowerShell 会报 Cannot run a document,所以 install.sh 另装一个 trash.ps1 入口,见下文「Windows(Git Bash)」一节。

三处 PowerShell 特有的判定:

  • Remove-Item Alias:rm、Remove-Item Env:FOO 删的是别名和环境变量,不是文件,按 provider 前缀放行。
  • [IO.File]::Delete(...)、$item.Delete() 是表达式而非命令,命令词的判定不适用,只能按文本匹配。带 SendToRecycleBin 的调用是回收站删除,放行。
  • -EncodedCommand 的参数是 UTF-16LE 的 base64,会解码后再解析,否则等于留一个明面上的绕过口。

pwsh -Command "..."、Invoke-Expression、双引号里的 $(...) 都会递归展开。PowerShell 里的 bash -c 解析到的是 WSL 的 bash,其中的命令按 POSIX 策略判定 —— WSL 侧装着同一套替身,普通 rm 在那边仍有兜底。

rm 的判定不用正则

rm -rf build/ 删的是本机文件,adb shell rm -rf /data/x 删的是设备文件,正则匹配 \brm\b 两条都会命中。

shellcmds.py 先做词法切分,再判断 rm 位于命令词还是参数位置,只有命令词代表本机执行。因此下列命令不受影响:

adb shell rm -rf /data/local/tmp     # 命令词是 adb
ssh box rm -rf /srv                  # 命令词是 ssh
docker exec c1 rm -rf /app           # 命令词是 docker
echo 'rm -rf /'                      # 命令词是 echo
grep -r rm .                         # 命令词是 grep

而下列命令仍被识别为本机删除:

sudo -u alice rm -rf /opt/x          # 跳过 sudo 及其选项参数
timeout 5 rm x                       # 跳过 timeout 及其时长参数
cat f | xargs -I{} rm {}             # xargs 后的词是新的命令词
sh -c 'cd /tmp && rm -rf junk'       # -c 的参数递归展开

sh -c 的参数会递归展开,因为那段字符串在本机执行;adb、ssh、docker exec 的参数不展开。

安装

依赖 Python 3.10 以上、git;Linux 另需 trash-cli,Windows 另需 PowerShell 7(pwsh)。

sh install.sh

脚本把文件软链到 ~/.claude/ 下的对应位置,不覆盖已存在的文件,并在 PATH 中安装替身入口(见下节)。

settings.json 只合并 hooks 与 env.CLAUDE_CODE_PLUGIN_DIRS,其余原样保留:同一个文件里这两项是跨机器共享的,model、tui 之类是本机口味,整文件同步会把后者一起冲掉。合并前先备份成 settings.json.<时间戳>.bak。模板里的 ~/.claude 在合并时展开成实际路径 —— hook command 由哪个 shell 执行没有保证,波浪号能否展开不可依赖。

替身入口放在哪

替身必须落在 Claude Code 继承到的 PATH 中、且排在真正的 rm 之前的目录里。有两种看起来可行、实际无效的做法:

写进 shell rc 无效。 Claude Code 的 Bash 工具起的是非交互非登录 shell($- 为 hBc,shopt login_shell 为 off),bash 在这种模式下既不读 .bashrc 也不读 .profile。

设 env.BASH_ENV 也无效。 bash 对非交互 shell 确实会读 $BASH_ENV,但 Claude Code 起 shell 后会 source 自己的 shell snapshot,其中有一行 export PATH=...,内容取自进程环境,会把 BASH_ENV 阶段前置的路径整体覆盖。

install.sh 因此从 PATH 中挑一个排在真 rm 之前、当前可写的目录,在那里装一个转发到 ~/.claude/shim/rm 的入口脚本。Git Bash 下通常是 ~/bin(它被自动前置到 PATH 首位);Linux 下 /usr/local/bin 排在 /usr/bin 之前但需要 root,此时脚本会打印出对应的 sudo 命令,由你执行。

这一步不能省。失效过程没有任何征兆:guard-shell.py 只拦 /bin/rm、sudo rm、find -delete 三种绕过写法,普通的 rm -rf x 交由替身接管,因此被有意放行。替身不在 PATH 中时,这条命令解析到真正的 rm,删除不可恢复,且不报错。

安装后需实测确认,不能只检查配置文件。在 Claude Code 中执行 command -v rm,结果应为入口脚本的路径;若为 /usr/bin/rm,说明未生效。

Windows(Git Bash)

Claude Code 在 Windows 上把 Bash 工具跑在 Git Bash 里,与 Linux 原生环境有三处差异,已在本仓库内处理:

ln -s 在 MSYS 未设 winsymlinks 时静默拷贝而非建链。install.sh 会检出这一点并明确报告;此时 ~/.claude/ 下是副本,改规则要改仓库再重跑,否则两端无声分叉。开启开发者模式后可设 MSYS=winsymlinks:nativestrict 得到真软链。

Git for Windows 默认 core.autocrlf=true,会把 rm.sh 检出成 CRLF,shebang 变为 #!/bin/sh\r,替身无法执行。仓库根目录的 .gitattributes 强制以 LF 检出。

Git Bash 下没有 trash-put。rm.sh 会退而使用 trash,由本仓库的 trash.ps1 实现:经 SHFileOperation 把字面路径送进回收站,删不掉的路径报错并以非零退出。两个 shell 各需一个入口:Git Bash 不执行 .ps1,PowerShell 不执行无扩展名脚本,所以 install.sh 在替身入口所在目录写 trash 与 trash.ps1 两个转发,都指向 ~/.claude/shim/trash.ps1。

不用 npm 的 trash-cli:它先把参数交给 globby 做通配匹配,再只删匹配到的结果,而 globby 把反斜杠当转义符,C:\... 形式的路径一个也匹配不上,结果什么都没删、退出码仍为 0。$LOCALAPPDATA 一类环境变量展开出来正是这种路径,删除失败看起来与成功一样。

trash.ps1 不用 Microsoft.VisualBasic 的 FileSystem.DeleteFile:它出错时(文件被占用)仍弹对话框,无人值守的调用会卡住。SHFileOperation 带 FOF_NOERRORUI 时错误变成返回码;代价是不能同时要「超出回收站容量时警告」,那也是一个对话框,所以这类项会被直接永久删除。互操作代码首次运行时编译,缓存在 %LOCALAPPDATA%\cc-dot-files\,之后每次调用约 0.9 秒,主要是 pwsh 的启动时间。

/usr/bin/rm、GNU tar 的 --null -T -、Python 3.10+ 在 Git Bash 中均可用,无需适配。

footer

mods/footer 是一个 Claude Code mod,把原先 statusline 的内容并进 prompt 下方那一行,整个 footer 只占一行:

⏵⏵ auto mode on · C:/Codes/cc-dot-files · Opus           ✓   ctx ██░░░░ 331k/1M   5h/7d ▀▀▀▀▀▀ 2% 5h / 90% 2d

左侧是 Claude Code 自己的权限模式 pill,后接工作目录与模型系列名。宽度不足时目录按 fish 的 prompt_pwd 缩写,中间各级只留首字符。

右侧依次是 git 状态、上下文与配额。git 状态只列非零项,工作区干净时显示一个勾;分支名只在不处于 main、master 时显示。上下文条按窗口占比填充,后接已用与总量。5h 与 7d 两个配额窗口叠在同一格高度里,上半格是 5h,下半格是 7d,右侧按同样顺序写用量与距重置的时间。达到 75%(上下文)或 60%(配额)变黄,90% 或 85% 变红。

两种情况会在 prompt 上方右端出提醒:上下文超过 512k token,提示收尾并 compact;距上次回复超过一小时,提示 prompt cache 已失效,数字为下一条消息需重新缓存的 token 数。mod 读不到缓存的真实状态,后者按 1 小时 TTL 推算,进入 overage 后 TTL 降为 5 分钟,提示会偏晚。提醒只在需要时出现,因为 prompt 上方的区域与输入框之间还隔着一行 Claude Code 自带的空白,常驻会让 footer 多占两行。

图标来自 Nerd Font,配色按 One Dark 取值,换浅色主题时要改 register.ts 里的 HEX。

notify

Claude Code 自带的桌面通知只在 Ghostty、Kitty、iTerm2 里发。mods/notify 在 Windows 上补一条 toast:任务完成或停在权限确认、且人不在终端前时弹出,触发时机沿用 Claude Code 自己的 Notification 事件。

notify 图标

通知来源显示为 Claude Code。正文按通知类型换成一句口吻轻松的话,Claude Code 的原文放在下一行,例如:

Psst… may I? Need your OK to keep going
Claude needs your permission to use Bash

点击通知不会做任何事。来源 ID 是 notify.ps1 在 HKCU\Software\Classes\AppUserModelId\ClaudeCode.Notify 下自行登记的,只带名字与图标,背后没有可启动的程序;借用 Windows Terminal 的 ID 会在点击时新开一个终端窗口。其他平台上此 mod 不做任何事。

mod 的加载

install.sh 把 mods/ 下的每个 mod 写进 settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS,效果等同每次启动都带 --plugin-dir,改动保存后在运行中的会话里热重载。变量直接指向本仓库,不经 ~/.claude 中转:Git Bash 下 ln -s 对目录会静默拷贝,拷贝出的目录此后不随仓库更新。

文件对应

本仓库本机路径
guard-edit.py~/.claude/hooks/guard-edit.py
guard-shell.py~/.claude/hooks/guard-shell.py
shellcmds.py~/.claude/hooks/lib/shellcmds.py
pwshcmds.py~/.claude/hooks/lib/pwshcmds.py
rm.sh~/.claude/shim/rm
CLAUDE.md~/.claude/CLAUDE.md
mods/footer/、mods/notify/不复制,由 settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS 直接指向
settings.hooks.json合并进 ~/.claude/settings.json 的 hooks 键

CLAUDE.md 末尾导入 @LOCAL.md,本机特定的环境信息放在那里,不进本仓库。

逃生口

CLAUDE_ALLOW_RM=1 前缀同时被 shim 和 hook 认可,执行不可恢复的删除:

CLAUDE_ALLOW_RM=1 rm -rf node_modules

已知边界

拦不到在进程内部完成的删除:Python 或 Perl 脚本里的 unlink、> file 截断、mv 覆盖。这三类没有可供判定的命令词。

heredoc 正文会被当作命令解析,正文中出现 /bin/rm 一类写法会触发误判。拆成多条命令即可绕开。

Source 1 files
hooks/register.ts 284 lines
1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3// 上下文与配额的警戒线、危险线,单位为百分比
4const WARN_CTX = 75, CRIT_CTX = 90
5const WARN_QUOTA = 60, CRIT_QUOTA = 85
6
7// 常驻的快捷键提示,? 里都查得到,占掉的宽度留给状态段;
8// 「Press Ctrl-C again to exit」这类一次性提示不在此列,照常显示
9const NOISE = ['(shift+tab to cycle)', '← for agents', 'esc to interrupt']
10
11type Git = {
12  head: string
13  ahead: number, behind: number
14  staged: number, modified: number, untracked: number, conflicted: number
15} | null
16
17const DEFAULT_BRANCHES = ['main', 'master']
18
19// Nerd Font 图标,写成转义:私有区字符在多数编辑器里显示为空白,直接写看不出是哪个
20const ICON = {
21  branch: '\ue725',
22  ahead: '\uf062',
23  behind: '\uf063',
24  staged: '\uf067',
25  modified: '\uf044',
26  untracked: '\uf128',
27  conflicted: '\uf071',
28  clean: '\uf00c',
29  compact: '\uf066',
30  cold: '\uf253',
31}
32
33const GAP = 2
34const SEP = ' · '
35const CTX_CELLS = 6
36const QUOTA_CELLS = 6
37// 按 One Dark 取色,与终端配色一致;换浅色主题时轨道色需要跟着换
38const HEX = {
39  normal: '#7f848e',
40  yellow: '#e5c07b',
41  red: '#e06c75',
42  green: '#98c379',
43  cyan: '#56b6c2',
44  magenta: '#c678dd',
45  track: '#3e4451',
46} as const
47// 最长的 pill 约为「⏵⏵ accept edits on」加图标余量
48const PILL_RESERVE = 24
49// band 右上角是 engine 的折叠按钮 [-],不让开会压住最后几个字符
50const COLLAPSE_RESERVE = 4
51// 上下文超过这么多 token 就提醒 compact
52const COMPACT_AT = 512_000
53// 主线程的 prompt cache TTL。mod 读不到缓存是否还热,只能按最后一次回复的时间推算;
54// 进入 overage 后 TTL 降为 5 分钟,这时提示会偏晚
55const CACHE_TTL_MS = 60 * 60 * 1000
56
57let path = ''
58let git: Git = null
59// 本模块加载以来最后一次回复结束的时刻;模块重载后归零,到下一次回复前不提示冷缓存
60let lastReplyAt = 0
61
62function stripNoise(hint: string): string {
63  return hint
64    .split(' · ')
65    .filter(part => !NOISE.includes(part.trim()))
66    .join('  ')
67}
68
69// 家目录记作 ~,分隔符统一为 /
70function tildePath(cwd: string, home: string | undefined): string {
71  const p = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
72  const h = home?.replace(/\\/g, '/').replace(/\/+$/, '')
73  if (h && (p === h || p.toLowerCase().startsWith(h.toLowerCase() + '/'))) return '~' + p.slice(h.length)
74  return p
75}
76
77// 宽度不够时的窄版本,同 fish 的 prompt_pwd:中间各级取首字符(隐藏目录保留点),首级与末级写全
78function abbreviate(p: string): string {
79  const parts = p.split('/')
80  return parts
81    .map((seg, i) => i === parts.length - 1 || i === 0 ? seg : seg.startsWith('.') ? seg.slice(0, 2) : seg.slice(0, 1))
82    .join('/')
83}
84
85// 只显示系列名;认不出的 id 原样显示
86const FAMILIES = ['Opus', 'Fable', 'Sonnet', 'Haiku']
87function modelLabel(id: string): string {
88  return FAMILIES.find(f => id.toLowerCase().includes(f.toLowerCase())) ?? id
89}
90
91// 300k、1.2M
92function tokens(count: number): string {
93  if (count >= 1_000_000) return `${+(count / 1_000_000).toFixed(1)}M`
94  return `${Math.round(count / 1000)}k`
95}
96
97// 未到警戒线时返回 undefined,调用方据此画暗色
98function tone(pct: number, warn: number, crit: number): string | undefined {
99  if (pct >= crit) return HEX.red
100  if (pct >= warn) return HEX.yellow
101  return undefined
102}
103
104// 距重置还有多久,只取最大的一级单位并四舍五入:2d、5h、45m
105function countdown(iso: string, now: number): string {
106  const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
107  if (minutes >= 1440) return `${Math.round(minutes / 1440)}d`
108  if (minutes >= 60) return `${Math.round(minutes / 60)}h`
109  return `${minutes}m`
110}
111
112// porcelain v2:1/2 开头是普通与重命名条目,XY 两位分别是暂存区与工作区;u 是冲突;? 是未跟踪
113function parseGit(stdout: string): Git {
114  let head: string | null = null, oid: string | null = null
115  const state = { ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0, conflicted: 0 }
116  for (const line of stdout.split('\n')) {
117    if (line.startsWith('# branch.head ')) head = line.slice(14).trim()
118    else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
119    else if (line.startsWith('# branch.ab ')) {
120      const m = /\+(\d+) -(\d+)/.exec(line)
121      if (m) { state.ahead = Number(m[1]); state.behind = Number(m[2]) }
122    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
123      if (line[2] !== '.') state.staged++
124      if (line[3] !== '.') state.modified++
125    } else if (line.startsWith('u ')) state.conflicted++
126    else if (line.startsWith('? ')) state.untracked++
127  }
128  if (head === '(detached)') head = oid && oid !== '(initial)' ? oid.slice(0, 7) : null
129  return head ? { head, ...state } : null
130}
131
132// git 在事件里跑,不在 ui.render 里跑:render 随 props 变化频繁触发,一次 git 要几十毫秒
133async function refreshGit($: EngineInterface) {
134  try {
135    const { exitCode, stdout } = await $.process.run(
136      ['git', 'status', '--porcelain=v2', '--branch', '--untracked-files=normal'], { timeoutMs: 2000 })
137    git = exitCode === 0 ? parseGit(stdout) : null
138  } catch {
139    git = null
140  }
141  $.ui.invalidate('ui.render')
142}
143
144export const register: Register = on => {
145  on('session.start', async ($, e, next) => {
146    path = tildePath(e.cwd, (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')))
147    await refreshGit($)
148    // 用量在每次 API 响应后变化,props 不变时 engine 不会自己重画;usage() 不发请求,定时重画开销可忽略。
149    // 配额倒计时也靠它走动
150    $.clock.every(3000, () => $.ui.invalidate('ui.render'))
151    return next(e)
152  })
153
154  on('tool.call', async ($, e, next) => {
155    const result = await next(e)
156    if (e.tool === 'Bash' || e.tool === 'PowerShell' || e.tool === 'Edit' || e.tool === 'Write') void refreshGit($)
157    return result
158  })
159
160  on('turn.complete', async ($, e, next) => {
161    lastReplyAt = Date.now()
162    void refreshGit($)
163    return next(e)
164  })
165
166  // 只在有提醒时出现:band 与 prompt 之间隔着一行 engine 的上边距,常驻会让 footer 多出两行
167  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
169    const { Box, Text } = $.ui.resolve(e)
170    const { context } = await $.session.usage()
171    const alerts = []
172
173    if (context.tokens !== undefined && context.tokens >= COMPACT_AT) {
174      alerts.push(Text({ color: HEX.yellow, children: [`${ICON.compact} ${tokens(context.tokens)}  wrap up and compact`] }))
175    }
176    // 闲置中且已过 TTL:下一条消息要把整段上下文重新写入缓存
177    const isCold = lastReplyAt > 0 && !e.props.isWorking && Date.now() - lastReplyAt > CACHE_TTL_MS
178    if (isCold && context.tokens) {
179      alerts.push(Text({ color: HEX.cyan, children: [`${ICON.cold} ${tokens(context.tokens)}  cache expired`] }))
180    }
181
182    if (!alerts.length) return next(e)
183    return Box({ flexDirection: 'row', justifyContent: 'flex-end', columnGap: 3,
184      width: e.props.bodyColumns - COLLAPSE_RESERVE, children: alerts })
185  })
186
187  // prompt 下方只改 engine 这一行的 hint,不自己画树。
188  // 自绘 pill 读不到实时权限模式:shift+tab 不经 config.set,transcript 与 statusLine 输入也要等下一条消息才更新。
189  // 把 engine 节点放进 Box 与自绘段并排也不行:它会把 pill 后的「·」折到第二行,
190  // 而它的祖先 Box 不许设 width、minWidth、height,撑不开也裁不掉。分支与用量因此画在同一行右端的 SessionMode
191  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
192    if (e.surface !== 'terminal') return next(e)
193
194    // pill 的宽度读不到,按最长的模式名预留;放不下时路径换缩写。
195    // 路径并入 hint 后 pill 后的分隔符「·」后面有了内容,不至于悬空
196    const rest = stripNoise(e.props.hint)
197    const room = (e.viewport?.columns ?? 120) - PILL_RESERVE - (rest ? rest.length + GAP : 0)
198    const model = modelLabel(await $.session.model())
199    const shown = path.length + SEP.length + model.length <= room ? path : abbreviate(path)
200    // 与 pill 后 engine 自带的分隔符同一个字符
201    const hint = [rest, shown, model].filter(Boolean).join(SEP)
202    return next({ ...e, props: { ...e.props, hint } })
203  })
204
205  // prompt 下方那一行的右端。不用 prompt 上方的 band:band 与 prompt 之间隔着 engine 给 prompt 的一行上边距,
206  // 这行不归 band 管,负 marginBottom 也压不进去(band 按树自身高度占位,高度为 0 时整块被裁掉)
207  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
208    if (e.surface !== 'terminal') return next(e)
209    const { Box, Text } = $.ui.resolve(e)
210    const { context, rateLimits } = await $.session.usage()
211    const children = []
212
213    const where = []
214    if (git) {
215      // 默认分支上分支名没有信息量,只在别的分支或 detached 时显示
216      if (!DEFAULT_BRANCHES.includes(git.head)) {
217        where.push(Text({ color: HEX.magenta, children: [`${ICON.branch} ${git.head}`] }))
218      }
219      // 只画非零项;全为零时画一个勾,与「不在仓库里」区分开
220      const marks: [keyof typeof ICON, number, string][] = [
221        ['ahead', git.ahead, HEX.cyan],
222        ['behind', git.behind, HEX.cyan],
223        ['conflicted', git.conflicted, HEX.red],
224        ['staged', git.staged, HEX.green],
225        ['modified', git.modified, HEX.yellow],
226        ['untracked', git.untracked, HEX.normal],
227      ]
228      const dirty = marks.filter(([, count]) => count > 0)
229      for (const [icon, count, color] of dirty) where.push(Text({ color, children: [`${ICON[icon]} ${count}`] }))
230      if (!dirty.length) where.push(Text({ color: HEX.green, children: [ICON.clean] }))
231    }
232
233    if (typeof context.percent === 'number') {
234      const pct = Math.min(context.percent, 100)
235      const c = tone(pct, WARN_CTX, CRIT_CTX)
236      const full = Math.min(Math.round(pct / 100 * CTX_CELLS), CTX_CELLS)
237      // 整格色块用背景色铺空格,与 5h/7d 叠条等高;制表符线段太细,░ 在部分字体里又渲染成点阵网纹
238      const used = context.tokens === undefined ? `${Math.round(pct)}%` : `${tokens(context.tokens)}/${tokens(context.window)}`
239      children.push(Text({ children: [
240        Text({ dimColor: true, children: ['ctx '] }),
241        Text({ backgroundColor: c ?? HEX.normal, children: [' '.repeat(full)] }),
242        Text({ backgroundColor: HEX.track, children: [' '.repeat(CTX_CELLS - full)] }),
243        Text({ color: c, dimColor: !c, children: [` ${used}`] }),
244      ] }))
245    }
246
247    // 5h 与 7d 叠在同一行高里:▀ 的上半格取前景色画 5h,下半格取背景色画 7d。
248    // 未填满的半格要画成轨道色,否则下半格空着时看不出条的长度;轨道色和填充色因此只能用定值,不能用 dimColor。
249    // 条右侧按上下顺序写两个窗口的用量与距重置多久
250    const top = rateLimits.find(r => r.kind === 'five_hour')
251    const bottom = rateLimits.find(r => r.kind === 'seven_day')
252    if (top && bottom) {
253      const now = Date.now()
254      const fill = (w: SessionRateLimit) => Math.min(Math.round(w.percentUsed / 100 * QUOTA_CELLS), QUOTA_CELLS)
255      const color = (w: SessionRateLimit) => tone(w.percentUsed, WARN_QUOTA, CRIT_QUOTA) ?? HEX.normal
256      const left = (w: SessionRateLimit) => {
257        const c = tone(w.percentUsed, WARN_QUOTA, CRIT_QUOTA)
258        const reset = w.resetsAt ? ` ${countdown(w.resetsAt, now)}` : ''
259        return Text({ color: c, dimColor: !c, children: [`${Math.round(w.percentUsed)}%${reset}`] })
260      }
261      children.push(Text({ children: [
262        Text({ dimColor: true, children: ['5h/7d '] }),
263        ...Array.from({ length: QUOTA_CELLS }, (_, i) => Text({
264          color: i < fill(top) ? color(top) : HEX.track,
265          backgroundColor: i < fill(bottom) ? color(bottom) : HEX.track,
266          children: ['▀'],
267        })),
268        Text({ children: [' '] }),
269        left(top),
270        Text({ dimColor: true, children: [' / '] }),
271        left(bottom),
272      ] }))
273    }
274
275    // engine 原本在这里画的模式标签(focus、memory paused 等)照旧保留,自己画成文本,不嵌 engine 节点
276    if (e.props.modes.length) children.push(Text({ dimColor: true, children: [e.props.modes.join(' & ')] }))
277
278    return Box({ flexDirection: 'row', columnGap: 3, children: [
279      Box({ flexDirection: 'row', columnGap: 2, children: where }),
280      ...children,
281    ] })
282  })
283}
284