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

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.
█ / ░ 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.
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.
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"
}
}
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.
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.
| Format | Example | Means |
|---|---|---|
| Plain number | 120000 | 120,000 tokens |
k suffix | 200k, 150.5k | thousands of tokens |
M suffix | 1M, 1.5M | millions of tokens |
% suffix | 80%, 62.5% | that share of the session's context window |
k and M work in either case, and spaces around the value or before the unit are ignored.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.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 }
}
}
}
showUsageLimits shows or hides the session (5-hour) and weekly limits. It is a boolean and defaults to true; false hides both limits.
claude plugin validate .
claude plugin test .
The Unlicense — public domain. Do whatever you want with it.
hooks/register.tsx 270 lines1import { 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}
270types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'context-statusline': { measured: number }
4 }
5}
6