提示框上方列出這個對話動過、還沒 commit 的 repo:分支、未 commit 檔案數、未 push 的 commit、別的 worktree、本輪改了幾檔

繁體中文版:README.zh-TW.md. The mod's on-screen text is in Traditional Chinese.
A Claude Code mod that draws one line above the prompt: the repos this conversation has touched that still have uncommitted changes. When you work across several repos at once (for example chezmoi, a vault, and mango-mods together), one glance before asking the agent to commit tells you what is still left.
Tested on: Claude Code 2.1.289 (0.1.0, loaded with --plugin-dir; tested two repos with one edited file each, one of them turning into a check mark after its commit, and showing alongside ctx-relay). The mods API is still in early access, so a Claude Code release may require changes.

A live Claude Code 2.1.290 session. The top line is repo-ledger: demo-app(main) 1 · 本輪 2 檔 ("2 files this turn"). With several repos they share one line, for example repoA(master) 1 · repoB(master) ✓ · 本輪 2 檔.
name(branch) followed by its count of uncommitted files (untracked files included, in orange); a clean repo shows a gray check mark.↑N: commits not yet pushed. ⎇N: extra worktrees of this repo.本輪 N 檔 ("N files this turn"): files inside repos edited with Edit / Write since you last sent a message; turns orange above 5 files, so you notice early when a lot is changing.--plugin-dir, repo-ledger is on top.cd, pushd, and git -C in Bash commands (paths starting with ~/ included).git command with no cd / git -C counts the session's working directory.At the end of every turn, after any Bash command containing git, and the first time a new repo is touched.
S=/x; cd "$S" or git -C "$(…)" cannot be seen through and are not tracked.sed -i, scripts, and so on) do not count toward 本輪 N 檔; if the repo is already tracked, they show up in the uncommitted count on the next refresh.cd some-repo inside a heredoc or a commit message may also add that repo (one extra entry; the others are unaffected).~/.local/share/chezmoi shows as chezmoi, a vault as its folder name./clear, and is read back from $.state after a hot reload. Neither has been tested live yet.git on PATH.git status and git worktree list in tracked repos on its own, so settings in the repo's own .git/config take effect. Merely cd-ing into an untrusted repo in the conversation triggers this.core.fsmonitor is blocked: every git status runs with -c core.fsmonitor=false, so a repo's fsmonitor command never runs.filter.<name>.clean: git status may run it while comparing file contents (an assumption, not tested). Use it only in repos you trust.hooks/register.tsx 204 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Ledger, RepoRow } from '../types'
5
6// repo-ledger:提示框上方一行,列出這個對話動過、還沒 commit 的 repo。
7// 追蹤哪些 repo:Edit/Write/NotebookEdit 的檔案路徑、Bash 指令裡 cd/git -C 後面的字面路徑(有 $ 或反引號的不認),
8// 以及沒有 cd 就跑 git 時的工作目錄。工作目錄本身不會一開場就列:對話動到它之前,裡面原有的改動不算。
9// 什麼時候重算:每輪結束、Bash 跑過 git 指令之後、第一次碰到新 repo 時。
10// 沒有未 commit 的檔、本輪也沒改檔時整行不畫(只剩未 push 的 commit 不算)。
11//
12// /clear 之後 $.state 歸零、模組變數保留;hot reload 反過來(ctx-relay P1 probe 實測)。
13// 所以追蹤清單兩邊各存一份:模組變數撐過 clear,$.state 撐過 reload,session.start(reload 會重跑)把兩邊合併。
14
15// 和 ctx-relay 同一組 dark-daltonized 可分辨色
16const ORANGE = '#E69F00'
17const SKY = '#56B4E9'
18const LABEL = '#7d8794'
19const VALUE = '#f5f7fa'
20const GIT = '' // Nerd Font md-git U+F02A2
21// 本輪改超過這麼多檔,檔數變橘色(「改太大了」的早期提醒)
22const WIDE_TURN = 5
23
24const ledgerAtom = atom({ plugin: 'repo-ledger', key: 'ledger' } as const, { rows: [], turnFiles: 0 } as Ledger)
25const rootsAtom = atom({ plugin: 'repo-ledger', key: 'roots' } as const, [] as string[])
26
27// 追蹤中的 repo 根目錄(加入順序)
28const roots: string[] = []
29// 目錄 → repo 根目錄;不是 repo 記 null,不重問 git
30const rootOf = new Map<string, string | null>()
31// 本輪 Edit/Write 過的檔案(含子代理的)
32const turnFiles = new Set<string>()
33
34export const register: Register = on => {
35 on('session.start', async ($, e, next) => {
36 const result = await next(e)
37 for (const root of await read($, rootsAtom)) {
38 if (!roots.includes(root)) roots.push(root)
39 }
40 if (roots.length > 0) await refresh($)
41 return result
42 })
43
44 // 只有主對話有 turn.start:你送出訊息就是新的一輪
45 on('turn.start', async ($, e, next) => {
46 turnFiles.clear()
47 await update($, ledgerAtom, l => ({ ...l, turnFiles: 0 }))
48 return next(e)
49 })
50
51 on('tool.call', async ($, e, next) => {
52 const result = await next(e)
53 if (result.deny !== undefined || result.isError === true) return result
54 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
55 const file = await absolute($, await $.session.cwd(), e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path)
56 const isNew = await track($, dirname(file))
57 // 只算 repo 裡的檔(scratchpad 這類暫存檔不算)
58 if (rootOf.get(dirname(file))) turnFiles.add(file)
59 if (isNew) await refresh($)
60 await update($, ledgerAtom, l => ({ ...l, turnFiles: turnFiles.size }))
61 } else if (e.tool === 'Bash') {
62 const cwd = await $.session.cwd()
63 const runsGit = /\bgit\b/.test(e.command)
64 const dirs = commandDirs(e.command)
65 // 完全沒有 cd/git -C 就跑 git:在工作目錄跑的(有但看不穿的,不猜)
66 if (runsGit && !MOVE_RE.test(e.command)) dirs.push(cwd)
67 let isNew = false
68 for (const dir of dirs) {
69 if (await track($, await absolute($, cwd, dir))) isNew = true
70 }
71 if (isNew || runsGit) await refresh($)
72 }
73 return result
74 })
75
76 on('turn.complete', async ($, e, next) => {
77 const result = await next(e)
78 if (!e.agentId) await refresh($)
79 return result
80 })
81
82 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83 if (e.props.hasSurvey) {
84 return next(e)
85 }
86 // band 只有一個:先讓排在下面的 mod 畫,自己這行疊在它下面
87 const below = await next(e)
88 const mine = await drawBand($, e)
89 if (mine === null) return below
90 if (!below) return mine
91 const { Box } = $.ui.resolve(e)
92 return (
93 <Box flexDirection="column">
94 {below}
95 {mine}
96 </Box>
97 )
98 })
99}
100
101// Bash 指令裡 cd/pushd/git -C 後面的路徑;帶 $ 或反引號的看不穿,不認
102const DIR_RE = /(?:^|[\s;&|(])(?:cd|pushd)\s+(?:--\s+)?('[^']*'|"[^"]*"|[^\s;&|)]+)|\bgit\s+-C\s+('[^']*'|"[^"]*"|[^\s;&|)]+)/g
103const MOVE_RE = /(?:^|[\s;&|(])(?:cd|pushd)\s|\bgit\s+-C\s/
104
105function commandDirs(command: string): string[] {
106 const dirs: string[] = []
107 for (const m of command.matchAll(DIR_RE)) {
108 const dir = (m[1] ?? m[2] ?? '').replace(/^(['"])(.*)\1$/, '$2')
109 if (dir === '' || dir === '-' || /[$`]/.test(dir)) continue
110 dirs.push(dir)
111 }
112 return dirs
113}
114
115async function absolute($: EngineInterface, cwd: string, path: string): Promise<string> {
116 if (path.startsWith('/')) return path
117 if (path === '~' || path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
118 return `${cwd}/${path}`
119}
120
121function dirname(path: string): string {
122 return path.slice(0, path.lastIndexOf('/')) || '/'
123}
124
125// 目錄所屬 repo 加進追蹤;回傳是否新加入
126async function track($: EngineInterface, dir: string): Promise<boolean> {
127 let root = rootOf.get(dir)
128 if (root === undefined) {
129 const run = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
130 root = run.exitCode === 0 ? run.stdout.trim() || null : null
131 rootOf.set(dir, root)
132 }
133 if (root === null || roots.includes(root)) return false
134 roots.push(root)
135 await update($, rootsAtom, () => [...roots])
136 return true
137}
138
139async function refresh($: EngineInterface): Promise<void> {
140 const rows: RepoRow[] = []
141 for (const root of roots) {
142 const row = await inspect($, root)
143 if (row !== null) rows.push(row)
144 }
145 await update($, ledgerAtom, l => ({ ...l, rows }))
146 // /clear 把 $.state 歸零後,下一次重算就把模組變數的清單寫回去,之後的 hot reload 才讀得到
147 await update($, rootsAtom, () => [...roots])
148}
149
150async function inspect($: EngineInterface, root: string): Promise<RepoRow | null> {
151 // repo 自己的 .git/config 若設了 core.fsmonitor,status 會執行它:外來 repo 只 cd 進去就會觸發,關掉
152 const status = await $.process.run(['git', '-C', root, '-c', 'core.fsmonitor=false', 'status', '--porcelain=v1', '--branch'], { timeoutMs: 10_000 })
153 if (status.exitCode !== 0) return null
154 const [head = '', ...files] = status.stdout.split('\n')
155 const worktrees = await $.process.run(['git', '-C', root, 'worktree', 'list', '--porcelain'], { timeoutMs: 5000 })
156 const others = worktrees.exitCode === 0 ? worktrees.stdout.split('\n').filter(l => l.startsWith('worktree ')).length - 1 : 0
157 return {
158 root,
159 name: root.split('/').filter(Boolean).at(-1) ?? root,
160 branch: branchOf(head),
161 dirty: files.filter(l => l.trim() !== '').length,
162 ahead: Number(/\[ahead (\d+)/.exec(head)?.[1] ?? 0),
163 worktrees: Math.max(0, others),
164 }
165}
166
167// `## master...origin/master [ahead 1]`、`## No commits yet on main`、`## HEAD (no branch)`
168function branchOf(head: string): string {
169 const text = head.replace(/^## /, '')
170 const unborn = /^No commits yet on (\S+)/.exec(text)
171 if (unborn) return unborn[1] ?? text
172 return text.split(/\.\.\.| /)[0] ?? text
173}
174
175// 沒有東西要畫時回 null
176async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
177 const ledger = await read($, ledgerAtom)
178 const isPending = ledger.rows.some(r => r.dirty > 0)
179 if (!isPending && ledger.turnFiles === 0) return null
180 const { Box, Text } = $.ui.resolve(e)
181 const parts: RenderElement[] = []
182 ledger.rows.forEach((r, i) => {
183 if (i > 0) parts.push(<Text color={LABEL}>{' · '}</Text>)
184 const isClean = r.dirty === 0 && r.ahead === 0
185 parts.push(<Text color={isClean ? LABEL : VALUE}>{`${r.name}(${r.branch})`}</Text>)
186 parts.push(isClean ? <Text color={LABEL}>{' ✓'}</Text> : <Text color={ORANGE} bold>{r.dirty > 0 ? ` ${r.dirty}` : ''}</Text>)
187 if (r.ahead > 0) parts.push(<Text color={SKY}>{` ↑${r.ahead}`}</Text>)
188 if (r.worktrees > 0) parts.push(<Text color={LABEL}>{` ⎇${r.worktrees}`}</Text>)
189 })
190 if (ledger.turnFiles > 0) {
191 parts.push(<Text color={LABEL}>{' · 本輪 '}</Text>)
192 parts.push(<Text color={ledger.turnFiles > WIDE_TURN ? ORANGE : VALUE} bold={ledger.turnFiles > WIDE_TURN}>{`${ledger.turnFiles}`}</Text>)
193 parts.push(<Text color={LABEL}>{' 檔'}</Text>)
194 }
195 return (
196 <Box flexDirection="row">
197 <Text color={LABEL} wrap="truncate-end">
198 {` ${GIT} `}
199 {parts}
200 </Text>
201 </Box>
202 )
203}
204types/index.d.ts 18 lines1// 一個 repo 的現況:git status --branch 與 git worktree list 的結果
2// dirty=未 commit 的檔案數(含未追蹤);ahead=還沒 push 的 commit 數;worktrees=這個 repo 另外開的 worktree 數
3export type RepoRow = { root: string; name: string; branch: string; dirty: number; ahead: number; worktrees: number }
4
5// band 讀的整份帳本。rows 照 repo 加入的順序,session 工作目錄的 repo 在最前;
6// turnFiles=本輪(上次送出訊息以來)Edit/Write 過的檔案數
7export type Ledger = { rows: RepoRow[]; turnFiles: number }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'repo-ledger': {
12 ledger: Ledger
13 // 這個對話追蹤中的 repo 根目錄;hot reload 後由 session.start 讀回
14 roots: string[]
15 }
16 }
17}
18