SLOPSHOPPER

usage-statusline

Live usage line under the prompt: model, context, branch, 5h/week/model limits, session time, folder, cache countdown and rewrite cost.

newspinnerprocessnetworktimer
v0.1.1MITupdated 2026-10-09OctopiAI/claude-code-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-statusline
› 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 │ [▓▓▓▓▓░░░░░] 49% │ 5H 31% │ 365D 7H │ app │ feat/auth-refresh │ ○ │ ≈$0.78 ⟨Claude Code's own drawing⟩

Draws

Prompt hint
OPUS 5.5 │ [▓▓▓▓▓░░░░░] 49% │ 5H 31% │ 365D 7H │ app │ feat/auth-refresh │ ○ │ ≈$0.78 ⟨Claude Code's own drawing⟩
README

<h1>【 Claude Code Status Line 】</h1> <h3></h3>

A Claude Code mod that draws a live usage line under the prompt: your model, context usage, rate limits, session time, folder and git branch, plus how long the prompt cache stays warm and what re-caching would cost. The same on macOS, Windows and Linux, in the terminal and the desktop app. No Python, no scripts, no settings to edit.

OPUS 5.5 │ [▓░░░░░░░░░] 12% │ 5H 3% │ 7D 62% │ FABLE 38% │ 42M │ my-project │ main │ ● 58M │ ≈$0.69

One row when the terminal is wide enough; a narrower one breaks onto more rows between segments.

Looking for the original Python status line script? It now lives in python/ and is deprecated in favor of this mod.


Install (macOS, Windows, Linux)

Requirements: Claude Code 2.1.287 or later (claude --version; update with claude update). The 5h, week and model limits need you signed in with a Claude subscription; on an API key they are simply left out.

  1. Start Claude Code in any terminal (Terminal, iTerm, Windows Terminal, PowerShell, any Linux terminal).
  2. At the prompt, type:
   /plugin install usage-statusline --marketplace OctopiAI/claude-code-statusline
  1. Answer y to add the marketplace, then press Enter to keep the default user scope.

You'll see Installed usage-statusline. Plugin is now active. and the line appears under the prompt right away, no restart needed. With the user scope it shows in every session from then on, in every project, and in the Claude Code desktop app too. The desktop app can't run /plugin install itself, so install once from a terminal.

Coming from the Python script? Remove the statusLine block from ~/.claude/settings.json (Windows: C:\Users\YOUR_USERNAME\.claude\settings.json), or you'll see both lines.

Update: claude plugin update usage-statusline, then /reload-plugins in a running session.<br> Uninstall: claude plugin uninstall usage-statusline.


What it displays

SegmentDescription
ModelActive model, e.g. OPUS 5.5
ContextFill bar and percentage of the context window; red from 80%
5H / 7DRate-limit windows; red from 80%
Model weeklyA model's own weekly limit (e.g. Fable), shown only if your plan has one. Claude Code doesn't hand it to mods, so the mod reads it from the same usage endpoint /usage uses, with your existing login, every 5 minutes. Dims when the reading is older than 30 minutes
SessionTime since the session started
FolderCurrent folder
BranchCurrent git branch (or short commit hash on a detached HEAD)
CacheTime left before the prompt cache goes cold: green, amber under half, red in the last 20%, a hollow ○ once it has expired
RewriteWhat re-caching the current context would cost at the model's list cache-write price (1.25x input on a 5m cache, 2x on a 1h cache); amber once the cache is cold, since that is when the next prompt pays it

The cache TTL is 1h on a Claude subscription and 5m otherwise; override it with the cacheTtl option in /config.


Developing

The mod is a Claude Code plugin of function hooks: hooks/register.tsx is the hooks module, hooks/format.ts the formatting, types/index.d.ts the state it keeps.

claude --plugin-dir /path/to/claude-code-statusline   # run a session with your working copy; saving a file reloads it
claude plugin validate .                              # manifest, marketplace and hooks module
claude plugin test .                                  # tests/*.test.tsx

Folder structure

claude-code-statusline/
├── .claude-plugin/
│   ├── plugin.json        # the mod's manifest
│   └── marketplace.json   # makes this repo installable with /plugin install
├── hooks/
│   ├── hooks.json
│   ├── register.tsx
│   └── format.ts
├── types/
│   └── index.d.ts
├── tests/
│   └── band.test.tsx
├── python/                # deprecated Python status line script
└── README.md

License

MIT

