SLOPSHOPPER

statusband

A two-row band above the prompt: context, prompt cache countdown and hit rate, rate limits with reset times, git

newbandguardtoaststatusprocess
v?MITupdated 2026-10-04dip497/claude-statusband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusband
› 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 ctx ████░░░░ 49% cache 93% hit · 1h0m left 5h ██░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
opus-5-5 ctx ████░░░░ 49% cache 93% hit · 1h0m left 5h ██░░░░░░ 31%
README

statusband

A Claude Code mod that draws a two-row band above the prompt:

opus-5-5   ctx █░░░░░░░ 14%   cache 98% hit · 54m left
5h █████░░░ 64% resets 2h30m   7d █░░░░░░░ 14% resets 4d16h   main ⇡2 S:1 U:1 ?:3 +3 -1
  • Prompt cache: how much of the last turn the cache served, and how long until it expires. Goes cold after /model or /compact, and says on resume whether the cache survived. A toast warns about five minutes before it lapses.
  • Context: how full the window is.
  • Rate limits: the 5-hour and weekly windows, with the time until each resets.
  • Git: branch, commits ahead/behind, staged (S:), unstaged (U:) and untracked (?:) files, lines added and removed.

Bars turn yellow at 70% and red at 90%.

Install

macOS and Linux:

curl -fsSL https://raw.githubusercontent.com/dip497/claude-statusband/main/install.sh | sh

Windows (PowerShell):

irm https://raw.githubusercontent.com/dip497/claude-statusband/main/install.ps1 | iex

Or without a script, the same on every platform, inside Claude Code:

/plugin install statusband --marketplace dip497/claude-statusband

It installs for your user, so every new Claude Code session loads it, in any project. Sessions already open pick it up after a restart or /reload-plugins.

Updates

Claude Code does not auto-update third-party plugins by default, so the install script turns auto-update on for statusband (the same flag as /plugin → Marketplaces → statusband → Enable auto-update). New versions then arrive by themselves, shortly after a session starts.

To install without it, set STATUSBAND_AUTO_UPDATE=0 before running the script. Installing with /plugin install leaves it off too. Either way you can update by hand:

claude plugin update statusband@statusband

An update applies to the next session, or to a running one after /reload-plugins.

Removing an old status line

statusband is a band above the prompt; it does not replace a custom status line (ccstatusline, a statusline.sh, ...) set as statusLine in ~/.claude/settings.json, so both would show. The installer tells you when it finds one. To remove it:

curl -fsSL https://raw.githubusercontent.com/dip497/claude-statusband/main/remove-statusline.sh | sh
irm https://raw.githubusercontent.com/dip497/claude-statusband/main/remove-statusline.ps1 | iex

It deletes only the statusLine entry and keeps the previous file beside it as settings.json.bak-<timestamp>. On macOS and Linux it needs jq, python3 or node.

Uninstall

claude plugin uninstall statusband@statusband
claude plugin marketplace remove statusband

How the cache countdown is worked out

Claude Code does not tell a mod when the cache expires, so the band works it out: the lifetime restarts at every response of the main conversation, and its length follows the documented rules. One hour on a subscription inside its plan's usage, five minutes otherwise, unless you set it yourself: FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H are read, in that order.

It is still an estimate. Anything else that shortens the lifetime on Anthropic's side is not seen, and the hit rate is the last turn's, not the session's.

Git counts refresh after each turn and after any tool that can change files. A change made outside the session shows at the next of those.

Develop

claude --plugin-dir .        # load it from this folder
claude plugin validate .
claude plugin test .
sh tests/scripts-test.sh    # the install and removal scripts

License

MIT

