SLOPSHOPPER

status-band

A band above the prompt with folder, git branch, model and effort, and colored meters for context and the 5-hour and weekly limits

newbandprocesstimer
v0.1.0no licenseupdated 2026-10-14adamico/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 detached* ctx ▰▰▰▰▱▱▱▱ 49% 5h ▰▰▱▱▱▱▱▱ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
/work/app detached* ctx ▰▰▰▰▱▱▱▱ 49% 5h ▰▰▱▱▱▱▱▱ 31%
README

status-band

A Claude Code mod that draws a band above the prompt: the folder and git branch (* when dirty), the model and effort, and colored meters for the context window and the 5-hour and weekly limits, with reset countdowns. Meters go green, yellow at 50%, red at 80%.

Install

/plugin install status-band --marketplace adamico/status-band

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 161 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Git, Usage } from '../types'
5import { LIMIT_LABELS, countdown, fit, folderName, levelColor, meter, modelName, shortFolder } from './format'
6
7const cwd = atom({ plugin: 'status-band', key: 'cwd' } as const, null)
8const git = atom({ plugin: 'status-band', key: 'git' } as const, null)
9const model = atom({ plugin: 'status-band', key: 'model' } as const, null)
10const effort = atom({ plugin: 'status-band', key: 'effort' } as const, null)
11const usage = atom({ plugin: 'status-band', key: 'usage' } as const, { limits: [] })
12const now = atom({ plugin: 'status-band', key: 'now' } as const, 0)
13
14function usageOf(e: { context: { percent?: number }; rateLimits: readonly Usage['limits'][number][] }): Usage {
15  return {
16    contextPercent: e.context.percent,
17    limits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
18  }
19}
20
21async function lookUpGit($: EngineInterface): Promise<Git | null> {
22  const { exitCode, stdout } = await $.process.run(['git', 'status', '--porcelain', '--branch'])
23
24  if (exitCode !== 0) {
25    return null
26  }
27
28  const [head = '', ...changes] = stdout.split('\n').filter(line => line !== '')
29  const branch = /^## (?:No commits yet on )?([^.\s]+)/.exec(head)?.[1] ?? 'detached'
30
31  return { branch: branch === 'HEAD' ? 'detached' : branch, isDirty: changes.length > 0 }
32}
33
34async function tick($: EngineInterface) {
35  const time = await $.clock.now()
36  await update($, now, () => time)
37}
38
39async function refreshGit($: EngineInterface) {
40  const repo = await lookUpGit($)
41  await update($, git, () => repo)
42}
43
44async function refreshPlace($: EngineInterface) {
45  const [dir, home, repo] = await Promise.all([$.session.cwd(), $.env.get('HOME'), lookUpGit($)])
46  await update($, cwd, () => folderName(dir, home))
47  await update($, git, () => repo)
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    const result = await next(e)
53    await refreshPlace($)
54    const figures = usageOf(await $.session.usage())
55    await update($, usage, () => figures)
56    await tick($)
57    $.clock.every(60_000, () => void tick($))
58
59    // Until the first turn names the model, the /config row stands in for it.
60    const row = (await $.config.list()).find(r => r.key === 'model')
61    if (typeof row?.value === 'string' && row.value !== '' && (await read($, model)) === null) {
62      const configured = row.value
63      await update($, model, () => configured)
64    }
65
66    return result
67  })
68
69  on('session.measure', async ($, e, next) => {
70    await update($, usage, () => usageOf(e))
71
72    return next(e)
73  })
74
75  on('turn.step', async function* ($, e, next) {
76    if (e.agentId === undefined) {
77      await update($, model, () => e.model)
78      await update($, effort, () => (e.effort === undefined ? null : String(e.effort)))
79    }
80
81    return yield* next(e)
82  })
83
84  on('turn.complete', async ($, e, next) => {
85    const result = await next(e)
86    await refreshGit($)
87
88    return result
89  })
90
91  on('classic.CwdChanged', async ($, e, next) => {
92    const result = await next(e)
93    await refreshPlace($)
94
95    return result
96  }).catch(($, e, next) => next(e))
97
98  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99    if (e.props.hasSurvey) {
100      return next(e)
101    }
102
103    const { Box, Text } = $.ui.resolve(e)
104    const [dir, repo, id, level, figures, time] = await Promise.all([
105      read($, cwd),
106      read($, git),
107      read($, model),
108      read($, effort),
109      read($, usage),
110      read($, now),
111    ])
112
113    const branch = repo ? `${repo.branch}${repo.isDirty ? '*' : ''}` : undefined
114    const gauges = [
115      ...(figures.contextPercent === undefined ? [] : [{ label: 'ctx', percent: figures.contextPercent, left: undefined }]),
116      ...figures.limits.map(limit => ({
117        label: LIMIT_LABELS[limit.kind] ?? limit.kind,
118        percent: limit.percentUsed,
119        left: countdown(limit.resetsAt, time),
120      })),
121    ].map(g => ({ ...g, value: `${Math.round(g.percent)}%` }))
122    const band = {
123      folder: dir ?? undefined,
124      branch,
125      model: id ? `◆ ${modelName(id)}${level ? ` ${level}` : ''}` : undefined,
126      gauges,
127    }
128    const layout = fit(band, e.props.bodyColumns)
129
130    return (
131      <Box flexDirection="row" flexWrap="wrap" columnGap={layout.gap} marginTop={1} width={e.props.bodyColumns}>
132        {dir && (
133          <Text>
134            <Text bold color="suggestion">{layout.isFolderShort ? shortFolder(dir) : dir}</Text>
135            {repo && <Text color={repo.isDirty ? 'warning' : 'success'}>{`  ${branch}`}</Text>}
136          </Text>
137        )}
138        {id && (
139          <Text>
140            <Text color="claude">{`◆ ${modelName(id)}`}</Text>
141            {level && <Text italic dimColor>{` ${level}`}</Text>}
142          </Text>
143        )}
144        {gauges.map(gauge => {
145          const { full, empty } = meter(gauge.percent, layout.cells)
146
147          return (
148            <Text>
149              <Text dimColor>{`${gauge.label} `}</Text>
150              <Text color={levelColor(gauge.percent)}>{full}</Text>
151              <Text dimColor>{empty}</Text>
152              <Text bold color={levelColor(gauge.percent)}>{` ${gauge.value}`}</Text>
153              {gauge.left && <Text dimColor>{` ↻${gauge.left}`}</Text>}
154            </Text>
155          )
156        })}
157      </Box>
158    )
159  })
160}
161
hooks/format.ts 84 lines
1// `claude-opus-5-5[1m]` → `Opus 5.5`; anything else as given.
2export const modelName = (id: string) => {
3  const match = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
4
5  if (!match) {
6    return id
7  }
8
9  const [, family = '', major, minor] = match
10
11  return `${family[0]?.toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}`
12}
13
14// `/home/me/a/b/c` → `~/a/b/c`, cut to its last two parts past three.
15export const folderName = (cwd: string, home: string | undefined) => {
16  const path = home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd
17  const parts = path.split('/')
18
19  return parts.filter(Boolean).length > 3 ? `…/${parts.slice(-2).join('/')}` : path
20}
21
22export const levelColor = (percent: number) =>
23  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
24
25export const meter = (percent: number, cells = 8) => {
26  const full = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
27
28  return { full: '▰'.repeat(full), empty: '▱'.repeat(cells - full) }
29}
30
31// Time until `resetsAt`: `42m`, `2h14m`, `3d4h`; undefined once past or unknown.
32export const countdown = (resetsAt: string | undefined, now: number) => {
33  const ms = resetsAt ? Date.parse(resetsAt) - now : Number.NaN
34
35  if (!(ms > 0)) {
36    return undefined
37  }
38
39  const minutes = Math.ceil(ms / 60_000)
40  const [d, h, m] = [Math.floor(minutes / 1440), Math.floor((minutes % 1440) / 60), minutes % 60]
41
42  return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
43}
44
45export const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
46
47// How the band is drawn: meter cells, columns between parts, and the folder
48// whole or as its last part.
49export type Layout = { cells: number; gap: number; isFolderShort: boolean }
50
51// Tried in order: the first that fits on one row is drawn; the last wraps.
52export const LAYOUTS: readonly Layout[] = [
53  { cells: 8, gap: 3, isFolderShort: false },
54  { cells: 6, gap: 2, isFolderShort: false },
55  { cells: 4, gap: 2, isFolderShort: false },
56  { cells: 4, gap: 2, isFolderShort: true },
57  { cells: 3, gap: 2, isFolderShort: true },
58]
59
60export type Gauge = { label: string; value: string; left?: string }
61
62// The band's texts, all but the meters.
63export type Band = { folder?: string; branch?: string; model?: string; gauges: Gauge[] }
64
65export const shortFolder = (folder: string) => folder.split('/').filter(Boolean).at(-1) ?? folder
66
67const placeText = (band: Band, layout: Layout) =>
68  band.folder === undefined
69    ? undefined
70    : `${layout.isFolderShort ? shortFolder(band.folder) : band.folder}${band.branch ? `  ${band.branch}` : ''}`
71
72export const gaugeText = (gauge: Gauge, cells: number) =>
73  `${gauge.label} ${'▰'.repeat(cells)} ${gauge.value}${gauge.left ? ` ↻${gauge.left}` : ''}`
74
75export const widthOf = (band: Band, layout: Layout) => {
76  const parts = [placeText(band, layout), band.model, ...band.gauges.map(g => gaugeText(g, layout.cells))]
77    .filter((part): part is string => part !== undefined)
78
79  return parts.reduce((sum, part) => sum + [...part].length, 0) + layout.gap * (parts.length - 1)
80}
81
82export const fit = (band: Band, columns: number): Layout =>
83  LAYOUTS.find(layout => widthOf(band, layout) <= columns) ?? (LAYOUTS.at(-1) as Layout)
84
types/index.d.ts 20 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = { contextPercent?: number; limits: Limit[] }
4
5export type Git = { branch: string; isDirty: boolean }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'status-band': {
10      cwd: string | null
11      git: Git | null
12      model: string | null
13      effort: string | null
14      usage: Usage
15      // The clock's time at the last minute tick, for the reset countdowns.
16      now: number
17    }
18  }
19}
20