SLOPSHOPPER

context-statusline

Colorized context-window and usage-limit band: model | project | ctx % [bar] used/total | session % | week %

bandtimer
v1.2.0Unlicenseupdated 2026-10-19petritol/context-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 | app | ctx 49% [██████████░░░░░░░░░░] 97.4k/200k | session 31% [███░░░░░░░] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 | app | ctx 49% [██████████░░░░░░░░░░] 97.4k/200k | session 31% [███░░░░░░░]
README

context-statusline

Sample states with contextBudget set to 75%

A Claude Code mod that shows context-window usage at a glance — a colorized percentage, a Unicode progress bar, and a used / total token count — in a band just above the prompt. It also shows your session (5-hour) and weekly usage limits, with how long until each one resets.

The color runs green → yellow → red as the context fills — fully red at 200k tokens by default, or at a point you configure. The bar's empty cells preview that gradient in a darker shade, and a │ in the current color marks where the budget is reached (┃ once the fill has passed it). The line is drawn on a black background so the colors read on light themes too.

Each section colors itself. The model, project and separators are neutral gray. The session and weekly limits run green → yellow at 50% → red at 100%, and that point is not configurable. Their bars are half the width of the context bar, with no budget marker.

As the terminal narrows, the bars shrink, then drop out, then the week limit goes, then the session limit, and finally the project name.

Requirements

  • Claude Code with mod support (function-hook plugins). Tested on v2.1.303.
  • A UTF-8 terminal (for the █ / ░ bar glyphs) with truecolor support for the gradient (virtually all modern terminals qualify).

The band draws in the terminal and in the desktop app's Code tab.

Usage limits appear only on a Claude subscription, and only after the session's first reply. The countdown refreshes each minute without making API calls or using tokens.

Installation

Install it from the petritol plugin marketplace:

/plugin marketplace add petritol/claude-plugins
/plugin install context-statusline@petritol

The band appears above the prompt and updates after each turn.

From a local clone

git clone https://github.com/petritol/context-statusline
claude --plugin-dir /path/to/context-statusline

To load it in every session without the flag, add the folder to the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/context-statusline"
  }
}

Upgrading from the status line script

Earlier versions were a statusLine command script. Remove the statusLine block from ~/.claude/settings.json and delete ~/.claude/statusline-context.js, or the usage shows twice.

Configuration

contextBudget sets the usage at which the color turns fully red; it passes yellow at three quarters of that point. Give it a token count to keep the gradient on fixed numbers, or a share of the context window to scale it with whatever window the session has. The default is 200k.

FormatExampleMeans
Plain number120000120,000 tokens
k suffix200k, 150.5kthousands of tokens
M suffix1M, 1.5Mmillions of tokens
% suffix80%, 62.5%that share of the session's context window
  • Decimals are allowed, k and M work in either case, and spaces around the value or before the unit are ignored.
  • A percentage follows the session's window: 80% is 160k on a 200k window and 800k on a 1M one. Values above 100% are accepted, but the color then never reaches full red.
  • Anything else falls back to 200k: zero, negative numbers, thousands separators (200,000), scientific notation (2e5), a leading dot (.5M) or extra words (200k tokens).

Set it in /config, with /plugin configure context-statusline@petritol, or in ~/.claude/settings.json as a quoted string:

{
  "pluginConfigs": {
    "context-statusline@petritol": {
      "options": { "contextBudget": "80%", "showUsageLimits": false }
    }
  }
}

Usage limits

showUsageLimits shows or hides the session (5-hour) and weekly limits. It is a boolean and defaults to true; false hides both limits.

Development

claude plugin validate .
claude plugin test .

License

The Unlicense — public domain. Do whatever you want with it.