Source 3 files
hooks/register.tsx 260 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { AccountLimit, AccountLimits, CacheMark, Session, Usage } from '../types'
5import {
6  bar,
7  basename,
8  cacheTone,
9  countdown,
10  duration,
11  modelName,
12  rewriteUsd,
13  ttlMs,
14  usd,
15} from './format'
16
17const usage = atom({ plugin: 'usage-statusline', key: 'usage' } as const, {
18  contextTokens: null,
19  window: null,
20  limits: [],
21} as Usage)
22const cache = atom({ plugin: 'usage-statusline', key: 'cache' } as const, null as CacheMark | null)
23const now = atom({ plugin: 'usage-statusline', key: 'now' } as const, 0)
24const session = atom({ plugin: 'usage-statusline', key: 'session' } as const, null as Session | null)
25const account = atom({ plugin: 'usage-statusline', key: 'account' } as const, null as AccountLimits | null)
26
27// Mods get five_hour and seven_day, never a model's own weekly limit, so that
28// one comes from the endpoint /usage reads, with the session's own login.
29const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
30const ACCOUNT_EVERY_MS = 5 * 60_000
31const ACCOUNT_STALE_MS = 30 * 60_000
32
33const SEP = ' │ '
34// The footer's indent on both sides; the width assumed before the surface
35// has measured.
36const GUTTER = 4
37const DEFAULT_COLUMNS = 120
38const ALERT_PERCENT = 80
39
40// One run of text in one style; a segment is the runs between separators.
41type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
42type Segment = Run[]
43
44const width = (seg: Segment) => seg.reduce((n, run) => n + [...run.text].length, 0)
45
46// Packs segments into as few rows as the band's width allows, breaking only
47// between segments.
48function rows(segments: Segment[], columns: number): Segment[][] {
49  const out: Segment[][] = []
50  let row: Segment[] = []
51  let used = 0
52  for (const seg of segments) {
53    const w = width(seg)
54    if (row.length > 0 && used + SEP.length + w > columns) {
55      out.push(row)
56      row = []
57      used = 0
58    }
59    used += (row.length > 0 ? SEP.length : 0) + w
60    row.push(seg)
61  }
62  if (row.length > 0) out.push(row)
63  return out
64}
65
66function toUsage(context: SessionContextUsage, limits: readonly SessionRateLimit[]): Usage {
67  return {
68    contextTokens: context.tokens ?? null,
69    window: context.window,
70    limits: limits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt ?? null })),
71  }
72}
73
74async function branchOf($: EngineInterface): Promise<string | null> {
75  for (const argv of [
76    ['git', 'symbolic-ref', '--short', 'HEAD'],
77    ['git', 'rev-parse', '--short', 'HEAD'],
78  ]) {
79    try {
80      const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 2000 })
81      if (exitCode === 0 && stdout.trim()) return stdout.trim()
82    } catch {
83      // No git, or no host commands on this surface.
84    }
85  }
86  return null
87}
88
89async function refreshSession($: EngineInterface): Promise<void> {
90  const [model, cwd, branch, { startedAt }] = await Promise.all([
91    $.session.model(),
92    $.session.cwd(),
93    branchOf($),
94    $.session.usage(),
95  ])
96  await update($, session, () => ({ model, branch, folder: basename(cwd), startedAt }))
97}
98
99async function refreshAccount($: EngineInterface): Promise<void> {
100  const auth = await $.session.authorize()
101  if (!auth) return
102  const res = await $.http.fetch(USAGE_URL, {
103    auth: auth.handle,
104    headers: { 'anthropic-beta': 'oauth-2025-04-20' },
105  })
106  if (!res.ok) return
107  const rows: unknown = JSON.parse(res.text)?.limits
108  if (!Array.isArray(rows)) return
109  const limits: AccountLimit[] = rows.flatMap(row => {
110    if (typeof row?.kind !== 'string' || typeof row.percent !== 'number') return []
111    return [{
112      kind: row.kind,
113      name: row.scope?.model?.display_name ?? null,
114      percent: row.percent,
115      resetsAt: typeof row.resets_at === 'string' ? row.resets_at : null,
116    }]
117  })
118  const fetchedAt = await $.clock.now()
119  await update($, account, () => ({ fetchedAt, limits }))
120}
121
122export const register: Register = (on, options) => {
123  on('session.start', async ($, e, next) => {
124    const result = await next(e)
125    const u = await $.session.usage()
126    await update($, usage, () => toUsage(u.context, u.rateLimits))
127    await update($, now, () => Date.now())
128    await refreshSession($)
129    $.clock.every(1000, () => void $.clock.now().then(t => update($, now, () => t)))
130    $.clock.every(ACCOUNT_EVERY_MS, () => void refreshAccount($).catch(() => {}))
131    void refreshAccount($).catch(() => {})
132    return result
133  })
134
135  on('session.end', async ($, e, next) => {
136    if (e.reason === 'clear') {
137      await update($, cache, () => null)
138      await refreshSession($)
139    }
140    return next(e)
141  })
142
143  on('session.measure', async ($, e, next) => {
144    await update($, usage, () => toUsage(e.context, e.rateLimits))
145    return next(e)
146  })
147
148  // Every main-thread response re-arms the prompt cache for its TTL.
149  on('turn.step', async function* ($, e, next) {
150    const result = yield* next(e)
151    if (e.agentId === undefined && result.usage) {
152      const u = result.usage
153      const at = await $.clock.now()
154      const sent = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
155      await update($, cache, () => ({ at, model: u.model, tokens: sent }))
156    }
157    return result
158  })
159
160  on('turn.complete', async ($, e, next) => {
161    const result = await next(e)
162    await refreshSession($)
163    return result
164  })
165
166  // Drawn under the prompt, above the engine's own hint line (modes, "esc to
167  // interrupt"), which stays as the engine draws it. The Python status line's
168  // order, styles and colors, with the cache appended.
169  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
170    const [hint, u, mark, t, s, acct] = await Promise.all([
171      next(e),
172      read($, usage),
173      read($, cache),
174      read($, now),
175      read($, session),
176      read($, account),
177    ])
178    const at = t || Date.now()
179    const ttl = ttlMs(options.cacheTtl, u.limits.length > 0 || acct !== null)
180    const isStale = acct !== null && at - acct.fetchedAt > ACCOUNT_STALE_MS
181    const limitColor = (percent: number, color: string) => (percent >= ALERT_PERCENT ? 'red' : color)
182
183    const segments: Segment[] = []
184
185    if (s) segments.push([{ text: modelName(s.model), color: 'yellow', bold: true }])
186
187    const ctx = u.contextTokens ?? mark?.tokens ?? null
188    if (ctx !== null && u.window) {
189      const percent = (ctx / u.window) * 100
190      const { filled, empty } = bar(percent)
191      const color = limitColor(percent, 'green')
192      segments.push([
193        { text: '[', color },
194        { text: filled, color },
195        { text: empty, dim: true },
196        { text: ']', color },
197        { text: ` ${Math.round(percent)}%`, color: 'gray' },
198      ])
199    }
200
201    const windows: [string, string, string, string][] = [
202      ['five_hour', 'session', '5H', 'yellow'],
203      ['seven_day', 'weekly_all', '7D', 'green'],
204    ]
205    for (const [kind, accountKind, label, color] of windows) {
206      const percent = u.limits.find(l => l.kind === kind)?.percentUsed
207        ?? acct?.limits.find(l => l.kind === accountKind)?.percent
208      if (percent === undefined) continue
209      segments.push([{ text: `${label} ${Math.round(percent)}%`, color: limitColor(percent, color) }])
210    }
211
212    for (const l of acct?.limits ?? []) {
213      if (l.kind !== 'weekly_scoped' || (l.resetsAt && Date.parse(l.resetsAt) < at)) continue
214      const text = `${(l.name ?? 'MODEL').toUpperCase()} ${Math.round(l.percent)}%`
215      segments.push([isStale ? { text, dim: true } : { text, color: limitColor(l.percent, 'magenta') }])
216    }
217
218    if (s) {
219      segments.push([{ text: duration(at - s.startedAt), dim: true }])
220      segments.push([{ text: s.folder, color: 'cyan', bold: true }])
221      if (s.branch) segments.push([{ text: s.branch, color: 'green' }])
222    }
223
224    const remaining = mark ? mark.at + ttl - at : 0
225    segments.push(
226      remaining > 0
227        ? [{ text: `● ${countdown(remaining)}`, color: cacheTone(remaining, ttl) }]
228        : [{ text: '○', dim: true }],
229    )
230
231    if (ctx !== null) {
232      const cost = rewriteUsd(ctx, mark?.model ?? s?.model ?? '', ttl)
233      if (cost !== null) {
234        segments.push([remaining > 0
235          ? { text: `≈${usd(cost)}`, dim: true }
236          : { text: `≈${usd(cost)}`, color: 'warning' }])
237      }
238    }
239
240    const { Box, Text } = $.ui.resolve(e)
241    return (
242      <Box flexDirection="column" marginTop={1}>
243        {rows(segments, (e.viewport?.columns ?? DEFAULT_COLUMNS) - GUTTER).map((row, r) => (
244          <Box key={`row-${r}`}>
245            <Text wrap="truncate-end">
246              {row.flatMap((seg, i) => [
247                i > 0 ? <Text dimColor>{SEP}</Text> : null,
248                ...seg.map(run => (
249                  <Text color={run.color} bold={run.bold} dimColor={run.dim}>{run.text}</Text>
250                )),
251              ])}
252            </Text>
253          </Box>
254        ))}
255        {hint}
256      </Box>
257    )
258  })
259}
260
hooks/format.ts 92 lines
1// List input prices in USD per million tokens, matched by model id prefix
2// (first match wins, so more specific prefixes come first).
3const INPUT_USD_PER_MTOK: readonly (readonly [string, number])[] = [
4  ['claude-fable', 10],
5  ['claude-mythos', 10],
6  ['claude-opus-5-5', 4],
7  ['claude-opus', 5],
8  ['claude-sonnet-5', 2],
9  ['claude-sonnet', 3],
10  ['claude-haiku-5-5', 0.1],
11  ['claude-haiku', 1],
12]
13
14const MINUTE = 60_000
15const HOUR = 60 * MINUTE
16
17export type Tone = 'success' | 'warning' | 'error' | 'inactive'
18
19// "/model" shows "Opus 5.5"; the API reports "claude-opus-5-5".
20export function modelId(name: string): string {
21  const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
22  return id.startsWith('claude-') ? id : `claude-${id}`
23}
24
25// Re-caching the whole context costs 1.25x input on the 5m TTL, 2x on 1h.
26export function rewriteUsd(tokens: number, model: string, ttlMs: number): number | null {
27  const id = modelId(model)
28  const price = INPUT_USD_PER_MTOK.find(([prefix]) => id.startsWith(prefix))?.[1]
29  if (price === undefined) return null
30  return (tokens / 1e6) * price * (ttlMs > 5 * MINUTE ? 2 : 1.25)
31}
32
33// "claude-opus-5-5[1m]", "Claude Opus 5.5 (1M context)" -> "OPUS 5.5"
34export function modelName(name: string): string {
35  const words = name
36    .toLowerCase()
37    .replace(/\[.*?\]|\(.*?\)/g, ' ')
38    .split(/[\s\-_]+/)
39    .filter(w => w && w !== 'claude')
40  const family = words.find(w => /^[a-z]+$/.test(w))
41  if (!family) return name.toUpperCase()
42  const version = words.filter(w => /^\d{1,2}(\.\d{1,2})?$/.test(w)).join('.')
43  return version ? `${family.toUpperCase()} ${version}` : family.toUpperCase()
44}
45
46export function bar(percent: number, cells = 10): { filled: string; empty: string } {
47  const n = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
48  return { filled: '▓'.repeat(n), empty: '░'.repeat(cells - n) }
49}
50
51export function tokens(n: number): string {
52  if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
53  if (n >= 1e3) return `${Math.round(n / 1e3)}K`
54  return String(n)
55}
56
57export function usd(n: number): string {
58  return `$${n.toFixed(2)}`
59}
60
61// 42M, 1H 05M, 3D 4H
62export function duration(ms: number): string {
63  const m = Math.max(0, Math.floor(ms / MINUTE))
64  if (m < 60) return `${m}M`
65  const h = Math.floor(m / 60)
66  if (h < 24) return `${h}H ${String(m % 60).padStart(2, '0')}M`
67  return `${Math.floor(h / 24)}D ${h % 24}H`
68}
69
70// Counts up to the next whole minute so a fresh 1h cache reads 60M; the last
71// minute counts seconds.
72export function countdown(ms: number): string {
73  if (ms < MINUTE) return `${Math.ceil(ms / 1000)}S`
74  return `${Math.ceil(ms / MINUTE)}M`
75}
76
77export function cacheTone(remainingMs: number, ttlMs: number): Tone {
78  if (remainingMs <= 0) return 'inactive'
79  const left = remainingMs / ttlMs
80  return left > 0.5 ? 'success' : left > 0.2 ? 'warning' : 'error'
81}
82
83export function ttlMs(setting: unknown, onSubscription: boolean): number {
84  if (setting === '5m') return 5 * MINUTE
85  if (setting === '1h') return HOUR
86  return onSubscription ? HOUR : 5 * MINUTE
87}
88
89export function basename(path: string): string {
90  return path.split(/[\\/]/).filter(Boolean).pop() ?? path
91}
92
types/index.d.ts 31 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt: string | null }
2
3export type Usage = {
4  contextTokens: number | null
5  window: number | null
6  limits: Limit[]
7}
8
9// When the main thread last got a response, which re-arms the prompt cache.
10export type CacheMark = { at: number; model: string; tokens: number }
11
12// One row of the usage endpoint's `limits`: `session` (5h), `weekly_all`
13// (week) and `weekly_scoped` (a model's own weekly, such as Fable).
14export type AccountLimit = { kind: string; name: string | null; percent: number; resetsAt: string | null }
15
16export type AccountLimits = { fetchedAt: number; limits: AccountLimit[] }
17
18export type Session = { model: string; branch: string | null; folder: string; startedAt: number }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'usage-statusline': {
23      usage: Usage
24      cache: CacheMark | null
25      now: number
26      session: Session | null
27      account: AccountLimits | null
28    }
29  }
30}
31