A band above the prompt: session and last-message cost, plus git branch, status, CI, pull and push (push commits any uncommitted changes first, with a…

A Claude Code mod: a band above the prompt. The left side shows what the session costs. The right side shows git and CI for the repo you're in.
Session $4.12 · Last message $0.38 ai-playground ⎇ [main ▾] ●3 ↑2 ↓0 ✅ CI [Pull] [Push 2]
⎇ [main ▾]). Picking a branch runs git switch. The list holds your 15 most recently committed local branches. On a surface with no picker element (VS Code, mobile), the branch is plain text.●N uncommitted files, and ↑ahead ↓behind the upstream.gh. It's hidden when there's none.git pull --ff-only.git push, with -u origin <branch> when there's no upstream.Commit & push (or Commit & push 3 when commits are already waiting). The second click stages everything (git add -A), commits with a one-line message Haiku writes from the diff (falling back to Update N files), then pushes. If the commit fails (a pre-commit hook, say), it shows git's error and doesn't push.git fetch every 5 minutes. Outside a git repo the whole right side hides.It's on by default. /meter hides or shows it. There are no toasts or alerts. The CI badges and timings are at the top of hooks/register.tsx.
The cost is the engine's own /cost figure. A session.measure hook fires after each turn, reads cost.usd and stores { total, lastTurn, startedAt } in $.state. Git state is read with $.process.run (git and gh, in the session's directory) into a git state value. The AbovePrompt render hook only reads state and draws. A $.clock.every timer runs the count-up while the session figure is moving, and another runs the 5-minute fetch.
Install it from the marketplace this repo provides:
/plugin marketplace add juulsverne/claude-code-mods
/plugin install cost-git@playground-mods
Or try it in one session from a clone:
claude --plugin-dir cost-git
claude plugin validate claude-code-mods/cost-git: passes with no warnings.tsc against Claude Code 2.1.286's plugin types: the hooks module is clean; the test file has fixture typing errors (the BAND props), which don't affect running the tests.claude plugin test claude-code-mods/cost-git: 10 tests pass on the terminal and desktop surfaces. They cover:Session $0.00 · Last message $0.00 with no emojigit switchgit switch refuses when uncommitted changes would be overwritten. The band shows git's error and stays put; it never stashes.hooks/register.tsx 345 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ci, Git, Meter, Motion } from '../types'
5
6// ── Tune these ──────────────────────────────────────────────────────────────
7
8const CI_BADGES: Record<Ci, { icon: string; color: string }> = {
9 pass: { icon: '✅', color: '#4ADE80' },
10 fail: { icon: '❌', color: '#F87171' },
11 running: { icon: '🟡', color: '#FDE047' },
12}
13
14const COUNT_UP_MS = 900
15const FRAME_MS = 40
16const FETCH_EVERY_MS = 5 * 60_000
17const PUSH_ARM_MS = 5000
18const NOTE_MS = 8000
19
20// ── State ───────────────────────────────────────────────────────────────────
21
22const meter = atom({ plugin: 'cost-git', key: 'meter' } as const, {
23 total: 0,
24 lastTurn: 0,
25 startedAt: 0,
26})
27const motion = atom({ plugin: 'cost-git', key: 'motion' } as const, { shown: 0 })
28const isHidden = atom({ plugin: 'cost-git', key: 'isHidden' } as const, false)
29const git = atom({ plugin: 'cost-git', key: 'git' } as const, null)
30
31const money = (usd: number) => `$${usd.toFixed(2)}`
32
33
34const firstLine = (text: string) => text.trim().split(/\r?\n/).find(Boolean) ?? ''
35
36// ── Git ─────────────────────────────────────────────────────────────────────
37
38// Runs one command in the session's directory; never throws.
39async function sh($: EngineInterface, argv: string[], timeoutMs = 15_000) {
40 try {
41 return await $.process.run(argv, { timeoutMs })
42 } catch (error) {
43 return { exitCode: -1, stdout: '', stderr: String(error) }
44 }
45}
46
47// The newest GitHub Actions run on the branch, or null if gh can't say.
48async function ciFor($: EngineInterface, branch: string): Promise<Ci | null> {
49 const run = await sh($, ['gh', 'run', 'list', '--branch', branch, '--limit', '1', '--json', 'status,conclusion'], 10_000)
50 if (run.exitCode !== 0) return null
51 try {
52 const [latest] = JSON.parse(run.stdout) as { status: string; conclusion: string }[]
53 if (!latest) return null
54 if (latest.status !== 'completed') return 'running'
55 return latest.conclusion === 'success' ? 'pass' : 'fail'
56 } catch {
57 return null
58 }
59}
60
61// Reads the repo, branch, dirty count, ahead/behind and CI into state.
62async function refreshGit($: EngineInterface) {
63 const top = await sh($, ['git', 'rev-parse', '--show-toplevel'])
64 if (top.exitCode !== 0) {
65 await update($, git, () => null)
66 return
67 }
68 const repo = top.stdout.trim().split(/[\\/]/).pop() ?? 'repo'
69
70 let branch = (await sh($, ['git', 'branch', '--show-current'])).stdout.trim()
71 if (!branch) {
72 branch = `@${(await sh($, ['git', 'rev-parse', '--short', 'HEAD'])).stdout.trim()}`
73 }
74 const status = await sh($, ['git', 'status', '--porcelain'])
75 const dirty = status.stdout.split(/\r?\n/).filter(Boolean).length
76
77 const counts = await sh($, ['git', 'rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
78 const hasUpstream = counts.exitCode === 0
79 const [behind = 0, ahead = 0] = hasUpstream ? counts.stdout.trim().split(/\s+/).map(Number) : []
80
81 const refs = await sh($, ['git', 'for-each-ref', '--sort=-committerdate', '--count=15', '--format=%(refname:short)', 'refs/heads'])
82 const branches = refs.stdout.split(/\r?\n/).map(b => b.trim()).filter(Boolean)
83 if (!branches.includes(branch)) branches.unshift(branch)
84
85 const ci = branch.startsWith('@') ? null : await ciFor($, branch)
86
87 await update($, git, was => ({
88 repo,
89 branch,
90 branches,
91 dirty,
92 ahead,
93 behind,
94 hasUpstream,
95 ci,
96 busy: was?.busy ?? null,
97 isPushArmed: was?.isPushArmed ?? false,
98 note: was?.note ?? null,
99 }))
100}
101
102async function fetchQuietly($: EngineInterface) {
103 await sh($, ['git', 'fetch', '--quiet'], 30_000)
104 await refreshGit($)
105}
106
107function showNote($: EngineInterface, text: string, isError: boolean) {
108 void update($, git, g => (g ? { ...g, note: { text, isError } } : g))
109 $.clock.after(NOTE_MS, () => {
110 void update($, git, g => (g?.note?.text === text ? { ...g, note: null } : g))
111 })
112}
113
114async function pull($: EngineInterface) {
115 const g = await read($, git)
116 if (!g || g.busy) return
117 await update($, git, s => (s ? { ...s, busy: 'pull' as const, note: null } : s))
118 const run = await sh($, ['git', 'pull', '--ff-only'], 120_000)
119 await update($, git, s => (s ? { ...s, busy: null } : s))
120 showNote($, run.exitCode === 0 ? 'pulled ✓' : firstLine(run.stderr || run.stdout), run.exitCode !== 0)
121 await refreshGit($)
122}
123
124// Uncommitted files: Push commits them first, so nothing is left behind.
125const commitsFirst = (g: Git) => g.dirty > 0
126
127// A one-line commit message from the staged diff, or a plain fallback.
128async function commitMessage($: EngineInterface, files: number) {
129 const fallback = `Update ${files} file${files === 1 ? '' : 's'}`
130 const stat = (await sh($, ['git', 'diff', '--cached', '--stat'])).stdout
131 const diff = (await sh($, ['git', 'diff', '--cached'])).stdout.slice(0, 12_000)
132 const r = await $.model.complete({
133 model: 'haiku',
134 timeoutMs: 15_000,
135 prompt:
136 'Write a git commit subject line (imperative, under 65 characters, no quotes, no trailing period) for this change. Reply with the line only.\n\n' +
137 `${stat}\n${diff}`,
138 })
139 const line = r.isAnswered ? firstLine(r.text).replace(/^["'`]|["'`]$/g, '') : ''
140 return line || fallback
141}
142
143// Stages everything and commits; returns the error line, or null on success.
144async function commitAll($: EngineInterface, files: number) {
145 const add = await sh($, ['git', 'add', '-A'], 60_000)
146 if (add.exitCode !== 0) return firstLine(add.stderr || add.stdout)
147 const message = await commitMessage($, files)
148 const commit = await sh($, ['git', 'commit', '-m', message], 60_000)
149 return commit.exitCode === 0 ? null : firstLine(commit.stderr || commit.stdout)
150}
151
152// First press arms it; a second press within PUSH_ARM_MS pushes.
153async function push($: EngineInterface) {
154 const g = await read($, git)
155 if (!g || g.busy) return
156 if (!g.isPushArmed) {
157 await update($, git, s => (s ? { ...s, isPushArmed: true, note: null } : s))
158 $.clock.after(PUSH_ARM_MS, () => {
159 void update($, git, s => (s && s.isPushArmed && !s.busy ? { ...s, isPushArmed: false } : s))
160 })
161 return
162 }
163 if (commitsFirst(g)) {
164 await update($, git, s => (s ? { ...s, busy: 'commit' as const, isPushArmed: false } : s))
165 const error = await commitAll($, g.dirty)
166 if (error) {
167 await update($, git, s => (s ? { ...s, busy: null } : s))
168 showNote($, error, true)
169 await refreshGit($)
170 return
171 }
172 }
173 await update($, git, s => (s ? { ...s, busy: 'push' as const, isPushArmed: false } : s))
174 const argv = g.hasUpstream ? ['git', 'push'] : ['git', 'push', '-u', 'origin', g.branch]
175 const run = await sh($, argv, 120_000)
176 await update($, git, s => (s ? { ...s, busy: null } : s))
177 showNote($, run.exitCode === 0 ? 'pushed ✓' : firstLine(run.stderr || run.stdout), run.exitCode !== 0)
178 await refreshGit($)
179}
180
181async function switchTo($: EngineInterface, target: string) {
182 const g = await read($, git)
183 if (!g || g.busy || target === g.branch) return
184 await update($, git, s => (s ? { ...s, busy: 'switch' as const, isPushArmed: false, note: null } : s))
185 const run = await sh($, ['git', 'switch', target], 60_000)
186 await update($, git, s => (s ? { ...s, busy: null } : s))
187 showNote($, run.exitCode === 0 ? `on ${target} ✓` : firstLine(run.stderr || run.stdout), run.exitCode !== 0)
188 await refreshGit($)
189}
190
191// ── The fare's count-up ─────────────────────────────────────────────────────
192
193let countUp: { cancel: () => void } | null = null
194
195// Counts the session figure from where it is up to `to`.
196async function animateTo($: EngineInterface, to: number) {
197 countUp?.cancel()
198 const from = (await read($, motion)).shown
199 const startedAt = await $.clock.now()
200
201 countUp = $.clock.every(FRAME_MS, () => {
202 void $.clock.now().then(now => {
203 const t = Math.min(1, (now - startedAt) / COUNT_UP_MS)
204 const eased = 1 - (1 - t) ** 3
205 const isDone = t >= 1
206 if (isDone) {
207 countUp?.cancel()
208 countUp = null
209 }
210 void update($, motion, () => ({ shown: isDone ? to : from + (to - from) * eased }))
211 })
212 })
213}
214
215// ── Hooks ───────────────────────────────────────────────────────────────────
216
217export const register: Register = on => {
218 on('session.start', async ($, e, next) => {
219 await $.command.register({
220 name: 'meter',
221 description: 'Show or hide the cost-git band',
222 })
223
224 // A reload keeps $.state, so only seed what the host can tell us now.
225 const usage = await $.session.usage()
226 const total = usage.cost?.usd ?? 0
227 await update($, meter, m => ({ ...m, total, startedAt: usage.startedAt }))
228 await update($, motion, () => ({ shown: total }))
229
230 void refreshGit($)
231 $.clock.every(FETCH_EVERY_MS, () => void fetchQuietly($))
232
233 return next(e)
234 })
235
236 on('session.measure', async ($, e, next) => {
237 if (e.cost && e.changed.includes('cost')) {
238 const before = await read($, meter)
239 const total = e.cost.usd
240 const lastTurn = Math.max(0, total - before.total)
241 await update($, meter, m => ({ ...m, total, lastTurn }))
242 void animateTo($, total)
243 }
244
245 return next(e)
246 })
247
248 // A turn may have committed, switched branch or edited files.
249 on('turn.complete', ($, e, next) => {
250 void refreshGit($)
251 return next(e)
252 })
253
254 on('command.run', { command: 'meter' }, async $ => {
255 const hidden = await update($, isHidden, was => !was)
256
257 return { text: hidden ? 'cost-git band hidden. /meter brings it back.' : 'cost-git band on.' }
258 })
259
260 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
261 if (e.props.hasSurvey || (await read($, isHidden))) {
262 return next(e)
263 }
264
265 const els = $.ui.resolve(e)
266 const { Box, Button, Text } = els
267 // Select is on the terminal and desktop; elsewhere the branch is plain text.
268 const Select = 'Select' in els ? els.Select : null
269 const m: Meter = await read($, meter)
270 const v: Motion = await read($, motion)
271 const g: Git | null = await read($, git)
272
273 const gitSide = g && (
274 <Box flexDirection="row" gap={1}>
275 <Text color="#A78BFA" bold>
276 {g.repo}
277 </Text>
278 {g.busy === 'switch' || !Select ? (
279 <Text color="#60A5FA">{g.busy === 'switch' ? '⎇ switching…' : `⎇ ${g.branch}`}</Text>
280 ) : (
281 <Select
282 key="git-branch"
283 label="⎇"
284 options={g.branches.map(b => ({ value: b }))}
285 value={g.branch}
286 onSelect={(target: string) => void switchTo($, target)}
287 />
288 )}
289 {g.dirty > 0 && <Text color="#FB923C">{`●${g.dirty}`}</Text>}
290 {g.hasUpstream ? (
291 <Text dimColor={g.ahead === 0 && g.behind === 0}>{`↑${g.ahead} ↓${g.behind}`}</Text>
292 ) : (
293 <Text dimColor>no upstream</Text>
294 )}
295 {g.ci && <Text color={CI_BADGES[g.ci].color}>{`${CI_BADGES[g.ci].icon} CI`}</Text>}
296 {g.note && <Text color={g.note.isError ? '#F87171' : '#4ADE80'}>{g.note.text}</Text>}
297 <Button
298 key="git-pull"
299 label={g.busy === 'pull' ? 'Pulling…' : g.behind > 0 ? `Pull ${g.behind}` : 'Pull'}
300 dimColor={g.behind === 0}
301 onPress={() => pull($)}
302 />
303 <Button
304 key="git-push"
305 label={
306 g.busy === 'commit'
307 ? 'Committing…'
308 : g.busy === 'push'
309 ? 'Pushing…'
310 : commitsFirst(g)
311 ? g.isPushArmed
312 ? `Commit ${g.dirty} file${g.dirty === 1 ? '' : 's'} & push${g.ahead > 0 ? ` ${g.ahead + 1} commits` : ''}? ✓`
313 : g.ahead > 0
314 ? `Commit & push ${g.ahead + 1}`
315 : 'Commit & push'
316 : g.isPushArmed
317 ? `Push ${g.ahead} commit${g.ahead === 1 ? '' : 's'}? ✓`
318 : g.ahead > 0
319 ? `Push ${g.ahead}`
320 : 'Push'
321 }
322 variant={g.isPushArmed ? 'primary' : undefined}
323 dimColor={!g.isPushArmed && g.ahead === 0 && g.dirty === 0 && g.hasUpstream}
324 onPress={() => push($)}
325 />
326 </Box>
327 )
328
329 return (
330 <Box key="cost-git" flexDirection="column">
331 <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={3}>
332 <Box flexDirection="row" gap={1}>
333 <Text dimColor>Session</Text>
334 <Text bold>{money(v.shown)}</Text>
335 <Text dimColor>·</Text>
336 <Text dimColor>Last message</Text>
337 <Text bold>{money(m.lastTurn)}</Text>
338 </Box>
339 {gitSide}
340 </Box>
341 </Box>
342 )
343 })
344}
345types/index.d.ts 44 lines1export type Meter = {
2 // Session total in USD, as /cost has it.
3 total: number
4 // What the most recent turn added.
5 lastTurn: number
6 // When the session began, in $.clock.now() milliseconds.
7 startedAt: number
8}
9
10export type Motion = {
11 // The session figure as drawn right now (counts up toward Meter.total).
12 shown: number
13}
14
15export type Ci = 'pass' | 'fail' | 'running'
16
17export type Git = {
18 // The repo's folder name.
19 repo: string
20 branch: string
21 // Local branches, most recently committed first (the picker's options).
22 branches: string[]
23 // Uncommitted files.
24 dirty: number
25 ahead: number
26 behind: number
27 hasUpstream: boolean
28 // The newest GitHub Actions run on this branch; null when there is none.
29 ci: Ci | null
30 // A commit, push, pull or branch switch in flight.
31 busy: 'commit' | 'push' | 'pull' | 'switch' | null
32 // The first click on Push arms it; a second within a few seconds pushes.
33 isPushArmed: boolean
34 // The last push or pull's outcome, shown for a few seconds.
35 note: { text: string; isError: boolean } | null
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 // `git` is null outside a git repository.
41 'cost-git': { meter: Meter; motion: Motion; isHidden: boolean; git: Git | null }
42 }
43}
44