Band above the prompt with model, branch and changes (or folder), context, turn activity and the 5-hour limit; the diamond pulses while a turn is running

A mod for Claude Code: a one-line band above the prompt with what is worth keeping in sight while you work.
◆ opus-5-5 ⎇ main ±3 ▰▰▱▱▱▱▱▱ 24% Edit register.tsx · 0:42 5h 38%
| Segment | Meaning |
|---|---|
◆ opus-5-5 | The session's model. The diamond pulses while a turn is running. |
⎇ main ±3 | Current branch and uncommitted files. Outside a repository, ⌂ folder. |
▰▰▱▱▱▱▱▱ 24% | Context window used. Yellow between 60% and 85%. |
Edit register.tsx · 0:42 | During a turn: the last tool called and the turn's stopwatch. |
last 1:12 · $0.25 | When idle: the last turn's length and cost. |
5h 38% | 5-hour usage limit. Yellow from 60%, red from 85%. |
The left side keeps its width; when a side pane narrows the band, the tool's label gives way first.
At the prompt of a terminal session:
/plugin install band --marketplace zrdqns/claude-code-band
Answer y to add the marketplace and choose the scope (the user scope loads it in every session, including the desktop app's).
To try it from a local copy, without installing it:
claude --plugin-dir ./claude-code-band
git on the PATH for the change counter (±N). Without it, the band shows the branch with no counter.claude plugin validate .
claude plugin test .
The module is in hooks/register.tsx, its state contract in types/index.d.ts and the tests in tests/.
hooks/register.tsx 283 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4const place = atom({ plugin: 'band', key: 'place' } as const, {
5 branch: null,
6 changes: 0,
7 folder: null,
8})
9const turn = atom({ plugin: 'band', key: 'turn' } as const, null)
10const last = atom({ plugin: 'band', key: 'last' } as const, null)
11
12// Cells of the context gauge.
13const CELLS = 8
14// One frame of the band while a turn runs; the desktop redraws ten times a second at most.
15const FRAME_MS = 120
16// Frames the timer outlives the band last seeing the turn at work.
17const IDLE_FRAMES = 25
18
19// The band's accent: a red livelier than the theme's.
20const ACCENT = '#ff3b30'
21
22// The diamond's beat while a turn runs, one step every other frame.
23const PULSE = '◇◈◆◈'
24
25// How long git may take to list the uncommitted files.
26const STATUS_MS = 3000
27// The argument that says what a tool call is about: the first of these it carries.
28const SUBJECTS = [
29 'file_path',
30 'notebook_path',
31 'command',
32 'pattern',
33 'query',
34 'url',
35 'skill',
36 'description',
37]
38// Characters of that argument the band keeps.
39const MAX_SUBJECT = 28
40// A session the desktop app started with no folder runs in one of these.
41const SCRATCH = /[\\/]scratch-workspaces[\\/]/
42
43const tone = (percent: number) =>
44 percent >= 60 && percent < 85 ? 'warning' : ACCENT
45
46const baseName = (path: string) =>
47 path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
48
49const clock = (ms: number) => {
50 const s = Math.max(0, Math.floor(ms / 1000))
51 const rest = `${Math.floor(s / 60) % 60}:${String(s % 60).padStart(2, '0')}`
52
53 return s >= 3600 ? `${Math.floor(s / 3600)}:${rest.padStart(5, '0')}` : rest
54}
55
56/** A tool call in a few words: `Edit register.tsx`, `Bash git status`. */
57const labelOf = (e: Record<string, unknown>) => {
58 const name = String(e.tool).replace(/^mcp__.*__/, '')
59 const key = SUBJECTS.find(one => typeof e[one] === 'string')
60 if (key === undefined) return name
61 const said = String(e[key])
62 const subject = key.endsWith('_path')
63 ? baseName(said)
64 : (said.trim().split('\n')[0] ?? '').replace(/^https?:\/\//, '')
65 if (subject === '') return name
66
67 return subject.length > MAX_SUBJECT
68 ? `${name} ${subject.slice(0, MAX_SUBJECT - 1)}…`
69 : `${name} ${subject}`
70}
71
72const refresh = async ($: EngineInterface) => {
73 let name: string | null = null
74 try {
75 const head = await $.fs.read('.git/HEAD')
76 name =
77 /^ref: refs\/heads\/(.+)$/m.exec(head)?.[1] ??
78 (head.trim().slice(0, 7) || null)
79 } catch {
80 // Not a repository (or a worktree's .git file): the band draws the folder.
81 }
82 // Without the optional locks, so a git command of the turn's never waits on this one.
83 const changes =
84 name === null
85 ? 0
86 : await $.process
87 .run(['git', '--no-optional-locks', 'status', '--porcelain'], {
88 timeoutMs: STATUS_MS,
89 })
90 .then(ran =>
91 ran.exitCode === 0
92 ? ran.stdout.split('\n').filter(line => line.trim() !== '').length
93 : 0,
94 )
95 .catch(() => 0)
96 const cwd = await $.session.cwd().catch(() => '')
97 const folder = SCRATCH.test(cwd) ? 'no folder' : baseName(cwd) || null
98 await update($, place, () => ({ branch: name, changes, folder }))
99}
100
101export const register: Register = on => {
102 let timer: Timer | undefined
103 let idle = 0
104
105 on('session.start', async ($, e, next) => {
106 await refresh($)
107
108 return next(e)
109 })
110
111 on('turn.start', async ($, e, next) => {
112 timer?.cancel()
113 idle = 0
114 timer = $.clock.every(FRAME_MS, () => {
115 // A turn that ended with no turn.complete would leave this running.
116 idle += 1
117 if (idle > IDLE_FRAMES) {
118 timer?.cancel()
119 } else {
120 $.ui.invalidate('ui.render')
121 }
122 })
123 const [startedAt, { cost }] = await Promise.all([
124 $.clock.now(),
125 $.session.usage(),
126 ])
127 await update($, turn, () => ({
128 startedAt,
129 tool: null,
130 usd: cost?.usd ?? null,
131 }))
132
133 return next(e)
134 })
135
136 // The main loop's calls only: a subagent's are its own business.
137 on('tool.call', async ($, e, next) => {
138 if (e.agentId === undefined) {
139 const tool = labelOf(e)
140 await update($, turn, one => one && { ...one, tool })
141 }
142
143 return next(e)
144 })
145
146 on('turn.complete', async ($, e, next) => {
147 if (e.agentId !== undefined) {
148 return next(e)
149 }
150
151 timer?.cancel()
152 const [began, { cost }] = await Promise.all([
153 read($, turn),
154 $.session.usage(),
155 ])
156 const usd =
157 began === null || began.usd === null || cost === undefined
158 ? null
159 : Math.max(0, cost.usd - began.usd)
160 await update($, turn, () => null)
161 await update($, last, () => ({ ms: e.durationMs, usd }))
162 await refresh($)
163
164 return next(e)
165 })
166
167 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168 if (e.props.hasSurvey) {
169 return next(e)
170 }
171
172 const { Box, Text } = $.ui.resolve(e)
173 const [model, usage, where, running, spent, now] = await Promise.all([
174 $.session.model(),
175 $.session.usage(),
176 read($, place),
177 read($, turn),
178 read($, last),
179 $.clock.now(),
180 ])
181 const { isWorking } = e.props
182 if (isWorking) {
183 idle = 0
184 }
185 const { percent } = usage.context
186 const full = Math.round((Math.min(100, percent ?? 0) / 100) * CELLS)
187 const fiveHour = usage.rateLimits.find(
188 limit => limit.kind === 'five_hour',
189 )
190 const used =
191 fiveHour === undefined ? undefined : Math.round(fiveHour.percentUsed)
192
193 // The left end holds its width; the right end gives way, the tool's label
194 // first, where a docked pane leaves the band narrow.
195 return (
196 <Box
197 flexDirection="row"
198 justifyContent="space-between"
199 gap={2}
200 width="100%"
201 >
202 <Box flexDirection="row" gap={2} flexShrink={0}>
203 <Box flexDirection="row" gap={1}>
204 <Text color={ACCENT}>
205 {isWorking
206 ? `${PULSE[Math.floor(now / FRAME_MS / 2) % PULSE.length]}`
207 : '◆'}
208 </Text>
209 <Text bold>{model.replace(/^claude-/, '')}</Text>
210 </Box>
211
212 {where.branch !== null && (
213 <Box flexDirection="row" gap={1}>
214 <Text dimColor>⎇</Text>
215 <Text>{where.branch}</Text>
216 {where.changes > 0 && <Text dimColor>{`±${where.changes}`}</Text>}
217 </Box>
218 )}
219
220 {where.branch === null && where.folder !== null && (
221 <Box flexDirection="row" gap={1}>
222 <Text dimColor>⌂</Text>
223 <Text>{where.folder}</Text>
224 </Box>
225 )}
226
227 {percent !== undefined && (
228 <Box flexDirection="row" gap={1}>
229 <Box flexDirection="row">
230 {full > 0 && (
231 <Text color={tone(percent)}>{'▰'.repeat(full)}</Text>
232 )}
233 {full < CELLS && (
234 <Text dimColor>{'▱'.repeat(CELLS - full)}</Text>
235 )}
236 </Box>
237 <Text dimColor>{`${percent}%`}</Text>
238 </Box>
239 )}
240 </Box>
241
242 <Box flexDirection="row" gap={2} minWidth={0}>
243 {isWorking && running !== null && (
244 <Box flexDirection="row" gap={1} minWidth={0}>
245 {running.tool !== null && (
246 <Text wrap="truncate-end">{running.tool}</Text>
247 )}
248 <Box flexShrink={0}>
249 <Text dimColor>
250 {running.tool === null
251 ? clock(now - running.startedAt)
252 : `· ${clock(now - running.startedAt)}`}
253 </Text>
254 </Box>
255 </Box>
256 )}
257
258 {!(isWorking && running !== null) && spent !== null && (
259 <Box flexShrink={0}>
260 <Text dimColor>
261 {spent.usd !== null && spent.usd >= 0.01
262 ? `last ${clock(spent.ms)} · $${spent.usd.toFixed(2)}`
263 : `last ${clock(spent.ms)}`}
264 </Text>
265 </Box>
266 )}
267
268 {used !== undefined && (
269 <Box flexDirection="row" gap={1} flexShrink={0}>
270 <Text dimColor>5h</Text>
271 {used < 60 ? (
272 <Text dimColor>{`${used}%`}</Text>
273 ) : (
274 <Text color={tone(used)}>{`${used}%`}</Text>
275 )}
276 </Box>
277 )}
278 </Box>
279 </Box>
280 )
281 })
282}
283types/index.d.ts 23 lines1/** Where the session works: its branch and uncommitted files, else its folder. */
2export type Place = {
3 branch: string | null
4 changes: number
5 folder: string | null
6}
7
8/** The main loop's turn in flight: its start, its last tool, the cost then. */
9export type Turn = {
10 startedAt: number
11 tool: string | null
12 usd: number | null
13} | null
14
15/** What the last finished turn took. */
16export type Last = { ms: number; usd: number | null } | null
17
18declare module 'claude-code' {
19 interface PluginState {
20 band: { place: Place; turn: Turn; last: Last }
21 }
22}
23