SLOPSHOPPER

usage-bars

Two-line status under the prompt: model, effort, context bar, prompt cache countdown, 5h and 7d limits with reset times.

newspinnerprocesstimer
v0.1.0MITupdated 2026-10-14Mariator-pro/claude-mod-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bars
› 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 [███▎ ] 48% 5h [██▍ ] 31% ↻ NaNhNaNm ⟨Claude Code's own drawing⟩

Draws

Prompt hint
Opus 5.5 | ctx [███▎ ] 48% 5h [██▍ ] 31% ↻ NaNhNaNm ⟨Claude Code's own drawing⟩
README

claude-mod-statusline

A Claude Code mod that draws a two-line usage status under the prompt:

claude-mod-statusline

  • Line 1: model, reasoning effort, context window fill, prompt cache time left (🔥, yellow under 5 min, ❄ cold once expired)
  • Line 2: 5 hour and 7 day limits with time to reset (only with a Claude subscription login)

Bars turn yellow and red as they fill. The context bar stretches so both lines are equally wide.

Install

/plugin install usage-bars --marketplace Mariator-pro/claude-mod-statusline

Answer y to add the marketplace, then pick a scope. Needs a Claude Code version with mod support. If statusLine is set in ~/.claude/settings.json (on Windows %USERPROFILE%\.claude\settings.json), remove it, or both show.

Develop

Run from a working copy (reloads on save):

claude --plugin-dir <path-to-clone>

Or for every session, in ~/.claude/settings.json:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/claude-mod-statusline" }