Source 2 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, Timer } from 'claude-code'
3
4import type { View } from '../types'
5
6type Figures = Pick<SessionMeasureInput, 'context' | 'rateLimits'>
7
8const CACHE_WARN_MIN = 5
9const MINUTE = 60_000
10const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
11
12const view = atom({ plugin: 'statusband', key: 'view' } as const, {
13  now: 0,
14  model: '',
15  git: '',
16  context: null,
17  limits: [],
18  cacheTtlMin: 5,
19  cacheHit: null,
20  lastResponseAt: null,
21})
22const FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
23const warned = new Set<string>()
24let ticker: Timer | undefined
25
26export const span = (ms: number): string => {
27  const m = Math.max(0, Math.round(ms / MINUTE))
28  if (m >= 1440) return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
29  return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}m`
30}
31
32export const bar = (percent: number, cells = 8): string => {
33  const full = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
34  return '█'.repeat(full) + '░'.repeat(cells - full)
35}
36
37export const tone = (percent: number): string => (percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success')
38
39// The main conversation caches for an hour on a subscription inside its plan's usage, five minutes otherwise.
40export const cacheTtlMin = (limits: readonly { percent: number }[]): number =>
41  limits.length > 0 && limits.every(l => l.percent < 100) ? 60 : 5
42
43export type TtlControls = { force5m?: string; envTtl?: string; settingTtl?: unknown; enable1h?: string }
44
45// What the person set wins over the default, in the order Claude Code reads its controls;
46// an hour asked for is not given while the plan is on usage credits.
47export const resolveTtlMin = (c: TtlControls, limits: readonly { percent: number }[]): number => {
48  const asked =
49    c.force5m === '1'
50      ? '5m'
51      : c.envTtl === '5m' || c.envTtl === '1h'
52        ? c.envTtl
53        : c.settingTtl === '5m' || c.settingTtl === '1h'
54          ? c.settingTtl
55          : c.enable1h === '1'
56            ? '1h'
57            : undefined
58  if (asked === undefined) return cacheTtlMin(limits)
59  return asked === '1h' && limits.every(l => l.percent < 100) ? 60 : 5
60}
61
62export const shortModel = (id: string): string => id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
63
64export const cacheLeft = (v: View): number | null =>
65  v.lastResponseAt === null ? null : v.cacheTtlMin * MINUTE - (v.now - v.lastResponseAt)
66
67export const gitSummary = (status: string, shortstat: string): string => {
68  const lines = status.split('\n')
69  const head = lines.find(l => l.startsWith('# branch.head '))?.slice(14) ?? ''
70  const ab = /^# branch\.ab \+(\d+) -(\d+)/m.exec(status)
71  const changed = lines.filter(l => l.startsWith('1 ') || l.startsWith('2 '))
72  const count = (n: number, label: string) => (n > 0 ? `${label}${n}` : '')
73  return [
74    head,
75    count(Number(ab?.[1] ?? 0), '⇡'),
76    count(Number(ab?.[2] ?? 0), '⇣'),
77    count(changed.filter(l => l[2] !== '.').length, 'S:'),
78    count(changed.filter(l => l[3] !== '.').length, 'U:'),
79    count(lines.filter(l => l.startsWith('? ')).length, '?:'),
80    count(Number(/(\d+) insertion/.exec(shortstat)?.[1] ?? 0), '+'),
81    count(Number(/(\d+) deletion/.exec(shortstat)?.[1] ?? 0), '-'),
82  ]
83    .filter(Boolean)
84    .join(' ')
85}
86
87// A repository's own config can name programs git runs for it; none of them may run for a status read.
88const GIT = ['git', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'diff.external=']
89
90// ponytail: re-read on a measure and after a tool that can change files, not watched; an edit made outside the session shows at the next of those
91async function readGit($: EngineInterface): Promise<string> {
92  const status = await $.process.run([...GIT, 'status', '--porcelain=v2', '--branch'])
93  if (status.exitCode !== 0) return ''
94  const stat = await $.process.run([...GIT, 'diff', '--no-ext-diff', '--no-textconv', '--shortstat', 'HEAD'])
95  return gitSummary(status.stdout, stat.stdout)
96}
97
98// Toasts once per crossing; dropping back under the line re-arms it.
99function warnOnce($: EngineInterface, key: string, isOver: boolean, text: string): void {
100  if (!isOver) warned.delete(key)
101  else if (!warned.has(key)) {
102    warned.add(key)
103    $.ui.toast(text, { timeoutMs: 8000 })
104  }
105}
106
107async function tick($: EngineInterface): Promise<void> {
108  const now = await $.clock.now()
109  const v = await update($, view, old => ({ ...old, now }))
110  const left = cacheLeft(v)
111  warnOnce(
112    $,
113    'cache',
114    left !== null && left > 0 && left <= CACHE_WARN_MIN * MINUTE,
115    `Prompt cache expires in about ${CACHE_WARN_MIN}m; the next prompt after that re-caches the whole context`,
116  )
117}
118
119async function measure($: EngineInterface, figures: Figures): Promise<void> {
120  const now = await $.clock.now()
121  const model = shortModel(await $.session.model())
122  const git = await readGit($)
123  // a window with no response yet has no reading; the local estimate, when asked for, stands in
124  const fill = figures.context.percent ?? figures.context.breakdown?.percentage
125  const context = fill === undefined ? null : Math.round(fill)
126  const limits = figures.rateLimits.map(r => ({
127    label: LIMIT_LABEL[r.kind] ?? r.kind,
128    percent: Math.round(r.percentUsed),
129    resetsAt: r.resetsAt === undefined ? null : Date.parse(r.resetsAt),
130  }))
131  const controls: TtlControls = {
132    force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
133    envTtl: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
134    settingTtl: ((await $.settings.read()) as Record<string, unknown>).promptCacheTtl,
135    enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
136  }
137  const isSet = Object.values(controls).some(v => v !== undefined)
138  // with nothing set and no reading yet, nothing is known of the plan: the lifetime already known stands
139  await update($, view, old => ({
140    ...old,
141    now,
142    model,
143    git,
144    context,
145    limits,
146    cacheTtlMin: isSet || limits.length > 0 ? resolveTtlMin(controls, limits) : old.cacheTtlMin,
147  }))
148}
149
150async function refreshGit($: EngineInterface): Promise<void> {
151  const git = await readGit($)
152  await update($, view, old => (old.git === git ? old : { ...old, git }))
153}
154
155// Reads the figures now, for a moment the engine raises no measurement of its own.
156async function remeasure($: EngineInterface): Promise<void> {
157  await measure($, await $.session.usage({ breakdown: 'summary' }))
158}
159
160// A cache nothing can read any more: 0 is long enough ago to be past any lifetime.
161async function chillCache($: EngineInterface): Promise<void> {
162  const now = await $.clock.now()
163  await update($, view, old => ({ ...old, now, lastResponseAt: 0, cacheHit: null }))
164}
165
166async function touchCache($: EngineInterface, cacheHit?: number | null): Promise<void> {
167  const now = await $.clock.now()
168  await update($, view, old => ({ ...old, now, lastResponseAt: now, cacheHit: cacheHit === undefined ? old.cacheHit : cacheHit }))
169}
170
171export const register: Register = on => {
172  on('session.start', async ($, e, next) => {
173    // drops the line an earlier version of this mod pinned under the prompt
174    $.ui.status(undefined)
175    await remeasure($)
176    // keeps the countdowns moving between turns; a session that starts over keeps one timer
177    ticker?.cancel()
178    ticker = $.clock.every(MINUTE, () => void tick($))
179    return next(e)
180  })
181
182  // a resumed session says how long it sat idle and whether its cache outlived that
183  on('classic.SessionStart', async ($, e, next) => {
184    const idleMs = (e.seconds_since_last_response ?? 0) * 1000
185    // a cleared conversation has no turn left for the hit rate to describe
186    if (e.source === 'clear') {
187      await update($, view, old => ({ ...old, cacheHit: null }))
188      await remeasure($)
189    } else if (e.prompt_cache_likely_expired === true) await chillCache($)
190    else if (e.seconds_since_last_response !== undefined) {
191      const now = await $.clock.now()
192      await update($, view, old => ({
193        ...old,
194        now,
195        lastResponseAt: now - idleMs,
196        cacheHit: null,
197        cacheTtlMin: idleMs > 5 * MINUTE ? 60 : old.cacheTtlMin,
198      }))
199    }
200    return next(e)
201  })
202
203  // each model has a cache of its own, and a compacted history shares no prefix with the old one
204  // Neither is followed by a measurement until the next turn, and both move the context figure.
205  on('classic.PostModelSwitch', async ($, e, next) => {
206    await chillCache($)
207    await remeasure($)
208    return next(e)
209  })
210
211  on('classic.PostCompact', async ($, e, next) => {
212    await chillCache($)
213    await remeasure($)
214    return next(e)
215  })
216
217  on('session.measure', async ($, e, next) => {
218    await measure($, e)
219    return next(e)
220  })
221
222  // a tool call follows a response, so the cache was just touched; a subagent's requests touch only its own
223  on('tool.call', async ($, e, next) => {
224    if (e.agentId === undefined) await touchCache($)
225    const ran = await next(e)
226    if (FILE_TOOLS.has(e.tool)) await refreshGit($)
227    return ran
228  })
229
230  on('turn.complete', async ($, e, next) => {
231    if (e.agentId === undefined && e.usage !== undefined) {
232      const u = e.usage
233      const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
234      await touchCache($, input === 0 ? null : Math.round((u.cache_read_input_tokens / input) * 100))
235    }
236    return next(e)
237  })
238
239  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240    const v = await read($, view)
241    if (e.props.hasSurvey || v.model === '') return next(e)
242
243    const { Box, Text } = $.ui.resolve(e)
244    const left = cacheLeft(v)
245    const cacheTone = left === null || left <= 0 ? 'error' : left <= CACHE_WARN_MIN * MINUTE ? 'warning' : 'success'
246
247    return (
248      <Box flexDirection="column" paddingLeft={2}>
249        <Box gap={2}>
250          <Text bold color="cyan">{v.model}</Text>
251          {v.context !== null && (
252            <Text>
253              <Text dimColor>ctx </Text>
254              <Text color={tone(v.context)}>{bar(v.context)} {v.context}%</Text>
255            </Text>
256          )}
257          {left !== null && (
258            <Text>
259              <Text dimColor>cache </Text>
260              <Text color={cacheTone}>
261                {left <= 0 ? 'cold' : `${v.cacheHit === null ? '' : `${v.cacheHit}% hit · `}${span(left)} left`}
262              </Text>
263            </Text>
264          )}
265        </Box>
266        <Box gap={2}>
267          {v.limits.map(l => (
268            <Text key={l.label}>
269              <Text dimColor>{l.label} </Text>
270              <Text color={tone(l.percent)}>{bar(l.percent)} {l.percent}%</Text>
271              {l.resetsAt !== null && l.resetsAt > v.now && <Text dimColor> resets {span(l.resetsAt - v.now)}</Text>}
272            </Text>
273          ))}
274          {v.git !== '' && <Text color="magenta" wrap="truncate-end">{v.git}</Text>}
275        </Box>
276      </Box>
277    )
278  })
279}
280
types/index.d.ts 22 lines
1export type Limit = { label: string; percent: number; resetsAt: number | null }
2
3export type View = {
4  now: number
5  model: string
6  git: string
7  context: number | null
8  limits: Limit[]
9  // prompt cache lifetime in minutes, derived from the plan's usage
10  cacheTtlMin: number
11  // share of the last turn's input the prompt cache served, 0 to 100
12  cacheHit: number | null
13  // when the cache was last touched; every response restarts its lifetime
14  lastResponseAt: number | null
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'statusband': { view: View }
20  }
21}
22