Claude Code mod: your 5h/7d quota, context window, prompt-cache time left and session cost on one line above the prompt, with per-subagent cost on hover

Know how much room you have left before the next prompt. A Claude Code mod that puts your 5h and 7d quota, context, prompt-cache time and session cost on one line above the prompt, with the subagents still running and what you last asked. It works in the terminal and the desktop app; hover a segment for details.
<img src="docs/media/demo.gif" alt="A Claude Code session in the terminal. A prompt asks for an Explore subagent; the line above the prompt shows the cache as live during the turn, then the running Explore agent, whose hover reads 1 subagent running. The last prompt has a row under the line. Hovering 5h, 7d, ctx, cache and the cost in turn shows each one's details on the row below, under the segment, the cost split into main thread and subagent" width="760">
<a href="#install">Install</a> · <a href="#what-you-see">What you see</a> · <a href="#configure">Configure</a> · <a href="#make-it-yours">Make it yours</a> · <a href="#what-it-can-reach">What it can reach</a>
Inside Claude Code (2.1.287 or later):
/plugin marketplace add VaitaR/claude-code-limits
/plugin install limits@claude-code-limits
/reload-plugins
git clone https://github.com/VaitaR/claude-code-limits
claude --plugin-dir ./claude-code-limits
Mods are an early-access Claude Code feature and their API can change between releases.
| Terminal |
|---|
| <img src="docs/media/band.png" alt="The band in a terminal: 5h and 7d quota gauges with their resets, the context gauge, the cache dot with minutes left and the session cost, with the last prompt on a row of its own" width="760"> |
| Desktop app |
| <img src="docs/media/desktop.png" alt="The band in the Claude desktop app above the prompt box: 5h 22% resetting in 2h22m, 7d 49% resetting in 4d19h, context 61% in amber after two compactions, cache live for 59 minutes, $19.51" width="760"> |
Hovering a segment shows its details: the desktop app lists them as rows, a terminal joins them into one line in a blank row the band keeps under itself, below the segment, or across the segment's row when the band has no row to spare.
| Segment | Shows | Hover, for example |
|---|---|---|
5h 7d | how much of each rate-limit window your plan reports is used, and after ↻ the time to its reset | 5h · 21% used, 79% left · resets 18:07 |
ctx | context against the window Claude Code compacts at; ⟲3 after three compactions | ctx · 31k of 258k tokens · 3 compactions |
cache | ● live while a turn runs, then ● 59m, the minutes the prompt cache has left (seconds in its last minute), and ○ cold once it has none | cache · warm until 16:31 (1h TTL) |
$0.20 | the session's cost at API prices, subagents included | session $0.20 at API prices, subagents included, then the last turn's cost; with a costCommand, rows for the main thread and the subagents |
◐ Explore | the subagents running now: the type of one, ◐ 3 agents for several | 1 subagent running, then per agent its type and task, and a row with how long it has run, its model, its last request's context, its tool calls so far and the latest one: 1m59s · Sonnet 5.5 · 156k ctx · 48 tools · Bash(Find RPC clients) |
❯ fix the flaky… | your last prompt, in the room the line has left and cut with …; with less than 40 cells left, on a row of its own when there is room for one | last prompt · sent 16:18, then the prompt in full |
Quiet until it matters. While all is well the gauges, cache and cost stay in plain text colours. Amber and red mark what needs attention:
| Amber | Red | |
|---|---|---|
5h 7d | 70% used | 90% used |
ctx | 60%, and the ⟲N of compactions | 80% |
cache | under 5 minutes left | expired: the next prompt rewrites the cache |
Model, branch, effort and the like are left to the rest of the app. On a narrow window the line moves whole segments to a second row rather than cutting one; only the last prompt is cut to fit.
In /config, or under pluginConfigs["limits"].options in settings.json:
| Option | Default | Meaning |
|---|---|---|
cacheTtl | 1h | the main loop's prompt-cache lifetime: 1h with extended caching, 5m otherwise |
palette | theme | theme follows your Claude Code theme (light, dark, colour-blind); pastel uses fixed colours tuned for dark terminals |
costCommand | empty | a command that prints the session's cost split as JSON; it is split on spaces into arguments, without quoting |
The command gets the session id as its last argument and prints one JSON object:
{ "total_usd": 5.85, "main_usd": 5.75, "unpriced_calls": 0,
"agents": [{ "agent_type": "Explore", "description": "map the repo", "usd": 0.1 }] }
Only total_usd is required; a missing amount counts as zero. The hover lists agents in the order given, so print the costliest first: it names the first six and sums the rest into one row, and a non-zero unpriced_calls adds a row of its own. For the object above it reads:
session $5.85 at API prices
$5.75 main
$0.10 Explore · map the repo
+$0.42 last turn
The command runs at session start and at the end of each main turn and each subagent, one run at a time, with a 15 s timeout. A non-zero exit, a timeout or output without total_usd keeps the last split. The customize-limits skill explains how to price a transcript.
The plugin ships a skill, customize-limits, that knows where each threshold, colour, gauge and segment lives, how to add one and test it, and the traps of drawing in a terminal. Ask Claude in plain words:
make the context gauge turn amber at 50%
hide the cost segment, or the last prompt while I share my screen
add a segment with the current git branch
wire a costCommand for my sessions
It only watches. No hook denies or rewrites a prompt, a tool call or a subagent; the one drawing hook fills the line above the prompt.
| It reads | Through |
|---|---|
| context, cost and rate-limit readings | session.measure, $.session.usage() |
| when the main loop's requests are sent and answered; a subagent's model and token counts per request | turn.step |
| a subagent's tool calls: the tool and the argument that names its target (a Bash call's description or command, a file's name, a search pattern) | tool.call |
| the text of your own prompts: typed, from the desktop app or Remote Control, never a notification's or another plugin's | prompt.submit |
| compactions | session.compact |
| subagent spawns and ends: type, description, model | agent.spawn, turn.complete |
| the tail of this session's transcript, at session start while no request or prompt has been seen yet, to start the cache clock and show the last prompt Claude Code recorded | sh -c tail -c 400000 ~/.claude/projects/*/<session id>.jsonl |
your costCommand's output, if you set one | $.process.run |
It makes no network requests and calls no model; your costCommand runs as you wrote it. State lives in session atoms and ends with the session. claude plugin validate . prints exactly what it hooks and calls.
cacheTtl. The engine reports no TTL, so a wrong cacheTtl gives a wrong clock. A compaction sets the cache to cold.claude --plugin-dir . # load it; edits hot-reload
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # after the first load, which writes .claude-plugin/types/
| File | Holds |
|---|---|
hooks/register.tsx | the event hooks and the band |
hooks/core.ts | config, formatting and readings as pure functions |
hooks/countdown.tsx | a surface module: the reset and cache countdowns tick on the surface's own clock |
types/index.d.ts | the state contract |
skills/customize-limits/ | the skill for tuning and extending the band |
See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.
MIT. Started as a fork of claude-flightdeck by Stephen Casella.
hooks/register.tsx 531 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { LastPrompt, Running, Usage } from '../types'
5import {
6 DEFAULT_USAGE,
7 PALETTES,
8 activityOf,
9 agentLines,
10 contextOf,
11 contextTokens,
12 fmtAt,
13 fmtCacheLeft,
14 fmtLeft,
15 fmtUsd,
16 gaugeCells,
17 kTokens,
18 lastPromptOf,
19 lastRequestAt,
20 limitLabel,
21 limitsOf,
22 listOf,
23 mergeLimits,
24 normalize,
25 parseConfig,
26 parseCostSplit,
27 plural,
28 promptLine,
29 quotaLevel,
30 shorten,
31 splitLines,
32} from './core'
33import type { Config } from './core'
34
35// ---------------------------------------------------------------- state
36
37const usage = atom({ plugin: 'limits', key: 'usage' } as const, DEFAULT_USAGE)
38const cacheAt = atom({ plugin: 'limits', key: 'cacheAt' } as const, 0)
39const running = atom({ plugin: 'limits', key: 'running' } as const, [])
40const costAtStart = atom({ plugin: 'limits', key: 'costAtStart' } as const, null)
41const lastTurnUsd = atom({ plugin: 'limits', key: 'lastTurnUsd' } as const, null)
42const split = atom({ plugin: 'limits', key: 'split' } as const, null)
43const lastPrompt = atom({ plugin: 'limits', key: 'lastPrompt' } as const, null)
44
45// Every read goes through these, so a value saved under an older shape still reads.
46async function getUsage($: EngineInterface): Promise<Usage> {
47 return normalize(DEFAULT_USAGE, await read($, usage))
48}
49async function getRunning($: EngineInterface): Promise<Running[]> {
50 return listOf<Running>(await read($, running))
51}
52/** Changes one running agent; an id not in the list (spawned before a reload) changes nothing. */
53async function touch($: EngineInterface, id: string, f: (a: Running) => Running) {
54 await update($, running, list => listOf<Running>(list).map(a => (a.id === id ? f(a) : a)))
55}
56async function getLastPrompt($: EngineInterface): Promise<LastPrompt | null> {
57 const p = (await read($, lastPrompt)) as Partial<LastPrompt> | null
58 return p && typeof p.text === 'string' && p.text ? { text: p.text, at: typeof p.at === 'number' ? p.at : null } : null
59}
60const PROMPT_MIN = 40
61const PROMPT_FLOOR = 12
62const numOr = <T,>(v: unknown, d: T): number | T => (typeof v === 'number' && Number.isFinite(v) ? v : d)
63
64/**
65 * A module loaded mid-session (a reload, a resume, a later install) has seen no request and no prompt:
66 * the cache clock starts from the transcript's last main-loop request, and the last prompt is the one
67 * Claude Code recorded there. The transcript is found by session id, so no project-folder naming is
68 * assumed; only its tail is read.
69 */
70async function seedFromTranscript($: EngineInterface) {
71 const needCache = numOr(await read($, cacheAt), 0) <= 0
72 const needPrompt = (await getLastPrompt($)) === null
73 if (!needCache && !needPrompt) return
74 const id = await $.session.id().catch(() => null)
75 if (!id || !/^[\w-]+$/.test(id)) return
76 const out = await $.process
77 .run(['sh', '-c', 'tail -c 400000 "$HOME"/.claude/projects/*/"$1".jsonl 2>/dev/null', 'sh', id], { timeoutMs: 5000 })
78 .then(r => r.stdout)
79 .catch(() => '')
80 const at = needCache ? lastRequestAt(out) : null
81 if (at) await update($, cacheAt, x => (numOr(x, 0) > 0 ? x : at))
82 const text = needPrompt ? lastPromptOf(out) : null
83 if (text) await update($, lastPrompt, x => x ?? { text, at: null })
84}
85
86/**
87 * The window Claude Code compacts against: `autoCompactWindow` from settings, or the model's own limit.
88 * A `summary` breakdown is estimated locally and sends no request.
89 */
90async function readCompactWindow($: EngineInterface) {
91 const u = await $.session.usage({ breakdown: 'summary', columns: 40 }).catch(() => null)
92 const raw = u?.context.breakdown?.rawMaxTokens
93 if (raw) await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), compactWindow: raw }))
94}
95
96/**
97 * One run at a time: a turn end and a subagent's end often land together. A request during a run
98 * queues one more, since the running one may have read the transcript before that end was written.
99 */
100let splitRunning = false
101let splitAgain = false
102
103/** The session's cost by transcript, from the person's `costCommand`; a failure keeps the last split. */
104async function refreshSplit($: EngineInterface, cfg: Config) {
105 if (cfg.costCommand.length === 0) return
106 if (splitRunning) {
107 splitAgain = true
108 return
109 }
110 splitRunning = true
111 try {
112 do {
113 splitAgain = false
114 const sid = await $.session.id().catch(() => null)
115 if (!sid) return
116 const r = await $.process.run([...cfg.costCommand, sid], { timeoutMs: 15_000 }).catch(() => null)
117 const s = r && r.exitCode === 0 ? parseCostSplit(r.stdout, await $.clock.now()) : null
118 if (s) await update($, split, () => s)
119 } while (splitAgain)
120 } finally {
121 splitRunning = false
122 }
123}
124
125/** The session's cost read fresh, not from the last measurement: the last turn's cost subtracts two of these. */
126async function costNow($: EngineInterface): Promise<number | null> {
127 const u = await $.session.usage().catch(() => null)
128 return u?.cost?.usd ?? null
129}
130
131async function resetAll($: EngineInterface) {
132 await update($, cacheAt, () => 0)
133 await update($, running, () => [])
134 await update($, costAtStart, () => null)
135 await update($, lastTurnUsd, () => null)
136 await update($, split, () => null)
137 await update($, lastPrompt, () => null)
138 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
139 await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null, compactions: 0 }))
140}
141
142// ---------------------------------------------------------------- hooks
143
144export const register: Register = (on, options) => {
145 const cfg = parseConfig(options)
146 const C = PALETTES[cfg.palette]
147
148 on('session.start', async ($, e, next) => {
149 // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
150 const u = await $.session.usage().catch(() => null)
151 if (u) {
152 const at = await $.clock.now()
153 await update($, usage, x => ({
154 ...normalize(DEFAULT_USAGE, x),
155 pct: u.context.percent ?? null,
156 tokens: u.context.tokens ?? null,
157 window: u.context.window,
158 costUsd: u.cost?.usd ?? null,
159 limits: mergeLimits(normalize(DEFAULT_USAGE, x).limits, limitsOf(u.rateLimits), at),
160 }))
161 }
162 await Promise.all([seedFromTranscript($), readCompactWindow($)])
163 void refreshSplit($, cfg).catch(() => undefined)
164 return next(e)
165 })
166
167 on('session.end', async ($, e, next) => {
168 if (e.reason === 'clear') await resetAll($)
169 return next(e)
170 })
171
172 // The person's own words: typed, through Remote Control, from an SDK host such as the desktop app, or
173 // sent by a plugin as theirs. A task's notification, a peer session or a schedule is not their prompt.
174 on('prompt.submit', async ($, e, next) => {
175 const done = await next(e)
176 const o = e.origin
177 const own = o.kind === 'composer' || o.kind === 'bridge' || o.kind === 'sdk' || (o.kind === 'plugin' && o.asUser === true)
178 const text = promptLine(e.text)
179 // The prompt has entered by now: a failure here must not reach the person as an error on it.
180 if (own && text && !('drop' in done)) {
181 await $.clock
182 .now()
183 .then(at => update($, lastPrompt, () => ({ text, at })))
184 .catch(() => undefined)
185 }
186 return done
187 })
188
189 on('turn.start', async ($, e, next) => {
190 const cost = await costNow($)
191 await update($, costAtStart, () => cost)
192 return next(e)
193 })
194
195 on('turn.step', async function* ($, e, next) {
196 const id = e.agentId
197 const sentAt = id ? 0 : await $.clock.now()
198 const done = yield* next(e)
199 const u = done.usage
200 if (u && id) await touch($, id, a => ({ ...a, model: u.model || a.model, tokens: contextTokens(u) }))
201 else if (u) await update($, cacheAt, () => sentAt)
202 return done
203 })
204
205 // Counted when a call starts, so the activity is what the agent is doing now. A failed write must not
206 // fail the agent's tool.
207 on('tool.call', async ($, e, next) => {
208 const id = e.agentId
209 if (id) {
210 await touch($, id, a => ({ ...a, tools: (a.tools ?? 0) + 1, activity: activityOf(e.tool, e as unknown as Record<string, unknown>) })).catch(
211 () => undefined,
212 )
213 }
214 return next(e)
215 })
216
217 on('session.measure', async ($, e, next) => {
218 const before = await getUsage($)
219 const at = await $.clock.now()
220 await update($, usage, x => ({
221 ...normalize(DEFAULT_USAGE, x),
222 pct: e.context.percent ?? null,
223 tokens: e.context.tokens ?? null,
224 window: e.context.window,
225 costUsd: e.cost?.usd ?? null,
226 limits: mergeLimits(normalize(DEFAULT_USAGE, x).limits, limitsOf(e.rateLimits), at),
227 }))
228 // A model switch changes the window; the compaction window follows it.
229 if (before.window !== e.context.window) await readCompactWindow($)
230 return next(e)
231 })
232
233 on('session.compact', async ($, e, next) => {
234 const done = await next(e)
235 if (!e.agentId && e.trigger !== 'precompute') {
236 await update($, usage, x => {
237 const u = normalize(DEFAULT_USAGE, x)
238 return { ...u, compactions: u.compactions + 1 }
239 })
240 // The compacted context is a new prefix: nothing of it is cached until the next request.
241 await update($, cacheAt, () => 0)
242 }
243 return done
244 })
245
246 on('agent.spawn', async ($, e, next) => {
247 const started = await next(e)
248 if (started.deny !== undefined || !started.agentId) return started
249 const agent: Running = {
250 id: started.agentId,
251 type: e.name ?? e.subagentType,
252 description: e.description || undefined,
253 model: started.model || undefined,
254 startedAt: await $.clock.now(),
255 }
256 await update($, running, list => [...listOf<Running>(list).filter(a => a.id !== agent.id), agent].slice(-24))
257 return started
258 })
259
260 on('turn.complete', async ($, e, next) => {
261 const done = await next(e)
262 const id = e.agentId
263 if (id) {
264 await update($, running, list => listOf<Running>(list).filter(a => a.id !== id))
265 // A subagent's transcript is final once it ends: price it while the main turn may still run.
266 void refreshSplit($, cfg).catch(() => undefined)
267 return done
268 }
269 const [start, cost] = await Promise.all([read($, costAtStart), costNow($)])
270 const from = numOr(start, null)
271 await update($, lastTurnUsd, () => (from !== null && cost !== null ? Math.max(0, cost - from) : null))
272 void refreshSplit($, cfg).catch(() => undefined)
273 return done
274 })
275
276 // ---------------------------------------------------------------- drawing
277
278 // What the rest of the app does not show, on one line above the prompt: quotas, context, cache, cost,
279 // running agents. Calm while all is well: colour marks only what needs attention. Hovering a segment
280 // shows its details.
281 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282 if (e.props.hasSurvey) return next(e)
283 const els = $.ui.resolve(e)
284 const { Box, Text } = els
285 const hasClient = 'Client' in els
286 const [u, agents, cache, now, lastTurn, sp, lp] = await Promise.all([
287 getUsage($),
288 getRunning($),
289 read($, cacheAt),
290 $.clock.now(),
291 read($, lastTurnUsd),
292 read($, split),
293 getLastPrompt($),
294 ])
295 const cx = contextOf(u)
296 const sentAt = numOr(cache, 0)
297 const last = numOr(lastTurn, null)
298 const levelColor = { ok: C.text, warn: C.amber, hot: C.warn }
299 // `plain` is the segment as text, for its width in a terminal's cells.
300 const segments: { key: string; node: RenderChildren; detail: string[]; cells: number }[] = []
301 const add = (key: string, node: RenderChildren, detail: string | string[], plain: string) =>
302 segments.push({ key, node, detail: typeof detail === 'string' ? [detail] : detail, cells: [...plain].length })
303 const gauge = (label: string, pct: number) => `${label} ${'━'.repeat(6)} ${Math.round(pct)}%`
304 // One glyph for both parts keeps every gauge the same width in a proportional font.
305 const meter = (label: string, pct: number, color: string) => {
306 const filled = gaugeCells(pct, 6)
307 return [
308 <Text dimColor>{`${label} `}</Text>,
309 <Text color={color === C.text ? C.dim : color}>{'━'.repeat(filled)}</Text>,
310 <Text color={C.faint}>{'━'.repeat(6 - filled)}</Text>,
311 <Text color={color} bold>{` ${Math.round(pct)}%`}</Text>,
312 ]
313 }
314
315 for (const l of u.limits.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')) {
316 const label = limitLabel(l.kind)
317 add(
318 `quota-${l.kind}`,
319 <Box>
320 {meter(label, l.pct, levelColor[quotaLevel(l.pct)])}
321 {l.resetsAt ? <Text dimColor> ↻</Text> : null}
322 {!l.resetsAt ? null : hasClient ? (
323 <els.Client key={`band-reset-${l.kind}`} module="./countdown.tsx" props={{ until: l.resetsAt, now, kind: 'left', color: C.dim, warn: C.amber, hot: C.warn }} />
324 ) : (
325 <Text dimColor>{fmtLeft(l.resetsAt - now)}</Text>
326 )}
327 </Box>,
328 `${label} · ${Math.round(l.pct)}% used, ${Math.round(100 - l.pct)}% left${l.resetsAt ? ` · resets ${fmtAt(l.resetsAt, now)}` : ''}`,
329 `${gauge(label, l.pct)}${l.resetsAt ? ` ↻${fmtLeft(l.resetsAt - now)}` : ''}`,
330 )
331 }
332 if (cx.pct !== null) {
333 add(
334 'ctx',
335 <Box>
336 {meter('ctx', cx.pct, cx.pct >= 80 ? C.warn : cx.pct >= 60 ? C.amber : C.text)}
337 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
338 </Box>,
339 `ctx · ${u.tokens !== null ? `${kTokens(u.tokens)} of ${kTokens(cx.limit)} tokens` : `${Math.round(cx.pct)}%`}${u.compactions > 0 ? ` · ${plural(u.compactions, 'compaction')}` : ''}`,
340 `${gauge('ctx', cx.pct)}${u.compactions > 0 ? ` ⟲${u.compactions}` : ''}`,
341 )
342 }
343 const until = sentAt + cfg.cacheTtlMs
344 const ttl = cfg.cacheTtlMs >= 3_600_000 ? '1h' : '5m'
345 add(
346 'cache',
347 <Box>
348 <Text dimColor>cache </Text>
349 {e.props.isWorking ? (
350 <Text color={C.text}>● live</Text>
351 ) : sentAt <= 0 ? (
352 <Text dimColor>○ cold</Text>
353 ) : hasClient ? (
354 <els.Client key="band-cache" module="./countdown.tsx" props={{ until, now, kind: 'cache', color: C.text, warn: C.amber, hot: C.warn }} />
355 ) : (
356 <Text color={until - now <= 0 ? C.warn : until - now < 5 * 60_000 ? C.amber : C.text}>
357 {until - now <= 0 ? '○ cold' : `● ${fmtCacheLeft(until - now)}`}
358 </Text>
359 )}
360 </Box>,
361 // Every request of a running turn re-arms the cache; the countdown starts once the turn ends.
362 e.props.isWorking
363 ? 'cache · live: each request of this turn refreshes it'
364 : sentAt <= 0
365 ? 'cache · cold: the next prompt rewrites it'
366 : until <= now
367 ? `cache · cold since ${fmtAt(until, now)}: the next prompt rewrites it`
368 : `cache · warm until ${fmtAt(until, now)} (${ttl} TTL)`,
369 `cache ${e.props.isWorking ? '● live' : sentAt <= 0 || until <= now ? '○ cold' : `● ${fmtCacheLeft(until - now)}`}`,
370 )
371
372 // The engine prices every response of the session, its subagents' included; on a plan it is the API-price equivalent.
373 if (u.costUsd !== null) {
374 add(
375 'cost',
376 <Text color={C.text}>{fmtUsd(u.costUsd)}</Text>,
377 [
378 `session ${fmtUsd(u.costUsd)} at API prices${sp ? '' : ', subagents included'}`,
379 ...(sp ? splitLines(sp) : []),
380 ...(last !== null ? [`+${fmtUsd(last)} last turn`] : []),
381 ],
382 fmtUsd(u.costUsd),
383 )
384 }
385
386 // A segment like the rest, so a narrow band wraps it whole: squeezed at the right edge it read as `◐…`.
387 if (agents.length > 0) {
388 const label = agents.length === 1 ? `◐ ${agents[0]?.type}` : `◐ ${agents.length} agents`
389 add(
390 'agents',
391 <Text color={C.agent}>{label}</Text>,
392 agentLines(agents, now),
393 label,
394 )
395 }
396
397 const width = Math.max(20, e.props.bodyColumns)
398 const hoverWidth = (rows: string[]) => Math.min(Math.max(...rows.map(l => l.length)) + 2, width)
399 const card = (key: string, rows: string[]) =>
400 rows.map((l, j) => (
401 <Text key={`${key}-${j}`} color={j === 0 ? C.text : C.dim} wrap={key === 'prompt' && j > 0 ? 'wrap' : 'truncate'}>
402 {l}
403 </Text>
404 ))
405
406 // A terminal paints in tree order, so a hover drawn inside its segment lay under the segments after
407 // it, and it clips a hover to the band's own rows, so none can sit above the band. There the band keeps
408 // a blank row under its segments, and a hover is drawn in it under its segment, shifted left to fit:
409 // laid over the band's row it sat flush on the neighbours' text, and revealed as a row in the flow it
410 // made the band jump. With no row to spare under `maxRows` the hover covers its segment's whole row.
411 // Both places come from the band's wrap (its cells are known in a terminal). The segment lights its
412 // hover through a scope.
413 const terminal = e.surface === 'terminal'
414 const placed: { row: number; col: number }[] = []
415 let row = 0
416 let col = 0
417 for (const s of segments) {
418 const room = s.cells + 2
419 if (col > 0 && col + room > width) {
420 row += 1
421 col = 0
422 }
423 placed.push({ row, col })
424 col += room
425 }
426
427 // The last prompt takes what its row has left, cut with `…`; with less than PROMPT_MIN cells left it
428 // starts a row of its own. Its cells are set here, not by its text, so it is placed last. A band
429 // taller than `maxRows` is folded into an `n more` row, hiding every segment: the prompt then stays
430 // in what its row has left, or is left out.
431 const plain = lp ? `❯ ${lp.text}` : ''
432 const full = [...plain].length
433 if (lp && col > 0 && width - col - 2 < Math.min(full, PROMPT_MIN) && row + 2 <= e.props.maxRows) {
434 row += 1
435 col = 0
436 }
437 const cells = Math.min(full, width - col - 2)
438 // PROMPT_FLOOR bounds a cut prompt; a short one that fits whole always shows.
439 if (lp && cells >= Math.min(full, PROMPT_FLOOR)) {
440 segments.push({
441 key: 'prompt',
442 node: (
443 <Box key="band-prompt" width={cells}>
444 <Text color={C.dim} wrap="truncate">
445 {plain}
446 </Text>
447 </Box>
448 ),
449 detail: [lp.at !== null ? `last prompt · sent ${fmtAt(lp.at, now)}` : 'last prompt', shorten(lp.text, 400)],
450 cells,
451 })
452 placed.push({ row, col })
453 }
454 const under = row + 2 <= e.props.maxRows
455
456 return (
457 <Box width={width} flexDirection={terminal ? 'column' : 'row'}>
458 <Box flexWrap="wrap" flexShrink={1}>
459 {/* The gap trails each segment: a wrapped row then starts flush, where a leading `│` read as a stray mark. */}
460 {segments.map(s =>
461 terminal ? (
462 <Box key={s.key} marginRight={2} hover={{ scope: `limits-${s.key}` }}>
463 {s.node}
464 </Box>
465 ) : (
466 <Box key={s.key} marginRight={2}>
467 {s.node}
468 {/* Shown while the pointer is on the segment. Sized to its longest row: unsized, it took the
469 segment's width and truncated. */}
470 <Box
471 position="absolute"
472 top={0}
473 left={0}
474 width={hoverWidth(s.detail)}
475 flexDirection="column"
476 display="none"
477 hover={{ display: 'flex' }}
478 backgroundColor={C.faint}
479 paddingX={1}
480 >
481 {card(s.key, s.detail)}
482 </Box>
483 </Box>
484 ),
485 )}
486 </Box>
487 {terminal && under ? (
488 <Box key="hover-row" height={1} width={width}>
489 {segments.map((s, i) => {
490 const line = s.detail.join(' · ')
491 const w = Math.min([...line].length, width)
492 return (
493 <Box
494 key={`hover-${s.key}`}
495 position="absolute"
496 top={0}
497 left={Math.max(0, Math.min(placed[i]?.col ?? 0, width - w))}
498 width={w}
499 display="none"
500 hover={{ scope: `limits-${s.key}`, display: 'flex' }}
501 >
502 <Text color={C.dim} wrap="truncate">
503 {line}
504 </Text>
505 </Box>
506 )
507 })}
508 </Box>
509 ) : null}
510 {terminal && !under
511 ? segments.map((s, i) => (
512 <Box
513 key={`hover-${s.key}`}
514 position="absolute"
515 top={placed[i]?.row ?? 0}
516 left={0}
517 width={width}
518 display="none"
519 hover={{ scope: `limits-${s.key}`, display: 'flex' }}
520 backgroundColor={C.faint}
521 paddingX={1}
522 >
523 {card(s.key, [s.detail.join(' · ')])}
524 </Box>
525 ))
526 : null}
527 </Box>
528 )
529 })
530}
531hooks/core.ts 292 lines1// Pure data: defaults, config, formatting. Nothing here touches `$`, so every behaviour is testable directly.
2import type { CostSplit, Running, Usage } from '../types'
3
4// ---------------------------------------------------------------- defaults
5
6export const DEFAULT_USAGE: Usage = {
7 pct: null,
8 tokens: null,
9 window: 0,
10 costUsd: null,
11 limits: [],
12 compactions: 0,
13 compactWindow: null,
14}
15
16/** Context against the window Claude Code compacts at, not the model's whole window. */
17export const contextOf = (u: Usage) => {
18 const limit = u.compactWindow ?? u.window
19 const pct = u.tokens !== null && limit > 0 ? (u.tokens / limit) * 100 : u.pct
20 return { limit, pct }
21}
22
23const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
24
25/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
26export const normalize = <T extends object>(def: T, stored: unknown): T =>
27 isObject(stored) ? ({ ...def, ...stored } as T) : def
28
29/** A stored list, or empty when what is stored is not a list. */
30export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
31
32// ---------------------------------------------------------------- config
33
34export type Config = {
35 palette: Palette
36 /** argv the session id is appended to; it prints the JSON the README describes. Empty: no split. */
37 costCommand: string[]
38 /** The prompt cache's lifetime: the engine reports no 5m/1h split, so the person names theirs. */
39 cacheTtlMs: number
40}
41
42/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
43export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
44 const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
45 return {
46 palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
47 costCommand: str('costCommand', '').trim().split(/\s+/).filter(Boolean),
48 cacheTtlMs: str('cacheTtl', '1h') === '5m' ? 300_000 : 3_600_000,
49 }
50}
51
52// ---------------------------------------------------------------- palette
53
54export type Palette = 'theme' | 'pastel'
55export type Colors = Record<'agent' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
56
57/**
58 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
59 * `pastel` is fixed hex tuned for dark terminals.
60 */
61export const PALETTES: Record<Palette, Colors> = {
62 theme: { agent: 'suggestion', amber: 'warning', warn: 'error', dim: 'inactive', faint: 'subtle', text: 'text' },
63 pastel: { agent: '#93c5fd', amber: '#fcd34d', warn: '#fca5a5', dim: '#6b7280', faint: '#3f4654', text: '#e5e7eb' },
64}
65
66// ---------------------------------------------------------------- formatting
67
68export const shorten = (s: string, n: number) => {
69 const one = s.replace(/\s+/g, ' ').trim()
70 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
71}
72
73export const kTokens = (n: number) =>
74 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
75
76/** `1 call`, `2 calls`. */
77export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
78
79export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
80
81/** Filled cells of a `width`-cell gauge; never none while anything is used: 4% of a quota is not 0%. */
82export const gaugeCells = (pct: number, width: number) => {
83 const round = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
84 return pct > 0 ? Math.max(1, round) : round
85}
86
87/** Colour band of a quota by what is used so far. */
88export const quotaLevel = (pct: number): 'ok' | 'warn' | 'hot' => (pct >= 90 ? 'hot' : pct >= 70 ? 'warn' : 'ok')
89
90/** `2h05m`, `45m`; with days, `2d00h`. */
91export const fmtLeft = (ms: number) => {
92 const m = Math.max(0, Math.floor(ms / 60_000))
93 const d = Math.floor(m / 1440)
94 const h = Math.floor((m % 1440) / 60)
95 if (d > 0) return `${d}d${String(h).padStart(2, '0')}h`
96 return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
97}
98
99/** The cache in whole minutes, then seconds in its last minute: the moment to send one more prompt. */
100export const fmtCacheLeft = (ms: number) =>
101 ms <= 0 ? 'cold' : ms < 60_000 ? `${Math.min(59, Math.ceil(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m`
102
103const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
104
105/** A moment as local wall time: `18:40` within a day of `now`, `Thu 14:00` further out. */
106export const fmtAt = (ms: number, now: number) => {
107 const d = new Date(ms)
108 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
109 return Math.abs(ms - now) < 86_400_000 ? hm : `${DAYS[d.getDay()]} ${hm}`
110}
111
112// ---------------------------------------------------------------- rate limits
113
114type Limit = Usage['limits'][number]
115const LIMIT_ORDER = ['five_hour', 'seven_day']
116
117export const limitsOf = (rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] =>
118 rateLimits.map(r => {
119 const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
120 return { kind: r.kind, pct: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
121 })
122
123/**
124 * A reading without a window keeps the last one until it resets: some responses carry no rate-limit
125 * headers, and an empty reading would otherwise blank the quotas until the next one that does.
126 */
127export const mergeLimits = (prev: readonly Limit[], next: readonly Limit[], now: number): Limit[] => {
128 const seen = new Set(next.map(l => l.kind))
129 const kept = prev.filter(l => !seen.has(l.kind) && (l.resetsAt == null || l.resetsAt > now))
130 const rank = (k: string) => (LIMIT_ORDER.includes(k) ? LIMIT_ORDER.indexOf(k) : LIMIT_ORDER.length)
131 return [...next, ...kept].sort((a, b) => rank(a.kind) - rank(b.kind))
132}
133
134/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
135export const limitLabel = (kind: string) =>
136 kind
137 .replace(/five[_ -]?hours?/i, '5h')
138 .replace(/seven[_ -]?days?/i, '7d')
139 .replace(/[_-]+/g, ' ')
140 .trim()
141
142// ---------------------------------------------------------------- cost split
143
144/** A `costCommand` answer, read leniently: anything without a total is no split. */
145export const parseCostSplit = (stdout: string, at: number): CostSplit | null => {
146 let o: Record<string, unknown>
147 try {
148 o = JSON.parse(stdout)
149 } catch {
150 return null
151 }
152 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
153 if (typeof o?.total_usd !== 'number') return null
154 return {
155 at,
156 totalUsd: num(o.total_usd),
157 mainUsd: num(o.main_usd),
158 unpriced: num(o.unpriced_calls),
159 agents: listOf<Record<string, unknown>>(o.agents).map(a => ({
160 type: String(a.agent_type ?? 'agent'),
161 description: String(a.description ?? ''),
162 usd: num(a.usd),
163 })),
164 }
165}
166
167/**
168 * One row per part: the main thread, the costliest agents by name, the rest summed, then unpriced calls.
169 * The amount leads: the desktop sets rows in a proportional font, so a row sized by its character count
170 * can still be truncated, and that must cut the description, never the amount.
171 */
172export const splitLines = (s: CostSplit, max = 6) => {
173 const named = s.agents.slice(0, max).map(a => `${fmtUsd(a.usd)} ${a.type}${a.description ? ` · ${shorten(a.description.toLowerCase(), 28)}` : ''}`)
174 const rest = s.agents.slice(max)
175 const more = rest.length > 0 ? [`${fmtUsd(rest.reduce((n, a) => n + a.usd, 0))} +${rest.length} more`] : []
176 const miss = s.unpriced > 0 ? [`${plural(s.unpriced, 'call')} unpriced`] : []
177 return [`${fmtUsd(s.mainUsd)} main`, ...named, ...more, ...miss]
178}
179
180// ---------------------------------------------------------------- running agents
181
182const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
183
184/** `claude-sonnet-5-5` → `Sonnet 5.5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`; an alias capitalised; any other id as given. */
185export const modelLabel = (id: string) => {
186 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(id)
187 if (m?.[1] && m[2]) return `${cap(m[1].toLowerCase())} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
188 return /^[a-z]+$/.test(id) ? cap(id) : id
189}
190
191/** `45s`, `1m59s`, `1h02m`. */
192export const fmtElapsed = (ms: number) => {
193 const s = Math.max(0, Math.floor(ms / 1000))
194 if (s < 60) return `${s}s`
195 const m = Math.floor(s / 60)
196 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
197}
198
199/** A tool call as Claude Code shows one: `Bash(run the tests)`, `Read(core.ts)`, `Grep(fmtAt)`; the tool alone when no argument names it. */
200export const activityOf = (tool: string, args: Readonly<Record<string, unknown>>) => {
201 const s = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
202 const file = s('file_path') || s('notebook_path')
203 const what =
204 tool === 'Bash'
205 ? s('description') || s('command')
206 : file
207 ? (file.split('/').filter(Boolean).pop() ?? file)
208 : s('description') || s('pattern') || s('query') || s('url') || s('path')
209 return what ? `${tool}(${shorten(what, 40)})` : tool
210}
211
212/** A request's context: what it sent plus what it got back, as the desktop app's agent card counts tokens. */
213export const contextTokens = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) =>
214 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
215
216/**
217 * Two rows per agent: what it is doing, then how far it got. Each part is left out until its event has
218 * been seen, so an agent spawned before a reload reads as its type alone.
219 */
220export const agentLines = (agents: readonly Running[], now: number, max = 4) => {
221 const rows = agents.slice(0, max).flatMap(a => {
222 const facts = [
223 typeof a.startedAt === 'number' ? fmtElapsed(now - a.startedAt) : '',
224 a.model ? modelLabel(a.model) : '',
225 a.tokens ? `${kTokens(a.tokens)} ctx` : '',
226 a.tools ? plural(a.tools, 'tool') : '',
227 a.activity ?? '',
228 ].filter(Boolean)
229 return [`${a.type}${a.description ? ` · ${shorten(a.description, 48)}` : ''}`, ...(facts.length > 0 ? [facts.join(' · ')] : [])]
230 })
231 return [`${plural(agents.length, 'subagent')} running`, ...rows, ...(agents.length > max ? [`+${agents.length - max} more`] : [])]
232}
233
234// ---------------------------------------------------------------- cache clock
235
236type Row = { type?: string; subtype?: string; isSidechain?: boolean; timestamp?: string; message?: { id?: string; usage?: unknown } }
237
238/**
239 * When the main loop's last answered request was sent, read from the tail of the session's transcript:
240 * the last timed row before the last main-loop response (its own first row when none precedes it). The first
241 * line of a tail is cut and skipped. null when no response is there or a compaction came after it.
242 */
243export const lastRequestAt = (tail: string): number | null => {
244 const rows: Row[] = []
245 for (const line of tail.split('\n').slice(1)) {
246 try {
247 rows.push(JSON.parse(line) as Row)
248 } catch {
249 // a cut or blank line
250 }
251 }
252 const main = rows.filter(r => !r.isSidechain)
253 let last = -1
254 for (let i = main.length - 1; i >= 0; i--) {
255 const r = main[i]
256 if (r?.subtype === 'compact_boundary') return null
257 if (r?.type === 'assistant' && r.message?.usage && r.message.id) {
258 last = i
259 break
260 }
261 }
262 if (last < 0) return null
263 const id = main[last]?.message?.id
264 let first = last
265 while (first > 0 && main[first - 1]?.type === 'assistant' && main[first - 1]?.message?.id === id) first--
266 // The request went out after the last row written before its response: a tool result, a prompt, an attachment.
267 const before = main.slice(0, first).filter(r => r.timestamp).pop()
268 const at = Date.parse((before ?? main[first])?.timestamp ?? '')
269 return Number.isFinite(at) ? at : null
270}
271
272// ---------------------------------------------------------------- last prompt
273
274/** A prompt as one line: line breaks and runs of spaces read as one space; a long paste keeps its head. */
275export const promptLine = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, 1000)
276
277/** The person's last prompt as Claude Code records it in the transcript's `last-prompt` rows; null when the tail has none. */
278export const lastPromptOf = (tail: string): string | null => {
279 const lines = tail.split('\n').slice(1)
280 for (let i = lines.length - 1; i >= 0; i--) {
281 const line = lines[i] ?? ''
282 if (!line.includes('"last-prompt"')) continue
283 try {
284 const r = JSON.parse(line) as { type?: string; lastPrompt?: unknown }
285 if (r.type === 'last-prompt' && typeof r.lastPrompt === 'string') return promptLine(r.lastPrompt) || null
286 } catch {
287 // a cut line
288 }
289 }
290 return null
291}
292hooks/countdown.tsx 36 lines1// A countdown ticking on the surface's frame clock, so the cache TTL and reset times move between redraws.
2import type { ClientModule } from 'claude-code'
3import { fmtCacheLeft, fmtLeft } from './core'
4
5type Props = { until: number; now: number; kind: 'cache' | 'left'; color: string; warn: string; hot: string }
6type Ref = { left: number; lastNow: number }
7type State = { ref: Ref }
8
9const text = (props: Props, left: number) => (props.kind === 'left' ? fmtLeft(left) : left <= 0 ? '○ cold' : `● ${fmtCacheLeft(left)}`)
10
11const Countdown: ClientModule<Props, State> = (props, surface) => {
12 const { Text } = surface.elements
13 const ref = surface.state?.ref ?? { left: 0, lastNow: -1 }
14 if (props.now !== ref.lastNow) {
15 ref.lastNow = props.now
16 ref.left = props.until - props.now
17 }
18 if (surface.state === undefined) {
19 surface.setState({ ref })
20 // A tick a minute would drift, so it ticks each second but repaints only when the shown text
21 // changes: each minute, then each second of the cache's last one.
22 surface.every(1000, () => {
23 if (ref.left <= 0) return
24 const was = text(props, ref.left)
25 ref.left -= 1000
26 if (text(props, ref.left) !== was) surface.setState({ ref })
27 })
28 }
29 if (props.kind === 'left') return <Text color={props.color}>{fmtLeft(ref.left)}</Text>
30 // Amber with time left to send one more prompt.
31 const color = ref.left <= 0 ? props.hot : ref.left < 5 * 60_000 ? props.warn : props.color
32 return <Text color={color}>{text(props, ref.left)}</Text>
33}
34
35export default Countdown
36types/index.d.ts 69 lines1export type Usage = {
2 pct: number | null
3 tokens: number | null
4 window: number
5 costUsd: number | null
6 /** `resetsAt` in ms; null when the window reported none. */
7 limits: { kind: string; pct: number; resetsAt?: number | null }[]
8 compactions: number
9 /**
10 * The window Claude Code compacts against (`autoCompactWindow` in settings, or the model's own limit),
11 * read from the session's context breakdown; null until read. Context is measured against it.
12 */
13 compactWindow: number | null
14}
15
16/**
17 * A subagent running now, by the id its spawn returned. The rest is optional: a value saved before they
18 * existed still reads, and each is set only once its event was seen.
19 */
20export type Running = {
21 id: string
22 type: string
23 /** The Agent call's few-word `description`. */
24 description?: string
25 /** The model its last request was answered by, else the one its spawn resolved. */
26 model?: string
27 startedAt?: number
28 /** Its last request's context: sent plus answered tokens. */
29 tokens?: number
30 /** Tool calls its loop has started. */
31 tools?: number
32 /** Its latest tool call, as `activityOf` names it. */
33 activity?: string
34}
35
36/** A session's cost split by transcript, as the `costCommand` priced it when `at`. */
37export type CostSplit = {
38 at: number
39 totalUsd: number
40 mainUsd: number
41 /** Calls no price covered: while above 0 the figures are a lower bound. */
42 unpriced: number
43 agents: { type: string; description: string; usd: number }[]
44}
45
46/** The person's last prompt as one line; `at` is when it was sent, null when read back from the transcript. */
47export type LastPrompt = { text: string; at: number | null }
48
49declare module 'claude-code' {
50 interface PluginState {
51 'limits': {
52 usage: Usage
53 /**
54 * When the main loop last sent a request that got a response: each request reads (or writes) the
55 * prompt cache and restarts its TTL. The request's start, not its end, so a long response never
56 * overstates the time left. 0: none since load, /clear or a compaction.
57 */
58 cacheAt: number
59 running: Running[]
60 /** The session's cost when the current main turn started; null when the host reports none. */
61 costAtStart: number | null
62 /** What the last main turn cost; null before one ends or without a cost. */
63 lastTurnUsd: number | null
64 split: CostSplit | null
65 lastPrompt: LastPrompt | null
66 }
67 }
68}
69