Coloured session lines above the prompt: model, effort, folder, git state, context usage, and rate limit bars with pace

<h1 align="center">claude-status-bar-mod</h1>
<b>Know how much context, session and week you have left, without asking</b>
<a href=".claude-plugin/plugin.json"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fskryvets%2Fclaude-status-bar-mod%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=d77757" alt="Version"/></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License: MIT"/></a> <img src="https://img.shields.io/badge/Claude%20Code-mod-d77757" alt="Claude Code mod"/> <a href="https://github.com/skryvets/claude-status-bar-mod/stargazers"><img src="https://img.shields.io/github/stars/skryvets/claude-status-bar-mod?style=flat&color=e0af68" alt="Stars"/></a>
A Claude Code mod that draws four lines above the prompt: where you are, what fills the context window, and how fast you spend your subscription limits
73% used says little on its own. The bar compares it with the time passed in the window and tells you 16% in reserve · lasts until reset or 3% in deficit · runs out in 3d 12h<img src="docs/hero.svg" alt="The status bar above the Claude Code prompt: a line with model, effort, folder, branch, git state and pull request, a stacked context bar, and Session and Weekly limit bars with pace" width="900"> <em>Illustration of the bar. Colours follow your Claude Code theme</em>
Get started (10 seconds). Type this at the prompt of a Claude Code terminal session:
/plugin install session --marketplace skryvets/claude-status-bar-mod
Answer y to add the marketplace, then pick the user scope. That is all: the bar appears above the prompt
[!NOTE] Mods are an early access feature of Claude Code, so the API this uses may change between releases. Written against Claude Code 2.1.296
| Line | What you get |
|---|---|
| Top line | Model, effort level, folder, git branch, git state and the branch's pull request |
| Context | One bar stacked by category, the free space and the autocompact buffer, then tokens used and percent |
| Session | The 5 hour subscription limit: time passed, usage marker, percent used, pace, forecast, time until reset |
| Weekly | The 7 day subscription limit, drawn the same way |
The same thing as plain text:
claude-opus-5-5 · high · ~/Code/app (add-login) ±3 ↑2 ↓1 · PR #12 open
Context: ██████░░░░░░░░░░░░░░▒▒▒▒ 46.1K (23%) · ■ msgs ■ tools ■ system ▒ buffer
Session: █████████████████████░░░ 73% used · 16% in reserve · lasts until reset · resets in 33m
Weekly: ██████████░█░░░░░░░░░░░░ 46% used · 3% in deficit · runs out in 3d 12h · resets in 4d 0h
| Sign | Meaning |
|---|---|
±N | Files with uncommitted changes, untracked files included |
↑N | Commits not pushed to the upstream branch. A bare ↑ is a branch that has no upstream yet, in a repo that has a remote |
↓N | Commits on the upstream branch to pull |
PR #N open / merged / closed | The pull request of the current branch |
git fetch once a minute and at the end of each turn to keep ↓N current. A remote that asks for a password is skippedgh, or when the branch has no pull request░ is free space and ▒ is the autocompact buffer, the reserve at the end of the window: auto-compaction runs when usage reaches it▌ is a cell split between two categories. A category too small for half a cell is named in the legend but not drawnThe limit bars fill with the time passed in the window, and a darker cell marks how much of the limit is used. A marker inside the fill is reserve, at its edge is on pace, past it is deficit
| Text | Meaning | Colour |
|---|---|---|
on pace | Within 2 points of spending the limit evenly across the window | Green |
N% in reserve | You have used less than even spending would | Green |
N% in deficit | You have used more than even spending would | Yellow, red past 12 points |
lasts until reset / runs out in … | A straight-line forecast from your spending so far | Same as the pace |
limit reached | The limit is fully used | Red |
No pace is shown until 3% of the window has passed (9 minutes for Session, about 5 hours for Weekly)
Type /status-bar-mod to hide the bar, and again to show it. The choice is kept between sessions
Everything runs on your machine. The bar reads session data from Claude Code and runs git status, git remote, git fetch and gh pr view in your folder. The last two talk to your own git remote and to GitHub, and nothing is sent anywhere else
/context showsclaude plugin validate .
claude plugin test .
claude --plugin-dir .
All of the mod is in hooks/register.tsx, and its tests are in tests/session.test.tsx
The pace rules follow CodexBar by Peter Steinberger
The stacked context bar follows context-bar by Hamza Zafar
hooks/register.tsx 402 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SessionLimit, SessionPull, SessionSlice, SessionView } from '../types'
5
6const HOUR = 3600000
7const SPAN: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 168 * HOUR }
8const NAME: Record<string, string> = { five_hour: 'Session', seven_day: 'Weekly' }
9const BAR = 24
10const ORDER = { used: 0, free: 1, buffer: 2 }
11const GLYPH = { used: '█', free: '░', buffer: '▒' }
12const LABEL: Record<string, string> = {
13 'System prompt': 'system',
14 'System tools': 'tools',
15 'MCP tools': 'mcp',
16 'MCP server instructions': 'mcp info',
17 'Memory files': 'memory',
18 'Custom agents': 'agents',
19 Messages: 'msgs',
20 'Autocompact buffer': 'buffer',
21}
22// Palette follows context-bar by Hamza Zafar (github.com/hamzafer/claude-code-mods): /context's theme gives several rows the same grey
23const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#c0caf5']
24const ELAPSED = '#999999'
25const MARK = '#333131'
26const COMMAND = 'status-bar-mod'
27const EMPTY: SessionView = {
28 tokens: 0,
29 percent: 0,
30 model: '',
31 effort: '',
32 folder: '',
33 branch: '',
34 changed: 0,
35 ahead: 0,
36 behind: 0,
37 isUnpublished: false,
38 pull: null,
39 slices: [],
40 window: 0,
41 limits: [],
42 now: 0,
43 isHidden: false,
44}
45const view = atom({ plugin: 'session', key: 'view' } as const, EMPTY)
46
47const short = (n: number) =>
48 n < 1000 ? String(n) : n < 1e6 ? `${(n / 1000).toFixed(1)}K` : `${(n / 1e6).toFixed(1)}M`
49
50const left = (ms: number) => {
51 const minutes = Math.max(0, Math.floor(ms / 60000))
52 const hours = Math.floor(minutes / 60)
53
54 return hours >= 24
55 ? `${Math.floor(hours / 24)}d ${hours % 24}h`
56 : hours > 0
57 ? `${hours}h ${minutes % 60}m`
58 : `${minutes}m`
59}
60
61const heat = (percent: number) =>
62 percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
63
64const cells = (percent: number) =>
65 percent > 0 ? Math.min(BAR, Math.max(1, Math.round((percent / 100) * BAR))) : 0
66
67const FREE = { kind: 'free', color: 'inactive' } as const
68const BUFFER = { kind: 'buffer', color: 'inactive' } as const
69
70// Sized in half cells: ▌ takes one colour as foreground and the next as background, so a cell holds two categories
71const stack = (slices: SessionSlice[], window: number) => {
72 let next = 0
73 const parts = slices.map(slice => ({
74 ...slice,
75 label: LABEL[slice.name] ?? slice.name.toLowerCase(),
76 color:
77 slice.kind !== 'used'
78 ? 'inactive'
79 : slice.name === 'Messages'
80 ? 'claude'
81 : PALETTE[next++ % PALETTE.length]!,
82 }))
83 const used = parts.filter(part => part.kind === 'used')
84 const sum = (list: number[]) => list.reduce((total, n) => total + n, 0)
85 const tokens = (kind: SessionSlice['kind']) =>
86 sum(slices.filter(slice => slice.kind === kind).map(slice => slice.tokens))
87 const scale = (BAR * 2) / Math.max(window, tokens('used'))
88 const quotas = used.map(part => part.tokens * scale)
89 const sizes = quotas.map(Math.floor)
90 // Whole cells in total: a half cell next to the free space leaves a gap in the terminal's background colour
91 const filled = 2 * Math.max(used.length > 0 ? 1 : 0, Math.round(sum(quotas) / 2))
92
93 const order = quotas
94 .map((quota, i) => [quota - sizes[i]!, i] as const)
95 .sort((a, b) => b[0] - a[0])
96
97 for (let turn = 0; sum(sizes) < filled; turn++) {
98 sizes[order[turn % order.length]![1]]!++
99 }
100
101 const buffer = Math.min(BAR * 2 - filled, 2 * Math.round((tokens('buffer') * scale) / 2))
102 const halves = [
103 ...used.flatMap((part, i) => Array<(typeof used)[number]>(sizes[i]!).fill(part)),
104 ...Array<typeof FREE>(BAR * 2 - filled - buffer).fill(FREE),
105 ...Array<typeof BUFFER>(buffer).fill(BUFFER),
106 ]
107
108 return {
109 parts,
110 cells: Array.from({ length: BAR }, (_, i) => {
111 const [a, b] = [halves[i * 2]!, halves[i * 2 + 1]!]
112
113 return a === b || a.kind !== 'used'
114 ? { glyph: GLYPH[a.kind], color: a.color }
115 : { glyph: '▌', color: a.color, ...(b.kind === 'used' && { backgroundColor: b.color }) }
116 }),
117 }
118}
119
120const untilReset = (limit: SessionLimit, now: number) =>
121 limit.resetsAt ? Date.parse(limit.resetsAt) - now : 0
122
123// Pace rules follow CodexBar's UsagePace.swift and UsagePaceText.swift, the marker its UsageProgressBar.swift (github.com/steipete/CodexBar)
124const passed = (limit: SessionLimit, now: number) => {
125 const span = SPAN[limit.kind]
126 const remaining = untilReset(limit, now)
127
128 return !span || remaining <= 0 || remaining > span ? null : ((span - remaining) / span) * 100
129}
130
131const pace = (limit: SessionLimit, now: number) => {
132 const expected = passed(limit, now)
133
134 if (expected === null) return null
135
136 const remaining = untilReset(limit, now)
137 const elapsed = SPAN[limit.kind]! - remaining
138 const actual = Math.min(100, Math.max(0, limit.percentUsed))
139
140 if (actual >= 100) return { verdict: 'limit reached', outlook: '', color: 'error' }
141 if (expected < 3) return null
142
143 const delta = actual - expected
144 const runsOutIn = actual > 0 ? ((100 - actual) / actual) * elapsed : Infinity
145
146 return {
147 verdict:
148 Math.abs(delta) <= 2
149 ? 'on pace'
150 : `${Math.round(Math.abs(delta))}% in ${delta > 0 ? 'deficit' : 'reserve'}`,
151 outlook: runsOutIn >= remaining ? 'lasts until reset' : `runs out in ${left(runsOutIn)}`,
152 color: delta > 12 ? 'error' : delta > 2 ? 'warning' : 'success',
153 }
154}
155
156const PULL: Record<string, string> = { OPEN: 'success', MERGED: 'merged', CLOSED: 'error' }
157
158// Reads `git status --porcelain=v2 --branch` (git-scm.com/docs/git-status#_porcelain_format_version_2)
159const repo = (status: string, hasRemote: boolean) => {
160 const lines = status.split('\n').filter(line => line !== '')
161 const header = (name: string) =>
162 lines.find(line => line.startsWith(`# branch.${name} `))?.slice(`# branch.${name} `.length)
163 const head = header('head') ?? ''
164 const [ahead = 0, behind = 0] = (header('ab') ?? '').split(' ').map(n => Math.abs(Number(n)))
165
166 return {
167 branch: head === '(detached)' ? '' : head,
168 changed: lines.filter(line => !line.startsWith('#')).length,
169 ahead,
170 behind,
171 isUnpublished: hasRemote && head !== '' && head !== '(detached)' && header('upstream') === undefined,
172 }
173}
174
175// Kept out of sync: both ask the network, too slow for every measure
176const review = async ($: EngineInterface, effort: string) => {
177 const [gh] = await Promise.all([
178 $.process
179 .run(['gh', 'pr', 'view', '--json', 'number,state,headRefName'], { timeoutMs: 5000 })
180 .catch(() => null),
181 // No prompt can be answered here, so a remote that asks for a password fails at once
182 $.process
183 .run(['git', 'fetch', '--quiet'], { timeoutMs: 10000, env: { GIT_TERMINAL_PROMPT: '0' } })
184 .catch(() => null),
185 ])
186 let pull: SessionPull | null = null
187
188 try {
189 const { number, state, headRefName } = JSON.parse(gh?.exitCode === 0 ? gh.stdout : 'null')
190
191 pull = { branch: headRefName, number, state }
192 } catch {}
193
194 await update($, view, now => ({ ...now, pull }))
195 await sync($, effort)
196}
197
198const sync = async ($: EngineInterface, effort: string) => {
199 const [cwd, home, model, usage, now, git, remote, hidden] = await Promise.all([
200 $.session.cwd(),
201 $.env.get('HOME'),
202 $.session.model(),
203 $.session.usage({ breakdown: 'summary' }),
204 $.clock.now(),
205 $.process.run(['git', 'status', '--porcelain=v2', '--branch']).catch(() => null),
206 $.process.run(['git', 'remote']).catch(() => null),
207 $.store.get('hidden'),
208 ])
209 await update($, view, ({ pull }) => ({
210 tokens: usage.context.tokens ?? usage.context.breakdown?.totalTokens ?? 0,
211 percent: usage.context.percent ?? usage.context.breakdown?.percentage ?? 0,
212 model,
213 effort,
214 folder: home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
215 ...repo(git?.exitCode === 0 ? git.stdout : '', remote?.exitCode === 0 && remote.stdout.trim() !== ''),
216 pull,
217 slices: (usage.context.breakdown?.categories ?? [])
218 .flatMap(({ name, tokens, kind }) =>
219 kind === 'deferred' || tokens <= 0 ? [] : [{ name, tokens, kind }],
220 )
221 .sort((a, b) => ORDER[a.kind] - ORDER[b.kind]),
222 window: usage.context.breakdown?.rawMaxTokens ?? 0,
223 limits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }) =>
224 resetsAt ? { kind, percentUsed, resetsAt } : { kind, percentUsed },
225 ),
226 now,
227 isHidden: hidden === true,
228 }))
229}
230
231export const register: Register = on => {
232 let effort = ''
233
234 on('session.start', async ($, e, next) => {
235 await $.command.register({ name: COMMAND, description: 'Show or hide the status bar' })
236 await sync($, effort)
237 await review($, effort)
238 $.clock.every(60000, async () => {
239 await sync($, effort)
240 await review($, effort)
241 })
242
243 return next(e)
244 })
245
246 // /clear and resume go on under a new session with empty state, and no session.start fires for them
247 on('classic.SessionStart', async ($, e, next) => {
248 await sync($, effort).catch(() => {})
249 await review($, effort).catch(() => {})
250
251 return next(e)
252 })
253
254 on('command.run', { command: COMMAND }, async $ => {
255 const isHidden = (await $.store.get('hidden')) !== true
256
257 await $.store.set('hidden', isHidden)
258 await sync($, effort)
259
260 return { text: isHidden ? 'Status bar hidden' : 'Status bar shown' }
261 })
262
263 on('session.measure', async ($, e, next) => {
264 await sync($, effort)
265
266 return next(e)
267 })
268
269 on('session.compact', async ($, e, next) => {
270 const result = await next(e)
271
272 if (e.agentId === undefined) {
273 await sync($, effort).catch(() => {})
274 }
275
276 return result
277 })
278
279 on('turn.step', async function* ($, e, next) {
280 const asked = String(e.effort ?? '')
281
282 if (e.agentId === undefined && asked !== effort) {
283 effort = asked
284 await sync($, effort)
285 }
286
287 return yield* next(e)
288 })
289
290 on('turn.complete', async ($, e, next) => {
291 if (e.agentId === undefined) {
292 await sync($, effort)
293 await review($, effort)
294 }
295
296 return next(e)
297 })
298
299 // Drawn above the prompt because the engine drops PromptHint while it shows a notice of its own ("paste again to expand")
300 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
301 const rest = await next(e)
302 const now = await read($, view)
303
304 if (e.props.hasSurvey || now.model === '' || now.isHidden) {
305 return rest
306 }
307
308 const { Box, Text } = $.ui.resolve(e)
309 const dot = <Text dimColor> · </Text>
310 const row = (label: string, percent: number, color: string) => [
311 <Text bold>{`${label}:`.padEnd(9)}</Text>,
312 <Text color={color}>{'█'.repeat(cells(percent))}</Text>,
313 <Text color="inactive">{'░'.repeat(BAR - cells(percent))}</Text>,
314 ]
315
316 const { parts, cells: bar } =
317 now.window > 0 ? stack(now.slices, now.window) : { parts: [], cells: [] }
318 const lines = 2 + now.limits.filter(limit => NAME[limit.kind]).length
319 // TODO: confirm the band should pack tight below this rather than keep its blank rows and scroll - the engine caps it at half the terminal, the prompt included
320 const gap = e.props.maxRows >= lines * 2 - 1 ? 1 : 0
321 const paddingTop = e.props.maxRows >= lines * 2 ? 1 : 0
322
323 return (
324 <Box flexDirection="column" gap={gap} paddingTop={paddingTop} paddingLeft={2}>
325 <Text mono wrap="truncate-end">
326 <Text>{now.model}</Text>
327 {now.effort !== '' && dot}
328 {now.effort !== '' && <Text color="claude">{now.effort}</Text>}
329 {dot}
330 <Text dimColor>{now.folder}</Text>
331 {now.branch !== '' && <Text color="suggestion">{` (${now.branch})`}</Text>}
332 {now.changed > 0 && <Text color="warning">{` ±${now.changed}`}</Text>}
333 {(now.ahead > 0 || now.isUnpublished) && (
334 <Text color="success">{` ↑${now.ahead || ''}`}</Text>
335 )}
336 {now.behind > 0 && <Text color="error">{` ↓${now.behind}`}</Text>}
337 {now.pull?.branch === now.branch && dot}
338 {now.pull?.branch === now.branch && (
339 <Text color={PULL[now.pull.state] ?? 'inactive'}>
340 {`PR #${now.pull.number} ${now.pull.state.toLowerCase()}`}
341 </Text>
342 )}
343 </Text>
344 <Text mono wrap="truncate-end">
345 {parts.length === 0
346 ? row('Context', now.percent, heat(now.percent))
347 : [
348 <Text bold>{'Context: '}</Text>,
349 ...bar.map(({ glyph, ...colors }) => <Text {...colors}>{glyph}</Text>),
350 ]}
351 <Text bold color={heat(now.percent)}>{` ${short(now.tokens)}`}</Text>
352 <Text>{` (${now.percent}%)`}</Text>
353 {/* TODO: confirm the legend is wanted - it is the only added text and is the first thing cut on a narrow terminal */}
354 {parts
355 .filter(part => part.kind !== 'free')
356 .sort((a, b) => ORDER[a.kind] - ORDER[b.kind] || b.tokens - a.tokens)
357 .map((part, i) => (
358 <Text>
359 {i === 0 ? dot : ' '}
360 <Text color={part.color}>{part.kind === 'used' ? '■' : GLYPH[part.kind]}</Text>
361 <Text dimColor>{` ${part.label}`}</Text>
362 </Text>
363 ))}
364 </Text>
365 {now.limits.flatMap(limit => {
366 const name = NAME[limit.kind]
367
368 if (!name) return []
369
370 const at = pace(limit, now.now)
371 const reset = untilReset(limit, now.now)
372 const filled = Math.round(((passed(limit, now.now) ?? 0) / 100) * BAR)
373 const mark = Math.min(BAR - 1, Math.max(0, Math.round((limit.percentUsed / 100) * BAR)))
374
375 return [
376 <Text mono wrap="truncate-end">
377 <Text bold>{`${name}:`.padEnd(9)}</Text>
378 {Array.from({ length: BAR }, (_, i) =>
379 i === mark ? (
380 <Text color={MARK}>█</Text>
381 ) : i < filled ? (
382 <Text color={ELAPSED}>█</Text>
383 ) : (
384 <Text color="inactive">░</Text>
385 ),
386 )}
387 <Text>{` ${Math.round(limit.percentUsed)}% used`}</Text>
388 {at && dot}
389 {at && <Text color={at.color}>{at.verdict}</Text>}
390 {at && at.outlook !== '' && dot}
391 {at && at.outlook !== '' && <Text color={at.color}>{at.outlook}</Text>}
392 {reset > 0 && dot}
393 {reset > 0 && <Text dimColor>{`resets in ${left(reset)}`}</Text>}
394 </Text>,
395 ]
396 })}
397 {rest}
398 </Box>
399 )
400 })
401}
402types/index.d.ts 31 lines1export type SessionLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type SessionSlice = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
4
5export type SessionPull = { branch: string; number: number; state: string }
6
7export type SessionView = {
8 tokens: number
9 percent: number
10 model: string
11 effort: string
12 folder: string
13 branch: string
14 changed: number
15 ahead: number
16 behind: number
17 isUnpublished: boolean
18 pull: SessionPull | null
19 slices: SessionSlice[]
20 window: number
21 limits: SessionLimit[]
22 now: number
23 isHidden: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 session: { view: SessionView }
29 }
30}
31