SLOPSHOPPER

status-band

Status band above the prompt: directory, git, model, effort, cost, rate limits and context.

newbandpromptprocesstimer
★ 20v0.1.0no licenseupdated 2026-10-09whatyouhide/dotfiles/claude/mods/status-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-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 /work/app ⎇ feat/auth-refresh +14 −3 Opus 5.5 5h ━━━━━ 31% ctx ━━━━━ 49% 97k/200k $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
/work/app ⎇ feat/auth-refresh +14 −3 Opus 5.5 5h ━━━━━ 31% ctx ━━━━━ 49% 97k/200k $0.42
README

Dotfiles 🥑

My dotfiles, I don't think they'll work for ya but who knows. This is for Macs (./setup). I have a runbook in Obsidian with instructions.

Linux

This now also supports Linux "remote devboxes", for the age of agents. Devboxes can run ./setup --profile devbox, either via its Ansible playbook or via dotfiles-sync from the Mac, which pulls the latest pushed commits on the box and re-runs setup there.

Source 2 files
hooks/register.tsx 354 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Git, Limit, Usage, Where } from '../types'
5
6const usage = atom({ plugin: 'status-band', key: 'usage' } as const, null)
7const git = atom({ plugin: 'status-band', key: 'git' } as const, null)
8const where = atom({ plugin: 'status-band', key: 'where' } as const, null)
9const effort = atom({ plugin: 'status-band', key: 'effort' } as const, null)
10
11// `claude-opus-4-5-20251101` reads as `Opus 4.5`; an alias (`opus`) as `Opus`.
12const modelName = (id: string) => {
13  const [, family = id, version] = /^(?:claude-)?([a-z]+)(?:-([\d-]+?))?(?:-\d{8})?(\[.*\])?$/.exec(id) ?? []
14  const suffix = /\[.*\]$/.exec(id)?.[0] ?? ''
15  const name = family.charAt(0).toUpperCase() + family.slice(1)
16
17  return `${name}${version === undefined ? '' : ` ${version.replaceAll('-', '.')}`}${suffix}`
18}
19
20const refreshWhere = async ($: EngineInterface) => {
21  const [cwd, home, model] = await Promise.all([
22    $.session.cwd(),
23    $.env.get('HOME'),
24    $.session.model(),
25  ])
26  const short = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd
27  const found: Where = { cwd: short, model: modelName(model) }
28  const shown = await read($, where)
29
30  if (shown?.cwd !== found.cwd || shown.model !== found.model) {
31    await update($, where, () => found)
32  }
33}
34
35const refreshGit = async ($: EngineInterface) => {
36  let found: Git | null = null
37
38  try {
39    const head = await $.process.run(['git', 'symbolic-ref', '--short', '-q', 'HEAD'])
40    const sha =
41      head.exitCode === 0 ? head : await $.process.run(['git', 'rev-parse', '--short', 'HEAD'])
42
43    if (sha.exitCode === 0) {
44      const stat = await $.process.run(['git', 'diff', '--shortstat', 'HEAD'])
45      const added = /(\d+) insertion/.exec(stat.stdout)?.[1] ?? '0'
46      const deleted = /(\d+) deletion/.exec(stat.stdout)?.[1] ?? '0'
47      found = { branch: sha.stdout.trim(), added: Number(added), deleted: Number(deleted) }
48    }
49  } catch {
50    found = null
51  }
52
53  await update($, git, () => found)
54}
55
56const refreshUsage = async ($: EngineInterface) => {
57  const { context, rateLimits, cost } = await $.session.usage()
58  const found: Usage = { ...context, limits: rateLimits, usd: cost?.usd }
59  await update($, usage, () => found)
60}
61
62// The engine draws the band's `[-]` collapse control over its top right.
63const COLLAPSE_CELLS = 4
64
65const GREEN = '#4ade80'
66const AMBER = '#fbbf24'
67const RED = '#f87171'
68const TRACK = '#88888855'
69
70const levelColor = (percent: number) => (percent >= 85 ? RED : percent >= 60 ? AMBER : GREEN)
71
72const clamp = (percent: number) => Math.min(100, Math.max(0, percent))
73
74const timeLeft = (limit: Limit | undefined, now: number) => {
75  const ms = limit?.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) - now
76
77  if (Number.isNaN(ms) || ms <= 0) {
78    return undefined
79  }
80
81  const hours = Math.floor(ms / 3_600_000)
82  const minutes = Math.floor((ms % 3_600_000) / 60_000)
83
84  return hours >= 24 ? `${Math.floor(hours / 24)}d${hours % 24}h` : `${hours}h${minutes}m`
85}
86
87const tokens = (count: number) =>
88  count >= 1_000_000 ? `${(count / 1_000_000).toFixed(1)}M` : `${Math.round(count / 1000)}k`
89
90const ring = (percent: number) => {
91  const around = 2 * Math.PI * 7
92  const arc = (clamp(percent) / 100) * around
93
94  return (
95    '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">' +
96    `<circle cx="9" cy="9" r="7" fill="none" stroke="${TRACK}" stroke-width="2.5"/>` +
97    `<circle cx="9" cy="9" r="7" fill="none" stroke="${levelColor(percent)}" stroke-width="2.5" ` +
98    `stroke-linecap="round" stroke-dasharray="${arc.toFixed(2)} ${around.toFixed(2)}" ` +
99    'transform="rotate(-90 9 9)"/></svg>'
100  )
101}
102
103const METER_WIDTH = 132
104
105const meterStops = (percent: number) =>
106  percent >= 85 ? [RED, '#fca5a5'] : percent >= 60 ? [AMBER, '#fcd34d'] : [GREEN, '#86efac']
107
108// The context meter: a pill in the level color, a little lighter at its tip.
109const meter = (percent: number) => {
110  const inner = METER_WIDTH - 2
111  const fill = percent <= 0 ? 0 : Math.max(8, (clamp(percent) / 100) * inner)
112  const [from, to] = meterStops(percent)
113
114  return (
115    `<svg xmlns="http://www.w3.org/2000/svg" width="${METER_WIDTH}" height="18" viewBox="0 0 ${METER_WIDTH} 18">` +
116    `<defs><linearGradient id="fill" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="${from}"/>` +
117    `<stop offset="1" stop-color="${to}"/></linearGradient></defs>` +
118    `<rect x="1" y="5" width="${inner}" height="8" rx="4" fill="${TRACK}"/>` +
119    `<rect x="1" y="5" width="${fill.toFixed(1)}" height="8" rx="4" fill="url(#fill)"/>` +
120    '</svg>'
121  )
122}
123
124const COIN =
125  '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">' +
126  `<circle cx="9" cy="9" r="7.5" fill="${GREEN}" fill-opacity="0.16" stroke="${GREEN}" stroke-width="1.5"/>` +
127  `<text x="9" y="12.6" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10.5" ` +
128  `font-weight="700" fill="${GREEN}">$</text></svg>`
129
130type Gauge = { label: string; percent: number; note?: string }
131
132const gaugesOf = (used: Usage | null, now: number): Gauge[] => {
133  const fiveHour = used?.limits.find(limit => limit.kind === 'five_hour')
134  const sevenDay = used?.limits.find(limit => limit.kind === 'seven_day')
135  const found: Gauge[] = []
136
137  if (fiveHour !== undefined) {
138    found.push({ label: '5h', percent: fiveHour.percentUsed, note: timeLeft(fiveHour, now) })
139  }
140
141  if (sevenDay !== undefined) {
142    found.push({ label: '7d', percent: sevenDay.percentUsed, note: timeLeft(sevenDay, now) })
143  }
144
145  if (used?.percent !== undefined) {
146    const note = used.tokens === undefined ? undefined : `${tokens(used.tokens)}/${tokens(used.window)}`
147    found.push({ label: 'ctx', percent: used.percent, note })
148  }
149
150  return found
151}
152
153export const register: Register = on => {
154  on('session.start', async ($, e, next) => {
155    await Promise.all([refreshWhere($), refreshGit($), refreshUsage($)])
156
157    return next(e)
158  })
159
160  // `/model`, `/cd` and the config menu change what the band shows with no
161  // turn in between, and a picker may answer after its command returned.
162  on('command.run', async ($, e, next) => {
163    const ran = await next(e)
164    await Promise.all([refreshWhere($), refreshGit($)])
165    $.clock.after(2000, () => void refreshWhere($))
166
167    return ran
168  })
169
170  on('config.set', async ($, e, next) => {
171    const set = await next(e)
172    await refreshWhere($)
173
174    return set
175  })
176
177  on('prompt.submit', async ($, e, next) => {
178    await refreshWhere($)
179
180    return next(e)
181  })
182
183  on('turn.step', async function* ($, e, next) {
184    await update($, effort, () => (e.effort === undefined ? null : String(e.effort)))
185
186    return yield* next(e)
187  })
188
189  on('turn.complete', async ($, e, next) => {
190    const done = await next(e)
191    await Promise.all([refreshWhere($), refreshGit($)])
192
193    return done
194  })
195
196  on('session.measure', async ($, e, next) => {
197    const found: Usage = { ...e.context, limits: e.rateLimits, usd: e.cost?.usd }
198    await update($, usage, () => found)
199
200    return next(e)
201  })
202
203  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
204    const place = await read($, where)
205
206    if (e.props.hasSurvey || place === null) {
207      return next(e)
208    }
209
210    const repo = await read($, git)
211    const used = await read($, usage)
212    const level = await read($, effort)
213    const gauges = gaugesOf(used, await $.clock.now())
214    const cost = used?.usd === undefined ? undefined : `$${used.usd.toFixed(2)}`
215    const isDirty = repo !== null && repo.added + repo.deleted > 0
216    const columns = e.props.bodyColumns
217
218    // Desktop: a card of ring gauges. The app already shows folder and model.
219    if (e.surface === 'desktop') {
220      const { Box, Text, Svg } = $.ui.resolve(e)
221      const context = gauges.find(gauge => gauge.label === 'ctx')
222
223      return (
224        <Box
225          flexDirection="row"
226          justifyContent="space-between"
227          alignItems="center"
228          borderStyle="round"
229          borderDimColor
230          paddingX={1}
231        >
232          <Box flexDirection="row" alignItems="center" gap={3}>
233            {gauges
234              .filter(gauge => gauge.label !== 'ctx')
235              .map(gauge => (
236                <Box flexDirection="row" alignItems="center" gap={1}>
237                  <Svg
238                    source={ring(gauge.percent)}
239                    alt={`${gauge.label} ${Math.round(gauge.percent)}%`}
240                    width={18}
241                    height={18}
242                  />
243                  <Text bold>{Math.round(gauge.percent)}%</Text>
244                  <Text dimColor>
245                    {gauge.label}
246                    {gauge.note === undefined ? '' : ` · ${gauge.note}`}
247                  </Text>
248                </Box>
249              ))}
250            {context !== undefined && (
251              <Box flexDirection="row" alignItems="center" gap={1}>
252                <Svg
253                  source={meter(context.percent)}
254                  alt={`context ${context.percent}%`}
255                  width={METER_WIDTH}
256                  height={18}
257                />
258                <Text bold>{context.percent}%</Text>
259                <Text dimColor>ctx{context.note === undefined ? '' : ` · ${context.note}`}</Text>
260              </Box>
261            )}
262          </Box>
263          <Box flexDirection="row" alignItems="center" gap={2}>
264            {repo !== null && <Text dimColor>⎇ {repo.branch}</Text>}
265            {repo !== null && isDirty && <Text color={GREEN}>+{repo.added}</Text>}
266            {repo !== null && isDirty && <Text color={RED}>−{repo.deleted}</Text>}
267            {used?.usd !== undefined && (
268              <Box flexDirection="row" alignItems="center" gap={1}>
269                <Svg source={COIN} alt="cost" width={18} height={18} />
270                <Box flexDirection="row">
271                  <Text bold color={GREEN}>
272                    {Math.floor(used.usd)}
273                  </Text>
274                  <Text color={GREEN} dimColor>
275                    .{used.usd.toFixed(2).split('.')[1]}
276                  </Text>
277                </Box>
278              </Box>
279            )}
280          </Box>
281        </Box>
282      )
283    }
284
285    const { Box, Text } = $.ui.resolve(e)
286    const cells = columns >= 110 ? 8 : 5
287    const isNarrow = columns < 70
288
289    const meters = (
290      <Box flexDirection="row" gap={isNarrow ? 2 : 3}>
291        {gauges.map(gauge => {
292          const filled = Math.round((clamp(gauge.percent) * cells) / 100)
293
294          return (
295            <Box flexDirection="row" gap={1}>
296              <Text dimColor>{gauge.label}</Text>
297              {!isNarrow && (
298                <Box flexDirection="row">
299                  <Text color={levelColor(gauge.percent)}>{'━'.repeat(filled)}</Text>
300                  <Text dimColor>{'━'.repeat(cells - filled)}</Text>
301                </Box>
302              )}
303              <Text bold color={levelColor(gauge.percent)}>
304                {Math.round(gauge.percent)}%
305              </Text>
306              {!isNarrow && gauge.note !== undefined && <Text dimColor>{gauge.note}</Text>}
307            </Box>
308          )
309        })}
310      </Box>
311    )
312
313    if (isNarrow) {
314      return (
315        <Box flexDirection="row" justifyContent="space-between" width={columns}>
316          {meters}
317          {cost !== undefined && <Text color={GREEN}>{cost}</Text>}
318        </Box>
319      )
320    }
321
322    return (
323      <Box flexDirection="column" width={columns}>
324        <Box flexDirection="row" justifyContent="space-between" paddingRight={COLLAPSE_CELLS}>
325          <Box flexDirection="row" gap={2}>
326            <Text bold color="cyan">
327              {place.cwd}
328            </Text>
329            {repo !== null && <Text color="magenta">⎇ {repo.branch}</Text>}
330            {repo !== null && isDirty && (
331              <Box flexDirection="row" gap={1}>
332                <Text color={GREEN}>+{repo.added}</Text>
333                <Text color={RED}>−{repo.deleted}</Text>
334              </Box>
335            )}
336          </Box>
337          <Box flexDirection="row" gap={1}>
338            <Text>{place.model}</Text>
339            {level !== null && <Text dimColor>· {level}</Text>}
340          </Box>
341        </Box>
342        <Box flexDirection="row" justifyContent="space-between">
343          {meters}
344          {cost !== undefined && (
345            <Text bold color={GREEN}>
346              {cost}
347            </Text>
348          )}
349        </Box>
350      </Box>
351    )
352  })
353}
354
types/index.d.ts 25 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = {
4  tokens?: number
5  window: number
6  percent?: number
7  limits: Limit[]
8  usd?: number
9}
10
11export type Git = { branch: string; added: number; deleted: number }
12
13export type Where = { cwd: string; model: string }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'status-band': {
18      usage: Usage | null
19      git: Git | null
20      where: Where | null
21      effort: string | null
22    }
23  }
24}
25