One line above the prompt: branch, ahead/behind upstream, dirty counts and the last test result, so 'is it pushed?' answers itself.

One line above the prompt: main ↑2 ↓0 · +1 ~3 ?2 · ✓ go test ./... 4m ago.
Why: answers "already pushed to main?", "udah commit push belom?" and "did the tests pass?" at a glance instead of asking.
no upstream when none; amber while unpushed), staged + / modified ~ / untracked ? / conflicted ! counts, or clean.git --no-optional-locks status --porcelain=v2 --branch at session start, after each main turn, and after any Bash call that runs git.&&/;/| segment, after wrappers like rtk, npx, uv run, FOO=1) starts with go test, pytest, unittest, npm/pnpm/yarn/bun test, vitest, jest, cargo test, make test, mvn/gradle test, deno test.Limits: pass/fail is the Bash call's exit status, so go test | tail reports tail's. Git commands you run yourself outside Claude refresh only at the next turn end. Remote state (CI, deploys) is not checked.
hooks/register.tsx 174 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ShipGit, ShipTest } from '../types'
5
6const git = atom({ plugin: 'ship-state', key: 'git' } as const, null)
7const lastTest = atom({ plugin: 'ship-state', key: 'test' } as const, null)
8// The band's clock: a tick keeps "4m ago" moving while the session is idle.
9const now = atom({ plugin: 'ship-state', key: 'now' } as const, 0)
10
11const TICK_MS = 30_000
12const HEAD_CHARS = 24
13
14// Test runners at the start of a command segment, after any wrapper is stripped.
15const TEST = /^(go test|pytest|python3? -m (pytest|unittest)|(npm|pnpm|yarn|bun)( run)? test|npx (vitest|jest)|vitest|jest|cargo (test|nextest)|make test|mvn\b.*\btest|(\.\/)?gradlew?\b.*\btest|deno test)\b/
16const WRAPPER = /^(\w+=\S*\s+|rtk\s+|npx\s+(?=vitest|jest)|bunx\s+|pnpm exec\s+|time\s+|env\s+|uv run\s+|poetry run\s+)/
17const GIT = /(^|[\s;&|(])git\s/
18
19// The test command a shell line runs, cut to its head, or undefined.
20export function testHead(command: string): string | undefined {
21 for (const raw of command.split(/&&|\|\||[;|\n()]/)) {
22 let segment = raw.trim()
23 for (let stripped = segment.replace(WRAPPER, ''); stripped !== segment; stripped = segment.replace(WRAPPER, '')) {
24 segment = stripped
25 }
26 if (TEST.test(segment)) {
27 return segment.length > HEAD_CHARS ? `${segment.slice(0, HEAD_CHARS - 1)}…` : segment
28 }
29 }
30 return undefined
31}
32
33export function runsGit(command: string): boolean {
34 return GIT.test(command)
35}
36
37// `git status --porcelain=v2 --branch` into counts.
38export function parseStatus(out: string): ShipGit {
39 const g: ShipGit = { branch: '?', hasUpstream: false, ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0, conflicted: 0 }
40 let oid = ''
41 for (const line of out.split('\n')) {
42 const [kind, a, b] = line.split(' ')
43 if (kind === '#' && a === 'branch.oid') {
44 oid = b ?? ''
45 } else if (kind === '#' && a === 'branch.head') {
46 g.branch = b ?? '?'
47 } else if (kind === '#' && a === 'branch.upstream') {
48 g.hasUpstream = true
49 } else if (kind === '#' && a === 'branch.ab') {
50 g.ahead = Math.abs(Number(b ?? 0))
51 g.behind = Math.abs(Number(line.split(' ')[3] ?? 0))
52 } else if (kind === '1' || kind === '2') {
53 if (a?.[0] !== '.') g.staged += 1
54 if (a?.[1] !== '.') g.modified += 1
55 } else if (kind === 'u') {
56 g.conflicted += 1
57 } else if (kind === '?') {
58 g.untracked += 1
59 }
60 }
61 if (g.branch === '(detached)') {
62 g.branch = `HEAD@${oid.slice(0, 7)}`
63 }
64 return g
65}
66
67export function ago(ms: number): string {
68 const s = Math.max(0, Math.round(ms / 1000))
69 if (s < 60) return `${s}s ago`
70 if (s < 3600) return `${Math.floor(s / 60)}m ago`
71 if (s < 86_400) return `${Math.floor(s / 3600)}h ago`
72 return `${Math.floor(s / 86_400)}d ago`
73}
74
75type Part = { text: string; color?: string }
76
77// The band's line, shortened to fit `columns`: the test's command goes first,
78// then the whole test part; the branch part always stays.
79export function shipLine(g: ShipGit, test: ShipTest | null, at: number, columns: number): Part[] {
80 const sync = g.hasUpstream ? `↑${g.ahead} ↓${g.behind}` : 'no upstream'
81 const dirty = [
82 g.staged ? `+${g.staged}` : '',
83 g.modified ? `~${g.modified}` : '',
84 g.untracked ? `?${g.untracked}` : '',
85 g.conflicted ? `!${g.conflicted}` : '',
86 ].filter(Boolean).join(' ') || 'clean'
87 const base: Part[] = [{ text: `${g.branch} ${sync}`, color: g.hasUpstream && g.ahead === 0 ? undefined : 'warning' }, { text: ` · ${dirty}` }]
88 if (test === null) return base
89
90 const mark: Part = { text: ` · ${test.isPassed ? '✓' : '✗'}`, color: test.isPassed ? 'success' : 'error' }
91 const width = (parts: Part[]) => parts.reduce((n, p) => n + p.text.length, 0)
92 for (const tail of [` ${test.command} ${ago(at - test.at)}`, ` ${ago(at - test.at)}`]) {
93 const line = [...base, mark, { text: tail }]
94 if (width(line) <= columns) return line
95 }
96 return base
97}
98
99type Live = { tick?: Timer }
100
101async function refreshGit($: EngineInterface, live: Live): Promise<void> {
102 let next: ShipGit | null = null
103 try {
104 const run = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 })
105 next = run.exitCode === 0 ? parseStatus(run.stdout) : null
106 } catch {
107 next = null
108 }
109 await update($, git, () => next)
110 await tickNow($)
111 // Started lazily so a hot reload, which drops timers, gets its tick back.
112 live.tick ??= $.clock.every(TICK_MS, () => {
113 tickNow($).catch(() => undefined)
114 })
115}
116
117async function tickNow($: EngineInterface): Promise<void> {
118 const t = await $.clock.now()
119 await update($, now, () => t)
120}
121
122export const register: Register = on => {
123 const live: Live = {}
124
125 on('session.start', async ($, e, next) => {
126 const started = await next(e)
127 await refreshGit($, live)
128 return started
129 })
130
131 on('turn.complete', async ($, e, next) => {
132 const done = await next(e)
133 if (e.agentId === undefined) {
134 await refreshGit($, live)
135 }
136 return done
137 })
138
139 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
140 const ran = await next(e)
141 const head = testHead(e.command)
142 const isFinished = ran.deny === undefined && (ran.isError === true || (!ran.result.interrupted && ran.result.backgroundTaskId === undefined))
143 if (head !== undefined && isFinished) {
144 const at = await $.clock.now()
145 await update($, lastTest, () => ({ isPassed: ran.isError !== true, command: head, at }))
146 await update($, now, () => at)
147 }
148 if (runsGit(e.command)) {
149 await refreshGit($, live)
150 }
151 return ran
152 }).catch(($, e, next) => next(e))
153
154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155 const g = await read($, git)
156 if (e.props.hasSurvey || g === null) {
157 return next(e)
158 }
159 const parts = shipLine(g, await read($, lastTest), await read($, now), e.props.bodyColumns)
160 const below = await next(e)
161 const { Box, Text } = $.ui.resolve(e)
162 return (
163 <Box flexDirection="column">
164 <Box key="ship-state">
165 <Text dimColor wrap="truncate-end">
166 {parts.map(p => (p.color ? <Text color={p.color}>{p.text}</Text> : p.text))}
167 </Text>
168 </Box>
169 {below}
170 </Box>
171 )
172 })
173}
174types/index.d.ts 19 lines1export type ShipGit = {
2 branch: string
3 hasUpstream: boolean
4 ahead: number
5 behind: number
6 staged: number
7 modified: number
8 untracked: number
9 conflicted: number
10}
11
12export type ShipTest = { isPassed: boolean; command: string; at: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 'ship-state': { git: ShipGit | null; test: ShipTest | null; now: number }
17 }
18}
19