Source 2 files
hooks/register.tsx 270 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4const measured = atom({ plugin: 'context-statusline', key: 'measured' } as const, 0)
5
6type Rgb = [number, number, number]
7
8const GREEN: Rgb = [0, 255, 0]
9const YELLOW: Rgb = [255, 255, 0]
10const RED: Rgb = [255, 0, 0]
11
12const DEFAULT_CONTEXT_BUDGET = '200k'
13const YELLOW_SHARE = 0.75
14export const LIMIT_YELLOW_SHARE = 0.5
15const TRACK_BRIGHTNESS = 0.4
16
17const UNITS: Record<string, number> = { '': 1, k: 1_000, m: 1_000_000 }
18
19function parseThreshold(value: string, window: number): number | undefined {
20  const match = /^\s*(\d+(?:\.\d+)?)\s*(k|m|%)?\s*$/i.exec(value)
21  if (!match) return undefined
22  const amount = Number(match[1])
23  const unit = (match[2] ?? '').toLowerCase()
24  return unit === '%' ? (amount / 100) * window : amount * UNITS[unit]!
25}
26
27export function budgetTokens(contextBudget: string, window: number): number {
28  return parseThreshold(contextBudget, window) || parseThreshold(DEFAULT_CONTEXT_BUDGET, window)!
29}
30
31export function formatTokens(tokens: number): string {
32  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
33  if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`
34  return String(Math.trunc(tokens))
35}
36
37function clampText(value: string, limit: number): string {
38  if (limit <= 0) return ''
39  if (value.length <= limit) return value
40  if (limit === 1) return value.slice(0, 1)
41  return `${value.slice(0, limit - 1)}~`
42}
43
44function toHex(rgb: Rgb): string {
45  return `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`
46}
47
48function gradientAt(usedTokens: number, budget: number, yellowShare: number): Rgb {
49  const gradient: [number, Rgb][] = [
50    [0, GREEN],
51    [budget * yellowShare, YELLOW],
52    [budget, RED],
53  ]
54  let rgb = RED
55  for (let i = 0; i < gradient.length - 1; i++) {
56    const [t0, c0] = gradient[i]!
57    const [t1, c1] = gradient[i + 1]!
58    if (usedTokens <= t1) {
59      const t = Math.max(0, (usedTokens - t0) / (t1 - t0))
60      rgb = c0.map((c, j) => Math.round(c + (c1[j]! - c) * t)) as Rgb
61      break
62    }
63  }
64  return rgb
65}
66
67export function colorFor(usedTokens: number, budget: number, yellowShare = YELLOW_SHARE): string {
68  return toHex(gradientAt(usedTokens, budget, yellowShare))
69}
70
71export function trackColorFor(usedTokens: number, budget: number, yellowShare = YELLOW_SHARE): string {
72  return toHex(gradientAt(usedTokens, budget, yellowShare).map(c => Math.round(c * TRACK_BRIGHTNESS)) as Rgb)
73}
74
75export function modelDisplayName(id: string): string {
76  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(id)
77  if (!match) return id
78  const [, family, major, minor, longContext] = match
79  const name = `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}`
80  return longContext ? `${name} (1M context)` : name
81}
82
83export type BarCell = { glyph: string; color: string }
84
85export type Span = { text: string; color: string }
86
87export const NEUTRAL_COLOR = '#b0b0b0'
88
89export function barCells(args: {
90  percent: number
91  width: number
92  tokens: number
93  window: number
94  budget: number
95  yellowShare?: number
96}): BarCell[] {
97  const { percent, width, tokens, window, budget, yellowShare = YELLOW_SHARE } = args
98  const fill = colorFor(tokens, budget, yellowShare)
99  const filled = Math.round((Math.max(0, Math.min(100, percent)) * width) / 100)
100  const marker = budget < window ? Math.floor((budget / window) * width) : -1
101
102  return Array.from({ length: width }, (_, i) => {
103    const isFilled = i < filled
104    if (i === marker) return { glyph: isFilled ? '┃' : '│', color: fill }
105    if (isFilled) return { glyph: '█', color: fill }
106    return { glyph: '░', color: trackColorFor(((i + 0.5) / width) * window, budget, yellowShare) }
107  })
108}
109
110export function lineText(spans: Span[]): string {
111  return spans.map(span => span.text).join('')
112}
113
114export type UsageLimit = { label: 'session' | 'week'; percentUsed: number; resetsInMs?: number }
115
116const LIMIT_KINDS = [
117  ['five_hour', 'session'],
118  ['seven_day', 'week'],
119] as const
120
121export function usageLimits(rateLimits: readonly SessionRateLimit[], now: number): UsageLimit[] {
122  return LIMIT_KINDS.flatMap(([kind, label]) => {
123    const limit = rateLimits.find(reading => reading.kind === kind)
124    if (!limit) return []
125    const resetsAt = Date.parse(limit.resetsAt ?? '')
126    if (resetsAt <= now) return []
127    return [{ label, percentUsed: limit.percentUsed, ...(Number.isNaN(resetsAt) ? {} : { resetsInMs: resetsAt - now }) }]
128  })
129}
130
131export function formatCountdown(ms: number): string {
132  const minutes = Math.ceil(ms / 60_000)
133  if (minutes < 60) return `${minutes}m`
134  const hours = Math.floor(minutes / 60)
135  if (minutes < 1_440) return `${hours}h ${minutes % 60}m`
136  return `${Math.floor(hours / 24)}d ${hours % 24}h`
137}
138
139function sectionSpans(head: string, cells: BarCell[], suffix: string, color: string): Span[] {
140  return [
141    { text: cells.length ? `${head} [` : head, color },
142    ...cells.map(cell => ({ text: cell.glyph, color: cell.color })),
143    { text: `${cells.length ? ']' : ''}${suffix}`, color },
144  ].filter(span => span.text)
145}
146
147function prefixSpans(prefix: string[]): Span[] {
148  const text = prefix.filter(Boolean).map(part => `${part} | `).join('')
149  return text ? [{ text, color: NEUTRAL_COLOR }] : []
150}
151
152function limitSpans(limit: UsageLimit, width: number): Span[] {
153  const cells = barCells({ percent: limit.percentUsed, width, tokens: limit.percentUsed, window: 100, budget: 100, yellowShare: LIMIT_YELLOW_SHARE })
154  const countdown = limit.resetsInMs === undefined ? '' : ` ↻${formatCountdown(limit.resetsInMs)}`
155  return [
156    { text: ' | ', color: NEUTRAL_COLOR },
157    ...sectionSpans(`${limit.label} ${limit.percentUsed.toFixed(0)}%`, cells, countdown, colorFor(limit.percentUsed, 100, LIMIT_YELLOW_SHARE)),
158  ]
159}
160
161function cutSpans(spans: Span[], columns: number): Span[] {
162  const kept: Span[] = []
163  let room = Math.max(0, columns - 1)
164  for (const span of spans) {
165    const text = span.text.slice(0, room)
166    if (text) kept.push({ ...span, text })
167    room -= text.length
168    if (text.length < span.text.length) break
169  }
170  const last = kept.at(-1)
171  if (last) last.text += '~'
172  else kept.push({ text: '~', color: NEUTRAL_COLOR })
173  return kept
174}
175
176export function statusLine(args: {
177  model: string
178  project: string
179  tokens: number
180  window: number
181  percent: number
182  budget: number
183  columns: number
184  limits?: UsageLimit[]
185}): Span[] {
186  const { model, project, tokens, window, percent, budget, columns, limits = [] } = args
187  const maxBar = columns >= 100 ? 20 : columns >= 72 ? 12 : 0
188  const fullPrefix = [clampText(model, 18), clampText(project, 24)]
189  const modelPrefix = [clampText(model, 12)]
190  const fits = (spans: Span[]) => lineText(spans).length <= columns
191
192  const build = (prefix: string[], ctxBar: number, shown: UsageLimit[]): Span[] => [
193    ...prefixSpans(prefix),
194    ...sectionSpans(
195      `ctx ${percent.toFixed(0)}%`,
196      barCells({ percent, width: ctxBar, tokens, window, budget }),
197      window ? ` ${formatTokens(tokens)}/${formatTokens(window)}` : '',
198      colorFor(tokens, budget),
199    ),
200    ...shown.flatMap(limit => limitSpans(limit, ctxBar / 2)),
201  ]
202
203  if (limits.length) {
204    for (const ctxBar of [20, 12, 0].filter(bar => bar <= maxBar)) {
205      const line = build(fullPrefix, ctxBar, limits)
206      if (fits(line)) return line
207    }
208    for (let shown = limits.length - 1; shown >= 1; shown--) {
209      const line = build(fullPrefix, 0, limits.slice(0, shown))
210      if (fits(line)) return line
211    }
212  }
213
214  const ctxBar = limits.length ? 0 : maxBar
215  let line = build(fullPrefix, ctxBar, [])
216  if (!fits(line)) line = build(modelPrefix, ctxBar, [])
217  if (ctxBar || fits(line)) return line
218  return cutSpans(line, columns)
219}
220
221export const register: Register = (on, options) => {
222  const contextBudget = String(options.contextBudget ?? DEFAULT_CONTEXT_BUDGET)
223  const showUsageLimits = options.showUsageLimits !== false
224
225  on('session.start', async ($, e, next) => {
226    if (showUsageLimits) $.clock.every(60_000, () => void update($, measured, n => n + 1))
227    return next(e)
228  })
229
230  on('session.measure', async ($, e, next) => {
231    if (e.changed.includes('context') || e.changed.includes('rateLimits')) await update($, measured, n => n + 1)
232    return next(e)
233  })
234
235  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236    if (e.props.hasSurvey) return next(e)
237    await read($, measured)
238
239    const [{ context, rateLimits }, model, root] = await Promise.all([
240      $.session.usage(),
241      $.session.model(),
242      $.session.root(),
243    ])
244    const limits = showUsageLimits && rateLimits.length ? usageLimits(rateLimits, await $.clock.now()) : []
245    const tokens = context.tokens ?? 0
246    const budget = budgetTokens(contextBudget, context.window)
247    const spans = statusLine({
248      model: modelDisplayName(model),
249      project: root.split(/[\\/]/).filter(Boolean).pop() ?? '',
250      tokens,
251      window: context.window,
252      percent: Math.max(0, Math.min(100, context.percent ?? 0)),
253      budget,
254      columns: e.props.bodyColumns,
255      limits,
256    })
257
258    const { Text } = $.ui.resolve(e)
259    return (
260      <Text color={NEUTRAL_COLOR} backgroundColor="#000000" wrap="truncate">
261        {spans.map(span => (
262          <Text color={span.color}>
263            {span.text}
264          </Text>
265        ))}
266      </Text>
267    )
268  })
269}
270
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'context-statusline': { measured: number }
4  }
5}
6