A pane (terminal and Desktop) and a terminal band showing 5-hour and weekly plan usage with burn rate, and where the context window is going.

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.
From a shell:
claude plugin marketplace add dante32683/claude-code-mods
claude plugin install agent-crabs@dante32683-mods
Swap agent-crabs for any mod below. In the desktop app, click + next to the prompt box, then Plugins, add the marketplace dante32683/claude-code-mods, and pick the mods you want.
Mods load when a chat starts, so open a new chat after installing.
A little Clawd above the prompt for each subagent that's running, with what it's doing right now. The crab acts it out: reading a book, walking around with a magnifying glass while it searches, writing on paper while it edits, jogging while a command runs, holding a globe while it's on the web, and thinking in between. It throws confetti when the subagent finishes and sits under a rain cloud if it fails. /crabs-demo plays every animation.
The animated crabs show in the desktop app; the terminal gets a small text crab.
A pane with your plan's 5-hour and weekly usage, how fast you're using it, how much per hour or day you can spend and still make the reset, and what's filling the context window. While the pane is closed, the usage bars sit above the prompt. /meter shows or hides the pane. It saves the last reading to statusline-limits.json in your Claude folder.
Better compaction. Before each compaction it saves the whole conversation to a searchable file in ~/.claude/compact-history/ and tells Claude where it is, so details the summary drops can still be found. Your own messages are kept word for word, the summary is written as a handoff, instruction files are re-read afterward, and when a turn ends close to the auto-compact limit it compacts right away instead of mid-task. History older than 30 days is deleted. Options: turnEndPercent and verbatimUserTokens.
Tells Claude when other Claude chats are working in the same repo (same checkout or another worktree) and which files they've edited, so it can coordinate before work collides. Windows only for now.
/aside <task> hands a side task to a background worker while your chat keeps going. Unlike /btw, the worker has tools. It sees the last few messages for context, and its answer opens in a pane where you can send it to the chat or dismiss it. Haiku by default; /aside opus: <task> uses Opus.
Windows Git Bash only. It fixes a few ways Claude's Bash tool breaks there: very long commands get cut off or mangled, rg patterns starting with / get turned into Windows paths, sed -i doesn't say when nothing changed, and rm "${DIR:?}"/... keeps asking for approval.
agent-crabs takes its idea from hamzafer's agent-radar (MIT), and Clawd's shape from Codrops' article on reverse-engineering Claude's mascot animations. Clawd is Anthropic's mascot; this is a fan project, not affiliated with Anthropic. compact-plus's summary style is modeled on OpenAI's Codex CLI.
MIT
hooks/register.js 675 lines1// usage-context: plan usage and the context window, in one pane.
2//
3// Pane "usage-context" draws on every surface (the terminal and the Desktop
4// app); the band above the prompt carries the usage rows as a fallback only
5// while the pane is not placed.
6//
7// session.measure / turn.complete: take the plan windows (five_hour, seven_day)
8// the last response reported and save a sample per window in $.store, so the
9// recent rate survives restarts and parallel sessions. Refresh the context
10// breakdown (the free local estimate, never the token-count requests).
11// ui.render (Pane, AbovePrompt): draw them.
12//
13// When there is no live reading yet (or a response carried no rate-limit
14// headers), it shows the last one saved in statusline-limits.json, dimmed.
15
16const PANE = 'usage-context'
17const HOUR = 3_600_000
18// recent: how far back the recent rate looks; minSpan: the least history it
19// needs (a weekly rate from a few busy hours would ignore sleep); floor: the
20// least elapsed time the window average divides by, so a young window's first
21// minutes don't project to several hundred percent.
22const WINDOWS = {
23 five_hour: { label: '5h', length: 5 * HOUR, recent: 45 * 60_000, minSpan: 10 * 60_000, floor: 30 * 60_000, perHour: 1, unit: '/h' },
24 seven_day: { label: 'wk', length: 7 * 24 * HOUR, recent: 24 * HOUR, minSpan: 18 * HOUR, floor: 24 * HOUR, perHour: 24, unit: '/day' },
25}
26const ORDER = ['five_hour', 'seven_day']
27const MAX_SAMPLES = 300
28const KEY_PREFIX = 'rl:'
29const HIDDEN_KEY = 'paneHidden'
30// Segment colours for the context bar; free space and the buffer are dim blocks.
31const PALETTE = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'white']
32const LIST_MAX = 8
33// The Desktop app's own context colours (sampled from its Context panel): the
34// four largest used categories in these, the rest grey, the autocompact buffer
35// darker, free space the bare track. Plan bars use the first, or amber and red
36// as they near the limit.
37const APP = { ranked: ['#3987e5', '#d95926', '#199e70', '#c98500'], other: '#97958d', buffer: '#5f5e5a', track: '#292827' }
38const APP_LEVEL = { success: '#3987e5', warning: '#c98500', error: '#d95926' }
39
40// Latest plan readings: { kind, percent, resetsAtMs, isCached }, in ORDER.
41let readings = []
42// Saved samples per window id: [[timeMs, percent], ...], oldest first.
43const samples = new Map()
44// The clock as of the last tick, so rendering never waits on a call.
45let nowMs = 0
46// The account's plan, such as "Max 5x" or "Pro", once read; percentages mean more on a bigger plan.
47let planName = null
48// Latest context figures: { tokens, window, percent, breakdown | null, isExact }.
49let context = null
50let isRefreshing = false
51// Which detail lists the pane has open.
52const open = { files: false, mcp: false, agents: false, skills: false }
53let isPaneOpen = false
54// True once the surface has drawn the pane; the band above the prompt then stays out.
55let isPanePlaced = false
56// The 30-second redraw, so a second session.start doesn't stack another.
57let timer = null
58
59// ---- pure helpers ----
60
61// Which account's windows these are: the CLAUDE_CONFIG_DIR folder name, or
62// "main". The plugin store is shared, so two accounts whose windows reset in
63// the same hour would otherwise mix their samples.
64let account = 'main'
65
66// A window's storage id: the account, the window kind and the hour its reset
67// falls in (last, so loadSamples can age it out).
68function windowId(kind, resetsAtMs) {
69 return KEY_PREFIX + account + ':' + kind + ':' + Math.round(resetsAtMs / HOUR)
70}
71
72// Samples since the window's figure last dropped. Usage only falls when the
73// window resets early, as a plan upgrade does, and older samples then belong
74// to a different count. Small dips are rounding noise.
75function sinceDrop(list) {
76 let start = 0
77 for (let i = 1; i < list.length; i++) if (list[i][1] < list[i - 1][1] - 2) start = i
78 return start === 0 ? list : list.slice(start)
79}
80
81// "2h10m", "45m", "3d4h": a duration in milliseconds, two units at most.
82function span(ms) {
83 const minutes = Math.max(0, Math.round(ms / 60_000))
84 if (minutes < 60) return minutes + 'm'
85 const hours = Math.floor(minutes / 60)
86 if (hours < 24) return hours + 'h' + String(minutes % 60).padStart(2, '0') + 'm'
87 return Math.floor(hours / 24) + 'd' + (hours % 24) + 'h'
88}
89
90// A rate with one decimal under 10, none above.
91function rate(n) {
92 return n < 10 ? n.toFixed(1) : String(Math.round(n))
93}
94
95// 1234 -> "1.2k", 1500000 -> "1.5M".
96function short(n) {
97 if (n >= 1_000_000) return (n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1) + 'M'
98 if (n >= 1_000) return (n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1) + 'k'
99 return String(Math.round(n))
100}
101
102// The last two parts of a path, for a narrow list.
103function tail(path) {
104 const parts = String(path).split(/[\\/]/).filter(Boolean)
105 return parts.slice(-2).join('/')
106}
107
108// The figures for one window, from its reading, its samples and the clock.
109function figures(reading, saved, now) {
110 const w = WINDOWS[reading.kind]
111 const opened = reading.resetsAtMs - w.length
112 const elapsed = Math.min(w.length, Math.max(w.floor, now - opened))
113 const remaining = Math.max(0, reading.resetsAtMs - now)
114 const avg = (reading.percent / (elapsed / HOUR)) * w.perHour
115
116 // Recent rate: from the newest sample at or before the cutoff (or the oldest
117 // one if the window is younger), to now. Idle time counts, so a pause lowers it.
118 let recent = null
119 saved = sinceDrop(saved)
120 if (saved.length > 0) {
121 const cutoff = now - w.recent
122 let from = saved[0]
123 for (const s of saved) if (s[0] <= cutoff) from = s
124 const hours = (now - from[0]) / HOUR
125 if (hours * HOUR >= w.minSpan) recent = (Math.max(0, reading.percent - from[1]) / hours) * w.perHour
126 }
127
128 // The pace that decides the end note and colour: the recent rate when there
129 // is one, otherwise the average. The window ends where that pace lands it.
130 const pace = (recent ?? avg) / w.perHour
131 const projected = reading.percent + pace * (remaining / HOUR)
132 const hoursToFull = pace > 0 ? (100 - reading.percent) / pace : Infinity
133 const willFill = projected >= 100 && reading.percent < 100
134 // Budget: the most you can use per unit of time from now and still reach the
135 // reset. With less than one unit left (an hour, a day) it says nothing useful.
136 const unitsLeft = (remaining / HOUR) * (1 / w.perHour)
137 const budget = unitsLeft >= 1 ? (100 - reading.percent) / unitsLeft : null
138 return { w, remaining, avg, recent, projected, willFill, hoursToFull, budget }
139}
140
141// Theme colours, so the pane follows the user's theme: fine while the pace
142// ends under 80%, a warning under 100%, an error when it fills early.
143function colorOf(percent, f) {
144 if (f.willFill || percent >= 90) return 'error'
145 if (f.projected >= 80) return 'warning'
146 return 'success'
147}
148
149// Split `width` cells among `weights` by largest remainder, each present one at least 1.
150function apportion(weights, width) {
151 const total = weights.reduce((a, b) => a + b, 0)
152 if (total <= 0 || width <= 0) return weights.map(() => 0)
153 const exact = weights.map((x) => (x / total) * width)
154 const cells = exact.map((x, i) => (weights[i] > 0 ? Math.max(1, Math.floor(x)) : 0))
155 let left = width - cells.reduce((a, b) => a + b, 0)
156 const order = exact.map((x, i) => [x - Math.floor(x), i]).sort((a, b) => b[0] - a[0])
157 for (let k = 0; left > 0 && order.length > 0; k = (k + 1) % order.length, left--) cells[order[k][1]]++
158 // Over-allocation from the 1-cell minimum: take back from the widest.
159 while (left < 0) {
160 const widest = cells.indexOf(Math.max(...cells))
161 cells[widest]--
162 left++
163 }
164 return cells
165}
166
167// ---- drawing (take the element table, never `$`) ----
168
169// A section title: larger on the Desktop app, bold in the terminal.
170function heading(Text, children) {
171 return Text({ weight: 'semibold', size: 'heading', children })
172}
173
174// A row with `left` at the start and `right` flush with the end.
175function spread(Box, left, right, props = {}) {
176 return Box({ flexDirection: 'row', justifyContent: 'space-between', columnGap: 2, ...props, children: [left, right] })
177}
178
179// One row per plan window: a single line when the surface is wide, otherwise
180// two lines in a block no wider than the pane, figures flush right.
181function usageRows(els, columns, isDesktop) {
182 const { Box, Text } = els
183 if (readings.length === 0) {
184 return [Text({ dimColor: true, children: 'plan usage: waiting for the first response' })]
185 }
186 const isWide = columns >= 100
187 const width = Math.min(columns - 2, 48)
188 const barWidth = columns >= 40 ? 14 : 10
189 const rows = []
190 for (const r of readings) {
191 const f = figures(r, samples.get(windowId(r.kind, r.resetsAtMs)) ?? [], nowMs)
192 const dim = r.isCached
193 const color = dim ? undefined : colorOf(r.percent, f)
194 const filled = Math.max(0, Math.min(barWidth, Math.round((r.percent / 100) * barWidth)))
195 // The average only until there is a recent rate, so the notes fit the pane.
196 const notes = [f.recent === null ? 'avg ' + rate(f.avg) + '%' + f.w.unit : 'now ' + rate(f.recent) + '%' + f.w.unit]
197 if (f.budget !== null && r.percent < 100) notes.push('budget ' + rate(f.budget) + '%' + f.w.unit)
198 const endColor = f.willFill && !dim ? 'error' : undefined
199
200 const meter = Box({
201 flexDirection: 'row',
202 children: [
203 Text({ weight: 'semibold', dimColor: dim, children: f.w.label + ' ' }),
204 ...(isDesktop && els.Svg
205 ? [els.Svg({ source: svgMeter(r.percent, dim ? APP.other : APP_LEVEL[color]), alt: f.w.label + ' ' + Math.round(r.percent) + '%', width: 120, height: 6 })]
206 : [Text({ color, dimColor: dim, children: '█'.repeat(filled) }), Text({ dimColor: true, children: '░'.repeat(barWidth - filled) })]),
207 Text({ weight: dim ? undefined : 'semibold', dimColor: dim, tabular: true, children: ' ' + (Math.round(r.percent) + '%').padStart(4) }),
208 ],
209 })
210 const resets = Text({ dimColor: true, tabular: true, children: (dim ? 'saved · ' : '') + 'resets in ' + span(f.remaining) })
211 const rates = Text({ dimColor: true, tabular: true, children: notes.join(' ') })
212 const ending = Text({
213 color: endColor,
214 dimColor: endColor === undefined,
215 tabular: true,
216 children: f.willFill ? 'full in ' + span(f.hoursToFull * HOUR) : 'ends near ' + Math.min(100, Math.round(f.projected)) + '%',
217 })
218 if (isWide) {
219 rows.push(Box({ flexDirection: 'row', columnGap: 2, children: [meter, resets, rates, ending] }))
220 } else {
221 rows.push(
222 Box({
223 flexDirection: 'column',
224 width,
225 children: [spread(Box, meter, resets), spread(Box, rates, ending, { paddingLeft: 3 })],
226 }),
227 )
228 }
229 }
230 return rows
231}
232
233// A plan bar as the app draws its own: a rounded fill on a rounded track.
234function svgMeter(percent, fill) {
235 const w = Math.max(0, Math.min(100, percent)) * 10
236 return (
237 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 6" width="1000" height="6" preserveAspectRatio="none">' +
238 '<clipPath id="m"><rect width="1000" height="6" rx="3"/></clipPath><g clip-path="url(#m)">' +
239 '<rect width="1000" height="6" fill="' + APP.track + '"/><rect width="' + w + '" height="6" fill="' + fill + '"/></g></svg>'
240 )
241}
242
243// The context bar as an SVG 1000 units wide, stretched to the pane: one
244// segment per category, a hairline gap between, free space left as the track.
245function svgBar(segments) {
246 const widths = apportion(segments.map((s) => s.c.tokens), 1000)
247 let x = 0
248 const rects = segments.map((s, i) => {
249 const rect = s.c.kind === 'free' ? '' : '<rect x="' + x + '" width="' + Math.max(0, widths[i] - 3) + '" height="6" fill="' + s.hex + '"/>'
250 x += widths[i]
251 return rect
252 })
253 return (
254 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 6" width="1000" height="6" preserveAspectRatio="none">' +
255 '<clipPath id="r"><rect width="1000" height="6" rx="3"/></clipPath><g clip-path="url(#r)">' +
256 '<rect width="1000" height="6" fill="' + APP.track + '"/>' + rects.join('') + '</g></svg>'
257 )
258}
259
260// The context sections: the window summary (a stacked bar by category and its
261// legend), the detail lists behind toggles, and the exact-count footer. Each
262// is one block; the pane spaces the blocks.
263function contextBlocks(els, columns, redraw, actions, isDesktop) {
264 const { Box, Text, Button } = els
265 const column = (children) => Box({ flexDirection: 'column', children })
266 if (!context) return [Text({ dimColor: true, children: 'context: waiting for the first response' })]
267
268 // Before the first response there is no measured count, and the measured
269 // window can differ from the one the breakdown (and the app) use; prefer the
270 // breakdown's window, and its local estimate until a count arrives.
271 const b = context.breakdown
272 const window = b?.rawMaxTokens || context.window
273 const tokens = context.tokens || b?.totalTokens || 0
274 const pct = window ? (tokens / window) * 100 : context.percent ?? 0
275 const summary = [
276 spread(
277 Box,
278 heading(Text, 'Context'),
279 Text({
280 tabular: true,
281 children: [
282 Text({ weight: 'semibold', color: pct >= 85 ? 'error' : pct >= 65 ? 'warning' : undefined, children: Math.round(pct) + '%' }),
283 Text({ dimColor: true, children: ' ' + short(tokens) + ' / ' + short(window) }),
284 ],
285 }),
286 ),
287 ]
288 if (!b) {
289 summary.push(Text({ dimColor: true, children: 'no breakdown yet' }))
290 return [column(summary)]
291 }
292
293 const barWidth = Math.max(10, Math.min(columns - 2, 60))
294 // Used categories largest first, as the app lists them, then the buffer and free space.
295 const shown = b.categories.filter((c) => c.kind !== 'deferred' && c.tokens > 0)
296 const cats = [...shown.filter((c) => c.kind === 'used').sort((x, y) => y.tokens - x.tokens), ...shown.filter((c) => c.kind !== 'used')]
297 const widths = apportion(cats.map((c) => c.tokens), barWidth)
298 let used = 0
299 const segments = cats.map((c, i) => {
300 const rank = c.kind === 'used' ? used++ : -1
301 const hex = rank >= 0 ? APP.ranked[rank] ?? APP.other : c.kind === 'buffer' ? APP.buffer : APP.track
302 const color = isDesktop ? hex : rank >= 0 ? PALETTE[rank % PALETTE.length] : undefined
303 const glyph = c.kind === 'free' ? '░' : c.kind === 'buffer' ? '▒' : '█'
304 return { c, color, hex, text: glyph.repeat(widths[i]) }
305 })
306 // The desktop's proportional font makes a glyph bar wrap; it gets an SVG bar
307 // that fills the pane instead.
308 summary.push(
309 isDesktop && els.Svg
310 ? Box({ marginBottom: 1, children: [els.Svg({ source: svgBar(segments), alt: 'Context ' + Math.round(pct) + '% used', height: 6 })] })
311 : Box({
312 flexDirection: 'row',
313 marginBottom: 1,
314 children: segments.map((s) => Text({ color: s.color, dimColor: s.color === undefined, children: s.text })),
315 }),
316 )
317 for (const s of segments) {
318 const share = ((s.c.tokens / window) * 100).toFixed(1) + '%'
319 summary.push(
320 spread(
321 Box,
322 Box({
323 flexDirection: 'row',
324 flexShrink: 1,
325 children: [
326 Text({ color: s.color, dimColor: s.color === undefined, children: '■ ' }),
327 Text({ dimColor: s.color === undefined, wrap: 'truncate', children: s.c.name }),
328 ],
329 }),
330 Text({ dimColor: true, tabular: true, children: short(s.c.tokens).padStart(6) + share.padStart(7) }),
331 ),
332 )
333 }
334 const deferred = b.categories.filter((c) => c.kind === 'deferred').reduce((a, c) => a + c.tokens, 0)
335 if (deferred > 0) summary.push(Text({ dimColor: true, children: 'on-demand tools ' + short(deferred) + ', outside the window' }))
336 if (b.autoCompactThreshold) summary.push(Text({ dimColor: true, children: 'compacts at ' + short(b.autoCompactThreshold) }))
337
338 // Detail lists, each behind a toggle.
339 const total = (list) => list.reduce((a, x) => a + x.tokens, 0)
340 const byTokens = (list) => [...list].sort((x, y) => y.tokens - x.tokens)
341 const servers = new Map()
342 for (const t of b.mcpTools) {
343 const s = servers.get(t.serverName) ?? { tokens: 0, count: 0 }
344 s.tokens += t.tokens
345 s.count++
346 servers.set(t.serverName, s)
347 }
348 const sections = [
349 { key: 'files', hotkey: 'f', title: 'Memory files', sum: total(b.memoryFiles), items: byTokens(b.memoryFiles).map((x) => [x.type + ' ' + tail(x.path), x.tokens]) },
350 {
351 key: 'mcp',
352 hotkey: 'm',
353 title: 'MCP servers',
354 sum: total(b.mcpTools),
355 items: [...servers.entries()].sort((x, y) => y[1].tokens - x[1].tokens).map(([name, s]) => [name + ' (' + s.count + ' tools)', s.tokens]),
356 },
357 { key: 'agents', hotkey: 'a', title: 'Agents', sum: total(b.agents), items: byTokens(b.agents).map((x) => [x.agentType, x.tokens]) },
358 {
359 key: 'skills',
360 hotkey: 's',
361 title: 'Skills',
362 sum: b.skills ? b.skills.tokens : 0,
363 items: b.skills ? byTokens(b.skills.skillFrontmatter).map((x) => [x.name, x.tokens]) : [],
364 },
365 ]
366 const details = []
367 for (const sec of sections) {
368 if (sec.items.length === 0) continue
369 details.push(
370 spread(
371 Box,
372 Button({
373 key: 'toggle-' + sec.key,
374 label: (open[sec.key] ? '▾ ' : '▸ ') + sec.title,
375 hotkey: sec.hotkey,
376 plain: true,
377 onPress: () => {
378 open[sec.key] = !open[sec.key]
379 redraw()
380 },
381 }),
382 Text({ dimColor: true, tabular: true, children: short(sec.sum) }),
383 ),
384 )
385 if (!open[sec.key]) continue
386 for (const [name, tokens] of sec.items.slice(0, LIST_MAX)) {
387 details.push(
388 spread(
389 Box,
390 Box({ flexShrink: 1, children: [Text({ wrap: 'truncate', children: name })] }),
391 Text({ dimColor: true, tabular: true, children: short(tokens) }),
392 { paddingLeft: 2 },
393 ),
394 )
395 }
396 if (sec.items.length > LIST_MAX) {
397 details.push(Text({ dimColor: true, children: ' … ' + (sec.items.length - LIST_MAX) + ' more' }))
398 }
399 }
400
401 const footer = Box({
402 flexDirection: 'row',
403 columnGap: 2,
404 alignItems: 'center',
405 children: [
406 Button({ key: 'exact', label: context.isExact ? 'Recount' : 'Exact count', hotkey: 'e', variant: 'secondary', onPress: actions.exact }),
407 Text({ dimColor: true, children: context.isExact ? 'counted by the API' : 'local estimate' }),
408 ],
409 })
410
411 const blocks = [column(summary)]
412 if (details.length > 0) blocks.push(column(details))
413 blocks.push(footer)
414 return blocks
415}
416
417// ---- hooks ----
418
419export function register(on) {
420 on('session.start', async ($, e, next) => {
421 const result = await next(e)
422 nowMs = await $.clock.now()
423 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
424 if (dir) account = String(dir).split(/[\\/]/).filter(Boolean).pop() || 'main'
425 await loadSamples($)
426 await readPlan($)
427 await takeReading($)
428 await takeContext($, 'summary')
429 timer?.cancel?.()
430 timer = $.clock.every(30_000, async () => {
431 nowMs = await $.clock.now()
432 $.ui.invalidate('ui.render')
433 })
434 // Open the pane unless the user closed it themselves last time. In a narrow
435 // terminal the open waits, and the band shows the usage rows meanwhile.
436 try {
437 await $.command.register({ name: 'meter', description: 'Show or hide the usage and context pane', immediate: true })
438 if (!(await $.store.get(HIDDEN_KEY))) await openPane($)
439 } catch {
440 // The pane is optional; the band still draws.
441 }
442 return result
443 })
444
445 on('command.run', { command: 'meter' }, async ($) => {
446 if (isPaneOpen) {
447 await $.ui.close({ id: PANE })
448 isPaneOpen = false
449 isPanePlaced = false
450 await $.store.set(HIDDEN_KEY, true)
451 } else {
452 await $.store.delete(HIDDEN_KEY)
453 await openPane($)
454 }
455 return {}
456 })
457
458 on('ui.close', async ($, e, next) => {
459 if (e.id === PANE) {
460 isPaneOpen = false
461 isPanePlaced = false
462 // The user closed it: stay closed in later sessions until /meter.
463 if (e.origin && e.origin.kind === 'person') await $.store.set(HIDDEN_KEY, true)
464 }
465 return next(e)
466 })
467
468 on('session.measure', async ($, e, next) => {
469 const result = await next(e)
470 nowMs = await $.clock.now()
471 if (e.changed.includes('rateLimits')) await accept($, e.rateLimits)
472 return result
473 })
474
475 on('turn.complete', async ($, e, next) => {
476 const result = await next(e)
477 if (!e.agentId) {
478 await takeReading($)
479 await takeContext($, 'summary')
480 }
481 return result
482 })
483
484 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
485 if (e.requestId !== PANE) return next(e)
486 isPanePlaced = true
487 const els = $.ui.resolve(e)
488 const { Box, Text } = els
489 const columns = e.props.bodyColumns ?? 40
490 const redraw = () => $.ui.invalidate('ui.render')
491 const actions = { exact: () => takeContext($, 'full') }
492 const usage = Box({
493 flexDirection: 'column',
494 children: [
495 spread(Box, heading(Text, 'Plan usage'), Text({ dimColor: true, children: planName ?? '' })),
496 ...usageRows(els, columns, e.surface !== 'terminal'),
497 ],
498 })
499 return Box({
500 flexDirection: 'column',
501 paddingX: 1,
502 rowGap: 1,
503 children: [usage, ...contextBlocks(els, columns, redraw, actions, e.surface !== 'terminal')],
504 })
505 })
506
507 // The usage rows above the prompt, as a fallback while the pane isn't drawn
508 // (a narrow terminal, or the pane closed).
509 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
510 if (e.props.hasSurvey || isPanePlaced) return next(e)
511 const theirs = await next(e)
512 // Another plugin's band (a form, say) gets the band to itself: the band is
513 // one scrolling region, so sharing it buries one under the other. The
514 // engine's own drawing comes back as { type: 'engine' }; agent-crabs' few
515 // short rows stack above the usage rows instead.
516 const isCrabs = theirs && theirs.part && theirs.part.plugin === 'agent-crabs'
517 if (theirs && theirs.type !== 'engine' && !isCrabs) return theirs
518 const els = $.ui.resolve(e)
519 const mine = els.Box({ flexDirection: 'column', paddingX: 1, children: usageRows(els, e.props.bodyColumns ?? 80, e.surface !== 'terminal') })
520 return theirs ? els.Box({ flexDirection: 'column', children: [theirs, mine] }) : mine
521 })
522}
523
524async function openPane($) {
525 const placed = await $.ui.open({ id: PANE, title: 'Usage & context', columns: 46 })
526 isPaneOpen = true
527 return placed
528}
529
530// Read the context window; `detail` is 'summary' (local, free) or 'full' (counted by the API).
531async function takeContext($, detail) {
532 if (isRefreshing) return
533 isRefreshing = true
534 try {
535 const usage = await $.session.usage({ breakdown: detail, columns: 40 })
536 const c = usage.context
537 context = {
538 tokens: c.tokens,
539 window: c.window,
540 percent: c.percent,
541 breakdown: c.breakdown ?? (context ? context.breakdown : null),
542 isExact: detail === 'full' && Boolean(c.breakdown),
543 }
544 $.ui.invalidate('ui.render')
545 } catch {
546 // No reading this time; the pane keeps the last one.
547 } finally {
548 isRefreshing = false
549 }
550}
551
552// Read the live plan windows, or the status line's saved ones when there are none.
553async function takeReading($) {
554 try {
555 nowMs = await $.clock.now()
556 const usage = await $.session.usage()
557 if (usage.rateLimits && usage.rateLimits.length > 0) {
558 await accept($, usage.rateLimits)
559 return
560 }
561 await takeCached($)
562 } catch {
563 // No reading this time; the pane keeps the last one.
564 }
565}
566
567// This account's files: by default the profile is ~/.claude.json and the rest
568// is in ~/.claude; with CLAUDE_CONFIG_DIR set, both are in that folder.
569async function accountPaths($) {
570 const join = (base, ...parts) => [base, ...parts].join(String(base).includes('\\') ? '\\' : '/')
571 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
572 if (dir) return { profile: join(dir, '.claude.json'), limits: join(dir, 'statusline-limits.json') }
573 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
574 if (!home) return null
575 return { profile: join(home, '.claude.json'), limits: join(home, '.claude', 'statusline-limits.json') }
576}
577
578// The plan from the profile's rate tier, such as default_claude_max_5x.
579async function readPlan($) {
580 try {
581 const paths = await accountPaths($)
582 if (!paths) return
583 const oauth = JSON.parse(await $.fs.read(paths.profile)).oauthAccount ?? {}
584 const tier = oauth.organizationRateLimitTier ?? ''
585 const max = /max_(\d+)x/.exec(tier)
586 planName = max ? 'Max ' + max[1] + 'x' : tier ? 'Pro' : null
587 } catch {
588 // No plan name; the pane works without it.
589 }
590}
591
592async function takeCached($) {
593 const paths = await accountPaths($)
594 if (!paths) return
595 const saved = JSON.parse(await $.fs.read(paths.limits))
596 const found = []
597 for (const kind of ORDER) {
598 const w = saved[kind]
599 if (!w || typeof w.used_percentage !== 'number') continue
600 const resetsAtMs = w.resets_at * 1000
601 // A window whose reset has passed says nothing about the new one.
602 if (resetsAtMs > nowMs) found.push({ kind, percent: w.used_percentage, resetsAtMs, isCached: true })
603 }
604 if (found.length > 0) {
605 readings = found
606 $.ui.invalidate('ui.render')
607 }
608}
609
610// Take the windows a response reported: show them and save a sample for each.
611async function accept($, limits) {
612 const found = []
613 for (const l of limits) {
614 if (!WINDOWS[l.kind] || !l.resetsAt) continue
615 found.push({ kind: l.kind, percent: l.percentUsed, resetsAtMs: new Date(l.resetsAt).getTime(), isCached: false })
616 }
617 found.sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind))
618 if (found.length === 0) return
619 readings = found
620 for (const r of found) await save($, r)
621 await saveCached($, found)
622 $.ui.invalidate('ui.render')
623}
624
625// Save live windows in the status line's cache too. The Desktop app runs no
626// status line, so without this the next session's first draw shows whatever
627// the terminal last saw, days old.
628async function saveCached($, found) {
629 try {
630 const paths = await accountPaths($)
631 if (!paths) return
632 const saved = {}
633 for (const r of found) saved[r.kind] = { used_percentage: r.percent, resets_at: Math.round(r.resetsAtMs / 1000) }
634 await $.fs.write(paths.limits, JSON.stringify(saved))
635 } catch {
636 // The cache is a fallback; the live reading still draws.
637 }
638}
639
640// Add a sample to a window's list unless the figure has not moved recently.
641async function save($, r) {
642 const id = windowId(r.kind, r.resetsAtMs)
643 // Read again right before writing: another session may have added samples.
644 const stored = await $.store.get(id)
645 const list = Array.isArray(stored) ? stored : (samples.get(id) ?? [])
646 let last = list[list.length - 1]
647 // The window started over (a plan upgrade resets it): start the list over too.
648 if (last && r.percent < last[1] - 2) {
649 list.length = 0
650 last = undefined
651 }
652 if (!last || last[1] !== r.percent || nowMs - last[0] > 10 * 60_000) {
653 list.push([nowMs, r.percent])
654 if (list.length > MAX_SAMPLES) list.splice(0, list.length - MAX_SAMPLES)
655 await $.store.set(id, list)
656 }
657 samples.set(id, list)
658}
659
660// Load the samples saved for the windows now open, and drop the old windows'.
661async function loadSamples($) {
662 const keys = await $.store.keys()
663 const keep = Math.round((nowMs - 8 * 24 * HOUR) / HOUR)
664 for (const key of keys) {
665 if (!key.startsWith(KEY_PREFIX)) continue
666 const hourOfReset = Number(key.slice(key.lastIndexOf(':') + 1))
667 if (hourOfReset < keep) {
668 await $.store.delete(key)
669 continue
670 }
671 const stored = await $.store.get(key)
672 if (Array.isArray(stored)) samples.set(key, stored)
673 }
674}
675