A band above the prompt that says in plain words whether your work is saved and in sync with GitHub

A one-line band above the Claude Code prompt that says, in plain language, whether your work is saved and matches GitHub. Read-only: it only runs git status, never changes anything.
| You see | It means | What to do |
|---|---|---|
| ✓ Everything is saved and matches GitHub | Nothing to do | — |
| ● N files have changes that are not saved yet | Edits not committed | /save |
| ↑ Saved on this computer, not on GitHub yet | Committed, not pushed | /save |
| ↓ GitHub has newer updates | The other account pushed | /start |
| ⇅ Both have new work | Needs care | Ask Claude to sort it out |
It refreshes every 15 seconds, after each turn, and when you press Refresh. Hide hides it for the session. "Behind GitHub" is based on the last time git contacted GitHub, so /start (which fetches) is what makes it fully current.
claude --plugin-dir ./mods/git-status-band
Needs Claude Code 2.1.286 or newer with mods. Validate with claude plugin validate mods/git-status-band using the same version (an older claude on PATH will report false errors).
hooks/register.tsx 129 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitSummary } from '../types'
5
6const summary = atom({ plugin: 'git-status-band', key: 'summary' } as const, null)
7const isHidden = atom({ plugin: 'git-status-band', key: 'isHidden' } as const, false)
8
9const REFRESH_MS = 15000
10
11// Turns `git status --porcelain=v1 -b` output into a small summary.
12// First line looks like: "## main...origin/main [ahead 1, behind 2]"
13function parse(stdout: string): GitSummary {
14 const lines = stdout.split('\n').filter(line => line.length > 0)
15 const head = lines[0] ?? ''
16 const changed = lines.length - 1
17
18 const branch = head.replace(/^## /, '').split('...')[0].replace(/^No commits yet on /, '')
19 const hasUpstream = head.includes('...')
20 const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
21 const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
22
23 let kind: GitSummary['kind'] = 'synced'
24 if (changed > 0) kind = 'dirty'
25 else if (ahead > 0 && behind > 0) kind = 'diverged'
26 else if (ahead > 0) kind = 'ahead'
27 else if (behind > 0) kind = 'behind'
28 else if (!hasUpstream) kind = 'no-upstream'
29
30 return { kind, branch, changed, ahead, behind }
31}
32
33function plural(n: number, one: string, many: string) {
34 return `${n} ${n === 1 ? one : many}`
35}
36
37// The sentence the user sees: plain language, no git jargon.
38function describe(s: GitSummary): { icon: string; color: string; text: string } {
39 switch (s.kind) {
40 case 'dirty':
41 return {
42 icon: '●',
43 color: 'warning',
44 text: `${plural(s.changed, 'file has', 'files have')} changes that are not saved yet. Say /save when you are done.`,
45 }
46 case 'ahead':
47 return {
48 icon: '↑',
49 color: 'warning',
50 text: `Saved on this computer, but ${plural(s.ahead, 'update is', 'updates are')} not on GitHub yet. /save sends them.`,
51 }
52 case 'behind':
53 return {
54 icon: '↓',
55 color: 'warning',
56 text: `GitHub has ${plural(s.behind, 'newer update', 'newer updates')} from the other account. Say /start to get them.`,
57 }
58 case 'diverged':
59 return {
60 icon: '⇅',
61 color: 'error',
62 text: 'This computer and GitHub both have new work. Ask Claude to sort it out before continuing.',
63 }
64 case 'no-upstream':
65 return { icon: '○', color: 'warning', text: 'This work is not connected to GitHub yet.' }
66 default:
67 return { icon: '✓', color: 'success', text: 'Everything is saved and matches GitHub.' }
68 }
69}
70
71let lastKey = ''
72let isRunning = false
73
74async function refresh($: EngineInterface) {
75 if (isRunning) return
76 isRunning = true
77 try {
78 const { exitCode, stdout } = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], {
79 timeoutMs: 10000,
80 })
81 const next = exitCode === 0 ? parse(stdout) : null
82 const key = JSON.stringify(next)
83 if (key !== lastKey) {
84 lastKey = key
85 await update($, summary, () => next)
86 }
87 } catch {
88 // git missing or not a repo: show nothing
89 if (lastKey !== 'null') {
90 lastKey = 'null'
91 await update($, summary, () => null)
92 }
93 } finally {
94 isRunning = false
95 }
96}
97
98export const register: Register = on => {
99 on('session.start', async ($, e, next) => {
100 void refresh($)
101 $.clock.every(REFRESH_MS, () => refresh($))
102 return next(e)
103 })
104
105 on('turn.complete', async ($, e, next) => {
106 void refresh($)
107 return next(e)
108 })
109
110 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111 const s = await read($, summary)
112 if (e.props.hasSurvey || s === null || (await read($, isHidden))) {
113 return next(e)
114 }
115
116 const { Box, Button, Text } = $.ui.resolve(e)
117 const { icon, color, text } = describe(s)
118
119 return (
120 <Box flexDirection="row" columnGap={1}>
121 <Text color={color}>{icon}</Text>
122 <Text dimColor>{text}</Text>
123 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
124 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
125 </Box>
126 )
127 })
128}
129types/index.d.ts 14 lines1export type GitSummary = {
2 kind: 'none' | 'synced' | 'dirty' | 'ahead' | 'behind' | 'diverged' | 'no-upstream'
3 branch: string
4 changed: number
5 ahead: number
6 behind: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'git-status-band': { summary: GitSummary | null; isHidden: boolean }
12 }
13}
14