Skip to content

Claude Code 底栏第三次重写:两行布局、Git 状态与 150ms 预算 ​

这是我第三次重写 Claude Code 的底栏脚本。

第一次(2 月)我只想让它别报错,第二次(5 月)我把它拆成了分层结构,解决了"改不动"的问题。两次都在回答同一个问题:怎么把信息塞进一行,并且永不崩。

这一次问题变了。我开始不满足于只看模型和 token——我在写代码,我想在底栏直接看到 git 状态:改了哪几类文件、领先远端几个 commit、有没有冲突。而这些东西一行已经放不下了。

所以这一版做三件事:把底栏拆成两行、把 git 解析做进脚本、给所有外部调用定一个时间预算。

先看完整代码 ​

python
# /// 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 调它。

这意味着三条硬约束:

  1. 不能阻塞(我的光标还在等着)
  2. 不能崩(一次 traceback 就是底栏上一条红色乱码)
  3. 不能输出废话(stdout 就是屏幕上的那两行,多一个字符都是视觉故障)

后面几乎每个设计都能对上这三条。

1. PEP-723 头:一个文件就是全部 ​

python
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///

uv run statusLine.py 直接执行,不需要 venv、不需要 requirements.txt、不需要任何安装步骤。把配置里的 statusLine.command 指到它就行。

dependencies = [] 是写死的零依赖。这不是图省事,而是省掉一整类失败:状态栏脚本不该有任何机会因为网络、镜像或锁文件而罢工。它要在我每一次呼吸之间都跑得起来。

2. 版面就是代码 ​

python
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"的隐式约定,顺序就是参数顺序,版面就是函数调用。想换位置、想加一段,都是在参数列表里挪一个函数名。

分隔符 " | " 在整个文件里只出现一次:

python
def join_line(*segments):
    return " | ".join(segment for segment in segments if segment)

3. 防御三件套:把类型检查收进三个小函数 ​

上一次重写时,我在每个取字段的地方都写了一遍 if type(x) is dict。这次只留三个小函数:

python
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

于是取字段变回一行,键路径写在行内,一眼能扫完:

python
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 集成:一次调用,自己解析 ​

这是这版的核心。

先定预算:

python
GIT_TIMEOUT = 0.15

每次重绘都要跑一次 git,所以先给它 150ms 的预算。超了就放弃这一段——底栏慢,比底栏少一行严重得多。

再取数据:

python
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 仓库")不该抛异常,只是一个空结果。

异常只写一行,但覆盖三类失败:

python
except (OSError, subprocess.SubprocessError, UnicodeDecodeError):
    return ""

OSError 对应 git 不存在或没有权限,UnicodeDecodeError 对应路径里有非法字节,而 subprocess.SubprocessError 是父类——TimeoutExpired 恰好是它的子类。所以第 5 节开头那个 150ms 超时也被这一个元组兜住了,不必再开一个 except TimeoutExpired 分支。

写这行之前我特地去确认了一遍 CPython 的异常层次,确认了才敢这么省。这种"一行兜住三件事"的地方,我认为是这版代码里最漂亮的一处。

6. 解析:把几百行 porcelain 读成六个数字 ​

python
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 的文件会同时进 + 和 ~。这符合直觉:它确实既暂存过、又改过,两件事都是真的。

计数符号只定义一次:

python
COUNT_TOKENS = (
    ("conflicts", "!"),
    ("staged", "+"),
    ("modified", "~"),
    ("deleted", "-"),
    ("untracked", "?"),
)

这个元组是三件事的单一事实源:计数器的键、显示顺序、显示符号。渲染侧因此只有一行:

python
parts += [f"{tok}{counts[name]}" for name, tok in COUNT_TOKENS if counts[name]]

顺序天然是严重度降序——冲突 > 暂存 > 修改 > 删除 > 未跟踪;而且是零值自动消失,没有 if count > 0 的重复判断。以后想加一类统计(比如 stash 数量),解析里加一行、元组里加一项,渲染侧一个字都不用动。

7. 三种"没有数字",不能都渲染成沉默 ​

python
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 也要有正确的显示 ​

python
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)。这两个都不是分支名,原样显示只会误导人。

所以这里做了三级降级:

  1. 有真分支名 → 显示分支名(shorten 超过 32 字符用 … 截断,长分支名不该把第二行挤爆)
  2. detached / head 未知 → 退到 7 位短 oid,前面加 @ 明确"这是提交号不是分支名"
  3. 连提交都没有 → unknown

三种状态三种显示,而不是让 (detached) 这种字符串出现在我的底栏上。这三个字面量还被提成了 NAMED_HEADS 常量——判"是不是具名分支"这件事在 git_ref 和 git_segment 里各用了一次,定义只该有一份。

9. clean 是算出来的,不是猜出来的 ​

python
if not any(counts.values()):
    parts.append("clean")

只有在确认没有任何一类计数时才补一个 clean。而 git 段整体不可用时,git_segment 早就返回 "" 了。

"工作区是干净的"和"我读不到 git"是两件不同的事,代码里也是两条不同的路径。这条界线如果模糊掉,底栏就会开始骗人——而一个会骗人的底栏,比没有底栏更糟。

10. main():唯一的职责是永不抛异常 ​

python
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

stdin 读不到、JSON 是坏的、JSON 顶层是数组或 null——三种情况全部退化成空 dict,然后照常渲染。缺的字段由 as_dict/to_int 兜住,缺的段落由空串契约消化,最终总能输出两行(哪怕内容是空的)。

这个脚本唯一不能做的事就是抛异常:它跑在别人的渲染循环里,没有 try 的上层。所以把"保证正常退出"压缩成三个常量级的兜底,比写一堆错误日志更符合它的身份——它没有观众,只有两行输出。

11. 它现在长这样 ​

txt
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 段整个消失:

txt
dir: Downloads | code:+0/-0

detached 在一台 CI 检出的仓库上:

txt
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 行,比我预想的短。前两版的经验在这里没有变成更多的代码,而是变成了更少的代码——所有的防御都收在几个小函数里,所有的版面都摊在两行里。这才是这段脚本让我满意的地方:不是它能显示多少东西,而是它每一行都能被我一眼读懂。

Nine and AI — Licensed under the MIT License