SLOPSHOPPER

band

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

newbandguardprocesstimer
v2.0.0MITupdated 2026-10-08zrdqns/claude-code-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◆ opus-5-5 ⌂ app ▰▰▰▰▱▱▱▱ 49% last 0:42 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ opus-5-5 ⌂ app ▰▰▰▰▱▱▱▱ 49% last 0:42 5h 31%
README

band

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%

What it shows

SegmentMeaning
◆ opus-5-5The session's model. The diamond pulses while a turn is running.
⎇ main ±3Current branch and uncommitted files. Outside a repository, ⌂ folder.
▰▰▱▱▱▱▱▱ 24%Context window used. Yellow between 60% and 85%.
Edit register.tsx · 0:42During a turn: the last tool called and the turn's stopwatch.
last 1:12 · $0.25When 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.

Installation

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

Requirements

  • git on the PATH for the change counter (±N). Without it, the band shows the branch with no counter.

Development

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/.

License

MIT

Source 2 files
hooks/register.tsx 283 lines
1import { 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}
283
types/index.d.ts 23 lines
1/** 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