Claude Code 底栏第三次重写:两行布局、Git 状态与 150ms 预算
这是我第三次重写 Claude Code 的底栏脚本。
第一次(2 月)我只想让它别报错,第二次(5 月)我把它拆成了分层结构,解决了"改不动"的问题。两次都在回答同一个问题:怎么把信息塞进一行,并且永不崩。
这一次问题变了。我开始不满足于只看模型和 token——我在写代码,我想在底栏直接看到 git 状态:改了哪几类文件、领先远端几个 commit、有没有冲突。而这些东西一行已经放不下了。
所以这一版做三件事:把底栏拆成两行、把 git 解析做进脚本、给所有外部调用定一个时间预算。
先看完整代码
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import json
import os
import subprocess
import sys
# ============================================================
# 常量配置
# ============================================================
GIT_TIMEOUT = 0.15
NAMED_HEADS = {"(detached)", "(unknown)"}
COUNT_TOKENS = (
("conflicts", "!"),
("staged", "+"),
("modified", "~"),
("deleted", "-"),
("untracked", "?"),
)
# ============================================================
# 工具函数
# ============================================================
def as_dict(value):
return value if isinstance(value, dict) else {}
def to_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
def to_percent(value):
if isinstance(value, (int, float)):
return round(value * 100) if value <= 1 else round(value)
return 0
def shorten(text, limit=32):
return text if len(text) <= limit else f"{text[: limit - 1]}…"
def join_line(*segments):
return " | ".join(segment for segment in segments if segment)
def get_cwd(data):
workspace = as_dict(data.get("workspace"))
return (
workspace.get("current_dir")
or data.get("cwd")
or workspace.get("project_dir")
or ""
)
# ============================================================
# 第一行模块
# ============================================================
def model_segment(data):
model = as_dict(data.get("model"))
effort = as_dict(data.get("effort")).get("level") or "-"
model_id = model.get("id") or "unknown"
return f"model: {model_id}({effort})"
def window_segment(data):
window = as_dict(data.get("context_window"))
size = format_window(window.get("context_window_size"))
used = to_int(window.get("used_percentage"))
return f"window[{size}]: {used}%"
def cache_segment(data):
ratio = as_dict(data.get("prompt_cache")).get("hit_ratio")
return f"cache: {to_percent(ratio)}%"
def format_window(size):
value = to_int(size)
if value >= 1_000_000:
return f"{value // 1_000_000}M"
if value >= 1_000:
return f"{value // 1_000}K"
return str(value)
# ============================================================
# 第二行模块
# ============================================================
def dir_segment(cwd):
name = os.path.basename(cwd.rstrip("/\\"))
return f"dir: {name}" if name else ""
def code_segment(data):
cost = as_dict(data.get("cost"))
added = to_int(cost.get("total_lines_added"))
removed = to_int(cost.get("total_lines_removed"))
return f"code:+{added}/-{removed}"
# ============================================================
# Git 状态模块
# ============================================================
def run_git_status(cwd):
try:
result = subprocess.run(
[
"git",
"-C",
cwd,
"status",
"--porcelain=v2",
"--branch",
"--untracked-files=normal",
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=GIT_TIMEOUT,
check=False,
)
return result.stdout if result.returncode == 0 else ""
except (OSError, subprocess.SubprocessError, UnicodeDecodeError):
return ""
def git_ref(head, oid):
if head and head not in NAMED_HEADS:
return shorten(head)
if oid and oid != "(initial)":
return f"@{oid[:7]}"
return "unknown"
def git_sync_parts(upstream, has_ab, ahead, behind, named):
if not upstream:
return ["no-up"] if named else []
if not has_ab:
return ["no-ab"]
parts = []
if ahead:
parts.append(f"↑{ahead}")
if behind:
parts.append(f"↓{behind}")
return parts or ["sync"]
def git_segment(status):
if not status:
return ""
head = oid = upstream = ""
ahead = behind = 0
has_ab = False
counts = {name: 0 for name, _ in COUNT_TOKENS}
for line in status.splitlines():
if line.startswith("# branch."):
key, _, value = line.removeprefix("# branch.").partition(" ")
if key == "ab":
has_ab = True
for item in value.split():
if item.startswith("+"):
ahead = to_int(item[1:])
elif item.startswith("-"):
behind = to_int(item[1:])
elif key == "head":
head = value
elif key == "oid":
oid = value
elif key == "upstream":
upstream = value
elif line.startswith("?"):
counts["untracked"] += 1
elif line.startswith("u "):
counts["conflicts"] += 1
elif line.startswith(("1 ", "2 ")):
fields = line.split(maxsplit=2)
if len(fields) >= 2:
x, y = fields[1][:2]
counts["deleted"] += "D" in (x, y)
counts["staged"] += x not in (".", "D")
counts["modified"] += y not in (".", "D")
named = bool(head and head not in NAMED_HEADS)
parts = [f"git:{git_ref(head, oid)}"]
parts += git_sync_parts(upstream, has_ab, ahead, behind, named)
parts += [f"{tok}{counts[name]}" for name, tok in COUNT_TOKENS if counts[name]]
if not any(counts.values()):
parts.append("clean")
return " | ".join(parts)
# ============================================================
# 状态行渲染
# ============================================================
def git_line_segment(cwd):
return git_segment(run_git_status(cwd)) if cwd else ""
def render_status_line(data):
cwd = get_cwd(data)
line1 = join_line(model_segment(data), window_segment(data), cache_segment(data))
line2 = join_line(dir_segment(cwd), git_line_segment(cwd), code_segment(data))
return f"{line1}\n{line2}"
# ============================================================
# 主程序
# ============================================================
def main():
try:
data = json.load(sys.stdin)
data = data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
data = {}
print(render_status_line(data))
return 0
if __name__ == "__main__":
raise SystemExit(main())232 行,零依赖,一份文件。我觉得这是三版里最像我想要的那一版,下面讲为什么。
一个前提:底栏是热路径
在拆代码之前先说清楚这个脚本的处境:它每刷新一次界面就要跑一次。Claude Code 每次更新状态栏都会带着一份 JSON 从 stdin 调它。
这意味着三条硬约束:
- 不能阻塞(我的光标还在等着)
- 不能崩(一次 traceback 就是底栏上一条红色乱码)
- 不能输出废话(stdout 就是屏幕上的那两行,多一个字符都是视觉故障)
后面几乎每个设计都能对上这三条。
1. PEP-723 头:一个文件就是全部
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///uv run statusLine.py 直接执行,不需要 venv、不需要 requirements.txt、不需要任何安装步骤。把配置里的 statusLine.command 指到它就行。
dependencies = [] 是写死的零依赖。这不是图省事,而是省掉一整类失败:状态栏脚本不该有任何机会因为网络、镜像或锁文件而罢工。它要在我每一次呼吸之间都跑得起来。
2. 版面就是代码
def render_status_line(data):
cwd = get_cwd(data)
line1 = join_line(model_segment(data), window_segment(data), cache_segment(data))
line2 = join_line(dir_segment(cwd), git_line_segment(cwd), code_segment(data))
return f"{line1}\n{line2}"底栏长什么样,看这两行就够了:第一行放模型、上下文窗口、缓存命中,第二行放目录、git、代码增删。
没有 if,没有"如果 A 存在就拼 B"的隐式约定,顺序就是参数顺序,版面就是函数调用。想换位置、想加一段,都是在参数列表里挪一个函数名。
分隔符 " | " 在整个文件里只出现一次:
def join_line(*segments):
return " | ".join(segment for segment in segments if segment)3. 防御三件套:把类型检查收进三个小函数
上一次重写时,我在每个取字段的地方都写了一遍 if type(x) is dict。这次只留三个小函数:
def as_dict(value):
return value if isinstance(value, dict) else {}
def to_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
def to_percent(value):
if isinstance(value, (int, float)):
return round(value * 100) if value <= 1 else round(value)
return 0于是取字段变回一行,键路径写在行内,一眼能扫完:
model = as_dict(data.get("model"))
effort = as_dict(data.get("effort")).get("level") or "-"as_dict(...) 后面可以直接接着 .get(...)——因为它保证返回一个 dict。这个"失败也要返回正确类型"的思路,让所有调用点都不需要判空。
to_percent 是我最满意的三行之一。Claude Code 的 hit_ratio 有可能给 0~1 的比值,也有可能给 0~100 的百分数;这个函数两种口径都接得住——不超过 1 就当比值放大 100 倍,否则原样取整。上游换了口径,底栏不用改。
4. 每个 segment 的契约:要么给内容,要么给空串
所有 *_segment 函数遵循同一个契约:返回字符串,没数据就返回空串。
于是"某个信息源不可用"退化成 join_line 里的一次过滤,而不是散落各处的 if 分支。不在 git 仓库里时,run_git_status 返回空 → git_segment 直接 return "" → 第二行只剩 dir 和 code,不会留下孤零零的分隔符,也不会留下空洞的标签。
一个契约,收掉了所有降级逻辑。这是这一版结构上我最喜欢的地方。
5. Git 集成:一次调用,自己解析
这是这版的核心。
先定预算:
GIT_TIMEOUT = 0.15每次重绘都要跑一次 git,所以先给它 150ms 的预算。超了就放弃这一段——底栏慢,比底栏少一行严重得多。
再取数据:
result = subprocess.run(
["git", "-C", cwd, "status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=GIT_TIMEOUT,
check=False,
)
return result.stdout if result.returncode == 0 else ""四点值得说:
-C cwd:不切目录、不依赖进程的工作目录。状态栏拿到的cwd直接喂进去就行。stderr=subprocess.DEVNULL:git 的告警永远不能污染底栏。stdout 是显示器,stderr 也不能例外地泄漏到屏幕上。--porcelain=v2:机器可读且格式稳定,还免费带上了# branch.head、# branch.oid、# branch.ab。分支名和 ahead/behind 一次就能拿全,整整两行底栏只 fork 一个进程。check=False+ 检查returncode:非零退出(比如"这不是一个 git 仓库")不该抛异常,只是一个空结果。
异常只写一行,但覆盖三类失败:
except (OSError, subprocess.SubprocessError, UnicodeDecodeError):
return ""OSError 对应 git 不存在或没有权限,UnicodeDecodeError 对应路径里有非法字节,而 subprocess.SubprocessError 是父类——TimeoutExpired 恰好是它的子类。所以第 5 节开头那个 150ms 超时也被这一个元组兜住了,不必再开一个 except TimeoutExpired 分支。
写这行之前我特地去确认了一遍 CPython 的异常层次,确认了才敢这么省。这种"一行兜住三件事"的地方,我认为是这版代码里最漂亮的一处。
6. 解析:把几百行 porcelain 读成六个数字
for line in status.splitlines():
if line.startswith("# branch."):
key, _, value = line.removeprefix("# branch.").partition(" ")
...
elif line.startswith("?"):
counts["untracked"] += 1
elif line.startswith("u "):
counts["conflicts"] += 1
elif line.startswith(("1 ", "2 ")):
fields = line.split(maxsplit=2)
if len(fields) >= 2:
x, y = fields[1][:2]
counts["deleted"] += "D" in (x, y)
counts["staged"] += x not in (".", "D")
counts["modified"] += y not in (".", "D")porcelain v2 的普通记录形如 1 <XY> <sub> <mH> <mI> <mW> <path>,我们要的只是第二个字段的前两个字符。split(maxsplit=2) 顺手把路径(可能含空格)挡在后面——反正我们不看路径,那就连引用都不需要。
XY 两位的含义是:X = 暂存区相对 HEAD,Y = 工作区相对暂存区,. 表示没变,D 表示删除。所以:
- 删除单独统计(
"D" in (x, y)) +和~只数非.且非D的位
一个 MM 的文件会同时进 + 和 ~。这符合直觉:它确实既暂存过、又改过,两件事都是真的。
计数符号只定义一次:
COUNT_TOKENS = (
("conflicts", "!"),
("staged", "+"),
("modified", "~"),
("deleted", "-"),
("untracked", "?"),
)这个元组是三件事的单一事实源:计数器的键、显示顺序、显示符号。渲染侧因此只有一行:
parts += [f"{tok}{counts[name]}" for name, tok in COUNT_TOKENS if counts[name]]顺序天然是严重度降序——冲突 > 暂存 > 修改 > 删除 > 未跟踪;而且是零值自动消失,没有 if count > 0 的重复判断。以后想加一类统计(比如 stash 数量),解析里加一行、元组里加一项,渲染侧一个字都不用动。
7. 三种"没有数字",不能都渲染成沉默
def git_sync_parts(upstream, has_ab, ahead, behind, named):
if not upstream:
return ["no-up"] if named else []
if not has_ab:
return ["no-ab"]
parts = []
if ahead:
parts.append(f"↑{ahead}")
if behind:
parts.append(f"↓{behind}")
return parts or ["sync"]--branch 只在"有上游、且 git 愿意给 ahead/behind"时才输出 # branch.ab。于是"为什么我没看到 ↑↓"有三种完全不同的原因:
| 显示 | 含义 |
|---|---|
no-up | 这个分支没有配上游 |
no-ab | 有上游,但 git 没给 ahead/behind |
sync | 有上游且完全一致 |
↑2 ↓1 | 有具体的领先/落后 |
如果全都渲染成空白,我永远分不清"已同步"和"读不到"。 把状态显式命名出来,是我从第一版起就坚持的习惯——底栏的价值在于告诉我真实情况,而不是让我看到一片安详。
顺带一个细节:no-up 只在有具名分支时显示。detached HEAD 谈"没有上游"没有意义。
8. detached HEAD 也要有正确的显示
NAMED_HEADS = {"(detached)", "(unknown)"}
def git_ref(head, oid):
if head and head not in NAMED_HEADS:
return shorten(head)
if oid and oid != "(initial)":
return f"@{oid[:7]}"
return "unknown"porcelain v2 在 detached 状态会把 branch.head 填成字面量字符串 (detached),仓库还没有第一个提交时填 (initial)。这两个都不是分支名,原样显示只会误导人。
所以这里做了三级降级:
- 有真分支名 → 显示分支名(
shorten超过 32 字符用…截断,长分支名不该把第二行挤爆) - detached / head 未知 → 退到 7 位短 oid,前面加
@明确"这是提交号不是分支名" - 连提交都没有 →
unknown
三种状态三种显示,而不是让 (detached) 这种字符串出现在我的底栏上。这三个字面量还被提成了 NAMED_HEADS 常量——判"是不是具名分支"这件事在 git_ref 和 git_segment 里各用了一次,定义只该有一份。
9. clean 是算出来的,不是猜出来的
if not any(counts.values()):
parts.append("clean")只有在确认没有任何一类计数时才补一个 clean。而 git 段整体不可用时,git_segment 早就返回 "" 了。
"工作区是干净的"和"我读不到 git"是两件不同的事,代码里也是两条不同的路径。这条界线如果模糊掉,底栏就会开始骗人——而一个会骗人的底栏,比没有底栏更糟。
10. main():唯一的职责是永不抛异常
def main():
try:
data = json.load(sys.stdin)
data = data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
data = {}
print(render_status_line(data))
return 0stdin 读不到、JSON 是坏的、JSON 顶层是数组或 null——三种情况全部退化成空 dict,然后照常渲染。缺的字段由 as_dict/to_int 兜住,缺的段落由空串契约消化,最终总能输出两行(哪怕内容是空的)。
这个脚本唯一不能做的事就是抛异常:它跑在别人的渲染循环里,没有 try 的上层。所以把"保证正常退出"压缩成三个常量级的兜底,比写一堆错误日志更符合它的身份——它没有观众,只有两行输出。
11. 它现在长这样
model: claude-sonnet-4-5(high) | window[200K]: 42% | cache: 91%
dir: nine-home | git:main | ↑2 | +3 | ~1 | ?4 | code:+128/-16在非 git 目录里打开,第二行的 git 段整个消失:
dir: Downloads | code:+0/-0detached 在一台 CI 检出的仓库上:
dir: repo | git:@4f2a91c | no-up | clean | code:+0/-0它不华丽,但我每次抬头看到它,需要的信息都在它该在的位置。
12. 这版好在哪里,我总结一下
- 版面即代码:
render_status_line两行读下来,就是底栏本身。改版面不需要理解任何逻辑。 - 契约统一:segment 要么给内容要么给空串,降级逻辑被收进
join_line一次过滤。 - 单一事实源:
COUNT_TOKENS一个元组同时管键名、顺序、符号,解析与渲染共享。 - 失败是分段的:git 拿不到数据,只掉 git 那一段,其余照常。没有"一处失败、整条消失"。
- 预算是显式的:
GIT_TIMEOUT = 0.15写在文件顶部,而不是"希望它够快"。 - 边界被命名了:
no-up/no-ab/sync/@oid/unknown/clean——每一个模糊状态都有一个明确的名字。 - 零依赖:一个文件,
uv run就跑,没有安装步骤可以失败。
关于那 150ms 再多说一句:我没有做过压测,也没有测量过 git 在冷缓存下到底要多久。我不需要知道——我要的是一个上界,而不是一个期望值。写在常量里、被异常元组兜住、超时后安静降级,预算这件事就算落实了。状态栏这种地方,确定性比速度更值钱。
结语
三版下来,我觉得自己在这段代码上最大的变化不是学会了什么技巧,而是开始按这个脚本真实的处境去写它:
它跑得极其频繁、它必须瞬间返回、它没有上层能接住异常、它的输出就是像素——那它就该长成现在这样:零依赖、有预算、有契约、每种模糊状态都有名字。
这版我写了 232 行,比我预想的短。前两版的经验在这里没有变成更多的代码,而是变成了更少的代码——所有的防御都收在几个小函数里,所有的版面都摊在两行里。这才是这段脚本让我满意的地方:不是它能显示多少东西,而是它每一行都能被我一眼读懂。