One-line status bar under the prompt: model, effort, git, context, prompt cache, rate limits, cost.

English | Русский
A one-line status bar for Claude Code, drawn under the prompt:
● ◆ Sonnet 5.5:xhi │ ◇ main ~2 +1 ↑1 │ ctx ████░░░░ 13% 127k/1M │ cache 99% Σ98% 1h 42m → 20:45 │ 5h 21% (4h17m) │ 7d 7% (6d17h) │ $1.20 │ up 19m

A capture from a live session, right after its second request: the prefix is read back in full (100%), the session total is Σ86%, the cache lives for one hour.
| Segment | Shows |
|---|---|
● / ◉ | idle / a turn is running |
◆ Sonnet 5.5:xhi | model and thinking effort (lo, md, hi, xhi, max) |
◇ main ~2 +1 ↑1 | branch; ~ modified, + added, - deleted, → renamed, ? untracked, ! conflicts, ↑/↓ ahead/behind, ✓ clean |
ctx | context window fill, with a bar and token counts |
cache | prompt cache of this session: the last request's hit ratio, Σ the whole session's, +Nk written by the last request, miss when it lost the cached prefix, the lifetime (5m / 1h), time left and the moment it goes cold |
5h, 7d | rate-limit windows and time until reset |
$ | session cost |
up | session length |
Bars and percentages turn yellow, orange and red as they fill (50 / 65 / 80%). The line drops detail on narrow terminals. The engine's own hint line stays beneath it, minus the (shift+tab to cycle) and N agents reminders.
The mod draws the PromptHint component, so it needs a terminal Claude Code with mods. Add the mod folder to ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/neocc-mods/neo-statusline" },
"pluginConfigs": { "neo-statusline": { "cacheTtl": "1h" } }
}
cacheTtl: lifetime of the prompt cache. The engine does not report it, so auto (the default) infers it from cache hits after a pause longer than five minutes and shows 5m? until it has proof. Set 5m or 1h to fix it.
showLimits (checkbox, on by default): show the 5h / 7d subscription limit windows. Turn it off if another tool, such as a bar, already shows them. A gateway spend limit is not affected.
The ratio is read / (read + written + uncached) of the last request, not of the whole turn: a turn sums all its requests, which hides what the cache is doing right now. Σ sums every request of the session. Each session keeps its own numbers, so a new session or a /clear starts from cache — instead of showing another session's countdown. Subagent requests are not counted: they have a cache of their own.
A lower ratio right after a long tool result is normal (+Nk is the new text being cached); miss is the real failure, and means the previous prefix was not read back.
For the full picture of a session, tools/ccache reads its transcript:
ln -s "$PWD/tools/ccache" ~/.local/bin/ccache
ccache # the newest session of the current directory
ccache 82045d4f # a session id prefix
It lists every API call (new / written / read tokens), marks CACHE BREAK and COLD requests, and prints the session ratio and when the cache expires.
claude plugin validate .
claude plugin test .hooks/register.tsx 466 lines1import type { Hook, Register, SessionUsage } from 'claude-code'
2
3type Api = Parameters<Hook<'session.start'>>[0]
4
5type Git = {
6 branch: string
7 ahead: number
8 behind: number
9 modified: number
10 added: number
11 deleted: number
12 renamed: number
13 untracked: number
14 conflicts: number
15}
16
17type Ttl = '5m' | '1h'
18// One main-thread request: `fresh` is the uncached part of the prompt; `broke` marks a
19// request that lost most of what the previous one had cached.
20type Cache = { ratio: number; read: number; write: number; fresh: number; at: number; broke: boolean }
21type Total = { read: number; write: number; fresh: number }
22type Saved = { cache: Cache | null; total: Total; ttl: Ttl | null }
23
24const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
25const TICK_MS = 15_000
26const STORE_KEY = 'cache-v2'
27const KEEP_SESSIONS = 12
28const EFFORT_KEY = 'effort-v1'
29
30const C = {
31 ink: '#1e1e2e',
32 label: '#a6adc8',
33 empty: '#585b70',
34 sep: '#585b70',
35 blue: '#89b4fa',
36 sky: '#89dceb',
37 teal: '#94e2d5',
38 mauve: '#cba6f7',
39 pink: '#f5c2e7',
40 green: '#a6e3a1',
41 lime: '#b8f0a0',
42 yellow: '#f9e2af',
43 orange: '#fab387',
44 red: '#f38ba8',
45}
46
47const usageColor = (pct: number) =>
48 pct >= 80 ? C.red : pct >= 65 ? C.orange : pct >= 50 ? C.yellow : C.green
49
50const cacheColor = (ratio: number) =>
51 ratio >= 90 ? C.lime : ratio >= 75 ? C.green : ratio >= 50 ? C.yellow : ratio > 0 ? C.orange : C.red
52
53const tokens = (n: number) =>
54 n >= 1_000_000
55 ? `${+(n / 1_000_000).toFixed(1)}M`
56 : n >= 1000
57 ? `${Math.round(n / 1000)}k`
58 : `${n}`
59
60const span = (ms: number) => {
61 const m = Math.max(0, Math.round(ms / 60_000))
62 const h = Math.floor(m / 60)
63 const d = Math.floor(h / 24)
64
65 return d >= 1
66 ? `${d}d${h % 24 ? `${h % 24}h` : ''}`
67 : h >= 1
68 ? `${h}h${m % 60 ? `${m % 60}m` : ''}`
69 : `${m}m`
70}
71
72const countdown = (ms: number) =>
73 ms < 60_000 ? `${Math.max(0, Math.ceil(ms / 1000))}s` : span(ms)
74
75const clock = (ms: number) => {
76 const d = new Date(ms)
77
78 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
79}
80
81const bar = (pct: number, width: number) => {
82 const filled = Math.min(width, Math.max(pct > 0 ? 1 : 0, Math.round((pct / 100) * width)))
83
84 return { on: '█'.repeat(filled), off: '░'.repeat(width - filled) }
85}
86
87const middle = (s: string, max: number) =>
88 s.length <= max ? s : `${s.slice(0, Math.ceil((max - 1) / 2))}…${s.slice(s.length - Math.floor((max - 1) / 2))}`
89
90const prettyModel = (id: string) => {
91 const m = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
92
93 if (m) {
94 const name = m[1]!
95 return `${name[0]!.toUpperCase()}${name.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
96 }
97
98 return id.replace(/\[.*\]$/, '')
99}
100
101const EFFORT: Record<string, string> = { low: 'lo', medium: 'md', high: 'hi', xhigh: 'xhi', max: 'max' }
102
103const parseGit = (out: string): Git => {
104 const g: Git = { branch: '', ahead: 0, behind: 0, modified: 0, added: 0, deleted: 0, renamed: 0, untracked: 0, conflicts: 0 }
105 let oid = ''
106
107 for (const line of out.split('\n')) {
108 if (line.startsWith('# branch.head ')) g.branch = line.slice(14)
109 else if (line.startsWith('# branch.oid ')) oid = line.slice(13, 20)
110 else if (line.startsWith('# branch.ab ')) {
111 const m = /\+(\d+) -(\d+)/.exec(line)
112 g.ahead = Number(m?.[1] ?? 0)
113 g.behind = Number(m?.[2] ?? 0)
114 } else if (line.startsWith('? ')) g.untracked += 1
115 else if (line.startsWith('u ')) g.conflicts += 1
116 else if (line.startsWith('1 ') || line.startsWith('2 ')) {
117 const xy = line.slice(2, 4)
118 if (line[0] === '2') g.renamed += 1
119 else if (xy.includes('A')) g.added += 1
120 if (xy.includes('D')) g.deleted += 1
121 if (/[MT]/.test(xy)) g.modified += 1
122 }
123 }
124
125 if (g.branch === '(detached)') g.branch = oid
126 return g
127}
128
129const S: {
130 git: Git | null
131 cwd: string
132 model: string
133 effort: string
134 // The session the cache numbers belong to: the store is shared by every session, and a
135 // /clear changes the id without a `session.start`.
136 sid: string | null
137 // The last main-thread request, and the sums over the whole session.
138 cache: Cache | null
139 total: Total
140 // The TTL the cache is known to have; null while `auto` has not seen proof.
141 ttl: Ttl | null
142 isBusy: boolean
143} = { git: null, cwd: '', model: '', effort: '', sid: null, cache: null, total: { read: 0, write: 0, fresh: 0 }, ttl: null, isBusy: false }
144
145const sessionId = ($: Api) => $.session.id().catch(() => '')
146
147// Points the cache numbers at the current session, loading what the store kept for it.
148async function bind($: Api) {
149 const sid = await sessionId($)
150 if (sid === S.sid) return
151
152 const all = ((await $.store.get(STORE_KEY)) ?? {}) as Record<string, Saved>
153 const saved = all[sid]
154
155 S.sid = sid
156 S.cache = saved?.cache ?? null
157 S.total = saved?.total ?? { read: 0, write: 0, fresh: 0 }
158 S.ttl = saved?.ttl === '5m' || saved?.ttl === '1h' ? saved.ttl : null
159}
160
161async function save($: Api) {
162 if (S.sid === null) return
163
164 const all = ((await $.store.get(STORE_KEY)) ?? {}) as Record<string, Saved>
165 all[S.sid] = { cache: S.cache, total: S.total, ttl: S.ttl }
166
167 // Drop the least recently used sessions so the store does not grow for ever.
168 const stale = Object.keys(all)
169 .sort((a, b) => (all[b]!.cache?.at ?? 0) - (all[a]!.cache?.at ?? 0))
170 .slice(KEEP_SESSIONS)
171 for (const k of stale) delete all[k]
172
173 await $.store.set(STORE_KEY, all)
174}
175
176async function load($: Api) {
177 await bind($)
178
179 const effort = await $.store.get(EFFORT_KEY)
180 if (typeof effort === 'string') S.effort = effort
181}
182
183// One main-thread request landed: its own numbers are the cache's state now.
184async function record($: Api, u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, forced: Ttl | null) {
185 await bind($)
186
187 const now = await $.clock.now()
188 const prev = S.cache
189 const fresh = u.input_tokens
190 const read = u.cache_read_input_tokens
191 const write = u.cache_creation_input_tokens
192 const total = fresh + write + read
193 const ratio = total > 0 ? Math.round((read / total) * 100) : 0
194 const gap = prev ? now - prev.at : 0
195
196 // After a pause past 5m a warm read proves the 1h cache; a cold miss on
197 // a big prompt (inside the hour) proves 5m.
198 if (forced === null && prev && gap > TTL_MS['5m'] + 30_000 && total > 4000) {
199 if (ratio >= 50) S.ttl = '1h'
200 else if (ratio <= 10 && gap < TTL_MS['1h']) S.ttl = '5m'
201 }
202
203 const broke = prev !== null && prev.read > 5000 && read < prev.read * 0.5
204
205 S.cache = { ratio, read, write, fresh, at: now, broke }
206 S.total = { read: S.total.read + read, write: S.total.write + write, fresh: S.total.fresh + fresh }
207 await save($)
208}
209
210async function refresh($: Api) {
211 if (S.isBusy) return
212 S.isBusy = true
213
214 try {
215 S.cwd = await $.session.cwd()
216 S.model = await $.session.model()
217
218 const run = await $.process.run(
219 ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'],
220 { cwd: S.cwd, timeoutMs: 4000 },
221 )
222 S.git = run.exitCode === 0 ? parseGit(run.stdout) : null
223 } catch {
224 S.git = null
225 } finally {
226 S.isBusy = false
227 $.ui.invalidate('ui.render')
228 }
229}
230
231export const register: Register = (on, options) => {
232 const forced = options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
233 const showLimits = options.showLimits !== false
234
235 on('session.start', async ($, e, next) => {
236 await load($)
237 $.clock.every(TICK_MS, () => void refresh($))
238 void refresh($)
239
240 return next(e)
241 })
242
243 on('turn.step', async function* ($, e, next) {
244 if (e.agentId === undefined) {
245 S.model = e.model
246 const effort = typeof e.effort === 'string' ? e.effort : e.effort === undefined ? '' : String(e.effort)
247
248 if (effort !== S.effort) {
249 S.effort = effort
250 await $.store.set(EFFORT_KEY, effort)
251 }
252 }
253
254 // A turn's own `usage` is the sum over all its requests; the cache's state is the last one's.
255 const res = yield* next(e)
256
257 if (e.agentId === undefined && res.usage) await record($, res.usage, forced)
258
259 return res
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 void refresh($)
264
265 return next(e)
266 })
267
268 on('tool.call', async ($, e, next) => {
269 const ran = await next(e)
270
271 if (e.tool === 'Bash' || e.tool === 'Edit' || e.tool === 'Write') void refresh($)
272
273 return ran
274 })
275
276 on('ui.render', { component: 'PromptHint' }, async ($, e) => {
277 const { Box, Text } = $.ui.resolve(e)
278 const now = await $.clock.now()
279 // A /clear or a resume moves the process to another session without a `session.start`.
280 await bind($).catch(() => {})
281 // A failing read must not take the whole bar down with it.
282 const usage = await $.session.usage().catch((): SessionUsage => ({
283 startedAt: now,
284 context: { window: 0 },
285 rateLimits: [],
286 }))
287 const cols = e.viewport?.columns ?? 120
288 // 0: bare essentials, 1: + details, 2: + everything.
289 const level = cols >= 150 ? 2 : cols >= 115 ? 1 : 0
290 const { git, cache, effort } = S
291 const sep = <Text color={C.sep}> │ </Text>
292 const items: unknown[] = []
293
294 // Model, effort.
295 items.push(
296 <Text>
297 <Text color={e.props.isWorking ? C.orange : C.green} bold>
298 {e.props.isWorking ? '◉' : '●'}{' '}
299 </Text>
300 <Text color={C.mauve} bold>
301 ◆ {prettyModel(S.model || (await $.session.model().catch(() => '')) || 'claude')}
302 </Text>
303 {effort ? (
304 <Text color={C.pink} bold>
305 :{EFFORT[effort] ?? effort}
306 </Text>
307 ) : null}
308 </Text>,
309 )
310
311 // Branch and what changed in it.
312 if (git) {
313 const parts: [string, number, string][] = [
314 ['~', git.modified, C.yellow],
315 ['+', git.added, C.green],
316 ['-', git.deleted, C.red],
317 ['→', git.renamed, C.sky],
318 ['?', git.untracked, C.label],
319 ]
320 const changed = parts.filter(([, n]) => n > 0)
321
322 items.push(
323 <Text>
324 <Text color={C.teal} bold>
325 ◇ {middle(git.branch, level === 0 ? 16 : 24)}
326 </Text>
327 {changed.length === 0 && git.conflicts === 0 ? <Text color={C.green}> ✓</Text> : null}
328 {changed.map(([k, n, color]) => (
329 <Text color={color}> {k}{n}</Text>
330 ))}
331 {git.conflicts > 0 ? (
332 <Text color={C.red} bold>
333 {' '}!{git.conflicts}
334 </Text>
335 ) : null}
336 {git.ahead > 0 ? <Text color={C.yellow}> ↑{git.ahead}</Text> : null}
337 {git.behind > 0 ? <Text color={C.red}> ↓{git.behind}</Text> : null}
338 </Text>,
339 )
340 }
341
342 // Context window.
343 const pct = usage.context.percent
344
345 if (pct !== undefined) {
346 const used = usage.context.tokens ?? 0
347 const color = used > 250_000 && pct < 65 ? C.yellow : usageColor(pct)
348 const b = bar(pct, level === 0 ? 6 : 8)
349
350 items.push(
351 <Text>
352 <Text color={C.label}>ctx </Text>
353 <Text color={color}>{b.on}</Text>
354 <Text color={C.empty}>{b.off}</Text>
355 <Text color={color} bold>
356 {' '}{Math.round(pct)}%
357 </Text>
358 {level >= 1 ? (
359 <Text color={C.label}>
360 {' '}{tokens(used)}/{tokens(usage.context.window)}
361 </Text>
362 ) : null}
363 {pct >= 80 ? <Text color={C.red} bold> !</Text> : null}
364 </Text>,
365 )
366 }
367
368 // Prompt cache: the last request's hit ratio (Σ: the whole session's), its lifetime,
369 // and when it goes cold. `+Nk` is what the last request wrote, `miss` a lost prefix.
370 const ttl: Ttl | null = forced ?? S.ttl
371 const sum = S.total.read + S.total.write + S.total.fresh
372 const sumRatio = sum > 0 ? Math.round((S.total.read / sum) * 100) : 0
373
374 if (cache) {
375 const left = TTL_MS[ttl ?? '5m'] - (now - cache.at)
376 const frac = left / TTL_MS[ttl ?? '5m']
377 const timeColor = left <= 0 ? C.red : frac < 0.2 ? C.orange : frac < 0.5 ? C.yellow : C.sky
378
379 items.push(
380 <Text>
381 <Text color={C.label}>cache </Text>
382 <Text color={cacheColor(cache.ratio)} bold>
383 {cache.ratio}%
384 </Text>
385 {level >= 1 ? <Text color={C.label}> Σ{sumRatio}%</Text> : null}
386 {cache.broke ? (
387 <Text color={C.red} bold>
388 {' '}miss
389 </Text>
390 ) : null}
391 {level >= 1 && cache.write >= 4000 ? <Text color={C.orange}> +{tokens(cache.write)}</Text> : null}
392 <Text color={C.label}> {ttl ?? '5m?'}</Text>
393 {left > 0 ? (
394 <Text color={timeColor} bold>
395 {' '}{countdown(left)}
396 {level >= 1 ? <Text color={C.label} bold={false}> → {clock(cache.at + TTL_MS[ttl ?? '5m'])}</Text> : null}
397 </Text>
398 ) : (
399 <Text color={C.red} bold>
400 {' '}cold
401 </Text>
402 )}
403 </Text>,
404 )
405 } else {
406 items.push(<Text color={C.label}>cache — {forced ?? '5m?'}</Text>)
407 }
408
409 // Rate limits.
410 for (const r of usage.rateLimits) {
411 if (!showLimits && (r.kind === 'five_hour' || r.kind === 'seven_day')) continue
412
413 const label = r.kind === 'five_hour' ? '5h' : r.kind === 'seven_day' ? '7d' : r.kind === 'spend_limit' ? 'spend' : r.kind
414 const reset = r.resetsAt ? Date.parse(r.resetsAt) - now : NaN
415
416 items.push(
417 <Text>
418 <Text color={C.label}>{label} </Text>
419 <Text color={usageColor(r.percentUsed)} bold>
420 {Math.round(r.percentUsed)}%
421 </Text>
422 {level >= 1 && Number.isFinite(reset) && reset > 0 ? <Text color={C.label}> ({span(reset)})</Text> : null}
423 </Text>,
424 )
425 }
426
427 // Cost, session length.
428 if (usage.cost) {
429 items.push(
430 <Text color={C.yellow} bold>
431 ${usage.cost.usd.toFixed(2)}
432 </Text>,
433 )
434 }
435
436 if (level >= 2) items.push(<Text color={C.label}>up {span(now - usage.startedAt)}</Text>)
437
438 // The engine's hint minus the "(shift+tab to cycle)" and "N agents" reminders, and the
439 // separators it leaves behind.
440 const hint = e.props.hint
441 .replace(/\(shift\+tab to cycle\)/gi, '')
442 .replace(/[←→]?\s*\d+\s+agents?/gi, '')
443 .replace(/^[\s·•]+|[\s·•]+$/g, '')
444
445 // One row of status; the engine's own hint (shortcuts, agents) stays
446 // beneath it when anything is left of it.
447 return (
448 <Box flexDirection="column">
449 <Text wrap="truncate-end">
450 {items.map((item, i) => (
451 <Text>
452 {i > 0 ? sep : null}
453 {item as never}
454 </Text>
455 ))}
456 </Text>
457 {hint ? (
458 <Text dimColor wrap="truncate-end">
459 {hint}
460 </Text>
461 ) : null}
462 </Box>
463 )
464 })
465}
466