Check and test:

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 189 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Stats } from '../types'
5import { HOUR_MS, MIN5_MS, rows, weekday } from './format'
6
7const stats = atom({ plugin: 'usage-bars', key: 'stats' } as const, {
8  model: '?',
9  effort: null,
10  tokens: null,
11  window: 200_000,
12  limits: [],
13  cacheAt: null,
14  cacheTtl: null,
15} as Stats, { shape: 'v2' }) // bump when Stats changes, so a reload drops the old value
16const now = atom({ plugin: 'usage-bars', key: 'now' } as const, 0)
17
18const TICK_MS = 60_000 // the line shows whole minutes
19
20const set = ($: EngineInterface, patch: Partial<Stats>) => update($, stats, s => ({ ...s, ...patch }))
21
22// The 7d reset as the host's local time, through `date` so the host's time zone applies.
23// BSD/macOS takes `-r <epoch>`, GNU `-d @<epoch>`, Windows has PowerShell; all print "4 16:00" (1 = Monday).
24// Failures are not cached, so the next measure retries.
25const labels = new Map<string, string>()
26async function resetLabel($: EngineInterface, iso: string): Promise<string | null> {
27  if (labels.has(iso)) return labels.get(iso)!
28  const secs = String(Math.floor(Date.parse(iso) / 1000))
29  const ps = `$d = [DateTimeOffset]::FromUnixTimeSeconds(${secs}).LocalDateTime; `
30    + `'{0} {1}' -f ((([int]$d.DayOfWeek) + 6) % 7 + 1), $d.ToString('HH:mm', [cultureinfo]::InvariantCulture)`
31  for (const cmd of [
32    ['date', '-r', secs, '+%u %H:%M'],
33    ['date', '-d', `@${secs}`, '+%u %H:%M'],
34    ['powershell', '-NoProfile', '-NonInteractive', '-Command', ps],
35  ]) {
36    try {
37      const { exitCode, stdout } = await $.process.run(cmd)
38      const label = exitCode === 0 ? weekday(stdout) : null
39      if (label) {
40        labels.set(iso, label)
41        return label
42      }
43    } catch {
44      // Not on this host (or no host commands on this surface); try the next.
45    }
46  }
47  return null
48}
49
50async function limits($: EngineInterface, raw: readonly SessionRateLimit[]): Promise<Limit[]> {
51  return Promise.all(raw.map(async l => ({
52    kind: l.kind,
53    percent: l.percentUsed,
54    resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : null,
55    resetLabel: l.kind === 'seven_day' && l.resetsAt ? await resetLabel($, l.resetsAt) : null,
56  })))
57}
58
59type Entry = {
60  type?: string
61  isSidechain?: boolean
62  message?: { usage?: { cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } } }
63}
64
65// The transcript's last lines: `tail` where the host has it, else the engine's read (files up to 4 MiB).
66async function tail($: EngineInterface, path: string, lines: number): Promise<string[]> {
67  try {
68    const { exitCode, stdout } = await $.process.run(['tail', '-n', String(lines), path])
69    if (exitCode === 0) return stdout.split('\n')
70  } catch {
71    // No `tail` on this host (Windows).
72  }
73  try {
74    return (await $.fs.read(path)).split('\n').slice(-lines)
75  } catch {
76    return []
77  }
78}
79
80// The engine reports the cache's TTL to a mod only on a model switch; the transcript's usage has it
81// per response: the last main response that wrote to the cache, 1h unless it wrote 5m only.
82// No write in view keeps the TTL already known.
83async function ttlFromTranscript($: EngineInterface, path: string): Promise<void> {
84  const writes = (await tail($, path, 300)).flatMap(line => {
85    try {
86      const e = JSON.parse(line) as Entry
87      const c = e.type === 'assistant' && !e.isSidechain ? e.message?.usage?.cache_creation : undefined
88      return c && (c.ephemeral_5m_input_tokens ?? 0) + (c.ephemeral_1h_input_tokens ?? 0) > 0 ? [c] : []
89    } catch {
90      return []
91    }
92  })
93  const w = writes.at(-1)
94  if (w) await set($, { cacheTtl: (w.ephemeral_1h_input_tokens ?? 0) > 0 ? HOUR_MS : MIN5_MS })
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    const result = await next(e)
100    const [model, settings, usage, t] = await Promise.all([
101      $.session.model(),
102      $.settings.read(),
103      $.session.usage(),
104      $.clock.now(),
105    ])
106    const effort = (settings as { effortLevel?: string }).effortLevel ?? null
107    await set($, {
108      model,
109      effort,
110      tokens: usage.context.tokens ?? null,
111      window: usage.context.window,
112    })
113    await update($, now, () => t)
114    // The 7d label spawns `date`; start does not wait for it, and a measure that landed meanwhile wins.
115    void limits($, usage.rateLimits).then(l => update($, stats, s => (s.limits.length ? s : { ...s, limits: l })))
116    // `-p` and SDK runs draw nothing, so nothing to tick for.
117    if (e.isInteractive) $.clock.every(TICK_MS, () => void $.clock.now().then(t => update($, now, () => t)))
118    return result
119  })
120
121  on('session.end', async ($, e, next) => {
122    if (e.reason === 'clear') await set($, { cacheAt: null, cacheTtl: null, tokens: null })
123    return next(e)
124  })
125
126  on('session.measure', async ($, e, next) => {
127    await set($, {
128      tokens: e.context.tokens ?? null,
129      window: e.context.window,
130      limits: await limits($, e.rateLimits),
131    })
132    return next(e)
133  })
134
135  // Every main response re-arms the prompt cache; the effort is the one the request carried.
136  on('turn.step', async function* ($, e, next) {
137    const result = yield* next(e)
138    if (e.agentId === undefined && result.usage) {
139      const at = await $.clock.now()
140      await update($, stats, s => ({
141        ...s,
142        model: e.model,
143        // A level; a numeric thinking budget is not one to show.
144        effort: typeof e.effort === 'string' ? e.effort : null,
145        cacheAt: at,
146      }))
147    }
148    return result
149  })
150
151  // A resumed session's cache: when its last response landed, as the engine reports it, and the TTL it wrote.
152  on('classic.SessionStart', async ($, e, next) => {
153    const result = await next(e)
154    if (e.seconds_since_last_response !== undefined) {
155      await set($, { cacheAt: (await $.clock.now()) - e.seconds_since_last_response * 1000 })
156    }
157    await ttlFromTranscript($, e.transcript_path)
158    return result
159  }).catch(($, e, next) => next(e))
160
161  // The TTL the turn actually wrote; turn.step already re-armed the cache.
162  on('classic.Stop', async ($, e, next) => {
163    const result = await next(e)
164    await ttlFromTranscript($, e.transcript_path)
165    return result
166  }).catch(($, e, next) => next(e))
167
168  // Under the prompt, above the engine's own hint line, which stays.
169  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
170    const [hint, s, t] = await Promise.all([next(e), read($, stats), read($, now)])
171    const { Box, Text } = $.ui.resolve(e)
172    // A render can come before session.start has seeded `now`; render has no `$.clock`.
173    return (
174      <Box flexDirection="column">
175        {rows(s, t || Date.now()).map((row, r) => (
176          <Box key={`row-${r}`} marginTop={1}>
177            <Text wrap="truncate-end">
178              {row.map((run, i) => (
179                <Text key={i} color={run.color} backgroundColor={run.bg} dimColor={run.dim}>{run.text}</Text>
180              ))}
181            </Text>
182          </Box>
183        ))}
184        <Box marginTop={1}>{hint}</Box>
185      </Box>
186    )
187  })
188}
189
hooks/format.ts 109 lines
1import type { Stats } from '../types'
2
3// One piece of text in one style; a row is the runs side by side.
4type Run = { text: string; color?: string; bg?: string; dim?: boolean }
5type Row = Run[]
6
7const BLUE = 'blue'
8const EMPTY_BG = '#3a3a3a' // xterm 237
9const EIGHTHS = '▏▎▍▌▋▊▉'
10const CTX_WIDTH = 22
11const MIN_CTX_WIDTH = 5
12export const HOUR_MS = 60 * 60_000
13export const MIN5_MS = 5 * 60_000
14const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
15
16const level = (p: number, yellow = 60, red = 85) => (p >= red ? 'red' : p >= yellow ? 'yellow' : 'green')
17
18// Full blocks, an eighth block on grey for the started cell, grey cells for the rest.
19export function bar(percent: number, width: number, color: string): Run[] {
20  const eighths = Math.floor((Math.min(Math.max(percent, 0) || 0, 100) * width * 8) / 100)
21  const full = Math.floor(eighths / 8)
22  const part = eighths % 8 ? EIGHTHS[(eighths % 8) - 1] : ''
23  const rest = width - full - (part ? 1 : 0)
24  return [
25    { text: '█'.repeat(full), color },
26    { text: part, color, bg: EMPTY_BG },
27    { text: ' '.repeat(rest), bg: EMPTY_BG },
28  ]
29}
30
31// Remaining time as 2h54m.
32export function left(ms: number): string {
33  const min = Math.max(0, Math.floor(ms / 60_000))
34  return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
35}
36
37// `date +'%u %H:%M'` output (1 = Monday) as "Thu 16:00".
38export function weekday(dateOutput: string): string | null {
39  const m = /^([1-7]) (\d\d:\d\d)/.exec(dateOutput.trim())
40  return m ? `${WEEKDAYS[Number(m[1]) - 1]} ${m[2]}` : null
41}
42
43// "claude-opus-5-5[1m]" or "Claude Opus 5.5" as "Opus 5.5".
44export function modelName(model: string): string {
45  const [, name, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model) ?? []
46  if (name) return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${major}.${minor}`
47  return model.replace(/^Claude /, '')
48}
49
50// Terminal cells; the fire emoji is two wide.
51export const width = (row: Row) => row.reduce((n, r) => n + [...r.text].length + (r.text.match(/🔥/gu)?.length ?? 0), 0)
52
53function cache(s: Stats, now: number): Row {
54  if (s.cacheAt === null) return []
55  // Unknown TTL: Claude Code writes 1h caches on a subscription login (the one with limits), 5m on an API key.
56  // Decided here, not when the cache was armed, since limits can arrive later (a resume).
57  const ms = s.cacheAt + (s.cacheTtl ?? (s.limits.length > 0 ? HOUR_MS : MIN5_MS)) - now
58  if (ms <= 0) return [{ text: ' | pc ' }, { text: '❄ cold', color: BLUE }]
59  return [{ text: ' | pc ' }, { text: `🔥 ${Math.floor(ms / 60_000)}m`, color: ms < MIN5_MS ? 'yellow' : 'green' }]
60}
61
62function context(s: Stats, now: number, barWidth: number): Row {
63  const pct = s.tokens === null || !(s.window > 0) ? 0 : Math.max(0, Math.floor((s.tokens * 100) / s.window))
64  const color = level(pct, 75, 90)
65  return [
66    { text: modelName(s.model), color: BLUE },
67    ...(s.effort ? [{ text: ` ${s.effort}`, color: BLUE, dim: true }] : []),
68    { text: ' | ctx [' },
69    ...bar(pct, barWidth, color),
70    { text: '] ' },
71    { text: `${pct}%`, color },
72    ...cache(s, now),
73  ]
74}
75
76// An unknown reset time shows no reset at all.
77function limit(label: string, percent: number, reset: string | null): Row {
78  const p = Math.floor(percent)
79  const color = level(p)
80  return [
81    { text: `${label} [` },
82    ...bar(p, 8, color),
83    { text: '] ' },
84    { text: `${p}%`, color },
85    ...(reset === null ? [] : [{ text: ` ↻ ${reset}`, dim: true }]),
86  ]
87}
88
89// Both rows; the context bar stretches so the first row is as wide as the second.
90export function rows(s: Stats, now: number): Row[] {
91  // The engine reports limits only with a response; one whose reset has passed is empty until the next.
92  const find = (kind: string) => {
93    const l = s.limits.find(l => l.kind === kind)
94    return l && l.resetsAt !== null && l.resetsAt <= now ? { ...l, percent: 0, resetsAt: null, resetLabel: null } : l
95  }
96  const five = find('five_hour')
97  const seven = find('seven_day')
98  const parts = [
99    ...(five ? [limit('5h', five.percent, five.resetsAt === null ? null : left(five.resetsAt - now))] : []),
100    ...(seven ? [limit('7d', seven.percent, seven.resetLabel)] : []),
101  ]
102  // Limits come only with a subscription login; without them the first row stands alone.
103  if (!parts.length) return [context(s, now, CTX_WIDTH)]
104  const second = parts.flatMap((p, i) => (i ? [{ text: ' | ' }, ...p] : p))
105  // The first row without its bar is the fixed part; the bar takes the rest.
106  const w = Math.max(MIN_CTX_WIDTH, width(second) - width(context(s, now, 0)))
107  return [context(s, now, w), second]
108}
109
types/index.d.ts 19 lines
1export type Limit = { kind: string; percent: number; resetsAt: number | null; resetLabel: string | null }
2
3export type Stats = {
4  model: string
5  effort: string | null
6  tokens: number | null
7  window: number
8  limits: Limit[]
9  // When the last main response re-armed the prompt cache (ms), and its TTL once a cache write showed it.
10  cacheAt: number | null
11  cacheTtl: number | null
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'usage-bars': { stats: Shaped<Stats>; now: number }
17  }
18}
19