SLOPSHOPPER

usage-context

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.

newpanebandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-context
│ ┃ Usage & context ✕ › fix the failing auth test and add an audit log call │ ┃ Plan usage │ ┃ 5h ████░░░░░░░░░░ 31% resets in ⏺ Read(src/auth.ts) │ ┃ avg 7.7%/h ends nea ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Context 49% 97.4k / 200k ⎿ Added 2 lines, removed 1 line │ ┃ no breakdown yet ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /meter │ │ ⟨Claude Code's own drawing⟩ 5h ████░░░░░░░░░░ 31% resets in 1h00m avg 7.7%/h ends near 39% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 5h ████░░░░░░░░░░ 31% resets in 1h00m avg 7.7%/h ends near 39%
Pane · Usage & context · while holding a tool call
Plan usage 5h ████░░░░░░░░░░ 31% resets in 1h00m avg 7.7%/h ends near 39% Context 49% 97.4k / 200k no breakdown yet
README

claude-code-mods

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.

agent-crabs

Install

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.

Mods

agent-crabs

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.

usage-context

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.

compact-plus

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.

repo-peers

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

/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.

bash-fixes

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.

Credits

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.

License

MIT

Source 1 files
hooks/register.js 675 lines
1// 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