Capsule band above the prompt: 5-hour and weekly quota, context window, CPU, memory and disk

Mods for Claude Code (CLI and the Desktop app's Code tab), each in its own folder under mods/.
| Mod | What it does |
|---|---|
| pillbar | A capsule band above the prompt: the 5-hour and weekly plan usage, this session's context window, and the machine's CPU, memory and disk. |
| clawd-run | While Claude works on a long turn, Clawd plays a side-scrolling platformer above the prompt, with the turn's tokens and cost. |
| token-tank | A pixel-art aquarium above the prompt: Claude's turns feed the fish, the fish spit coins onto the sand and you pick them up; a shop, feeding and levels live in a pane (/fish). |
| plain-narrator | A pane where Haiku explains, in plain language, what Claude is doing, with a side chat you can ask using the model you pick (Haiku, Sonnet, Opus, Fable, or the main conversation itself). Off until /narrator turns it on: it sends Claude's visible text and tool arguments to Haiku on your own quota. |
pillbar, clawd-run and token-tank share the band above the prompt and stack instead of replacing each other.

From a terminal session of Claude Code, one line per mod (y to add the marketplace, then pick a scope):
/plugin install token-tank --marketplace SheldonChangL/claude-mods
From a shell: claude plugin install token-tank --marketplace SheldonChangL/claude-mods. The same works for pillbar, clawd-run and plain-narrator.
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with SheldonChangL/claude-mods, then Add plugin.
To run a mod from a checkout instead, point CLAUDE_CODE_PLUGIN_DIRS at the mods you want, colon-separated, in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/mods/pillbar:/path/to/claude-mods/mods/token-tank:/path/to/claude-mods/mods/plain-narrator"
}
}
Then start a new session (on Desktop, restart the app).
pillbar's quota meters fill as soon as the engine reports usage. If the separate JetDeck mod is loaded, pillbar reads its daemon instead, so the meters fill without sending a message first; without it pillbar samples the machine itself.
Each mod is self-contained. From a mod's folder:
claude plugin test .
claude plugin validate .
/narrator).~/.claude; it reads the language row of your settings to pick its words.PILLBAR_JETDECK_DIR names it.~/.claude, downloads code, or runs a shell.These mods are free and open source. If one of them made your day a little better, a coffee on Ko-fi is appreciated and never expected.
hooks/register.tsx 238 lines1// A one-row band above the prompt: the account's 5-hour and weekly windows and
2// this session's context window, then this machine's CPU, memory and disk.
3// Design by Codex: on Desktop one tinted SVG capsule per meter (capsule.ts),
4// quota bars with a countdown to the reset; on the terminal thin `━━──` lines.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, RenderElement } from 'claude-code'
8
9import type { Context, ProbeStatus, SysSample, Window } from '../types'
10import { GAP_CAPSULE, GAP_GROUP, capsuleLayout } from './capsule'
11import type { Capsule } from './capsule'
12import { TERMINAL_FILL, lineSplitter, mergeWindows, meters, parseQuotaLine, parseSample, parseWindows, terminalLayout } from './model'
13
14const sys = atom({ plugin: 'pillbar', key: 'sys' } as const, null as SysSample | null)
15const probe = atom({ plugin: 'pillbar', key: 'probe' } as const, 'starting' as ProbeStatus)
16const windows = atom({ plugin: 'pillbar', key: 'windows' } as const, [] as Window[])
17const context = atom({ plugin: 'pillbar', key: 'context' } as const, null as Context | null)
18const isHidden = atom({ plugin: 'pillbar', key: 'isHidden' } as const, false)
19
20const INTERVAL_MS = 3000
21/** CPU is drawn as the mean of the last 30 s of samples (local every 3 s, the daemon's every 5 s). */
22const CPU_WINDOW_MS = 30_000
23const RETRIES = 3
24
25const toWindows = (limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Window[] =>
26 limits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
27
28/** The figures alone: never the /context breakdown. */
29const toContext = ({ tokens, window, percent }: Context): Context => ({ tokens, window, percent })
30
31/** The last quota windows any session read, kept across sessions and restarts. */
32const STORE_WINDOWS = 'windows'
33/** How often a session picks up a reading another session stored. */
34const STORE_POLL_MS = 60_000
35
36/**
37 * Lays `fresh` readings (and what `$.store` holds) over the known windows. A
38 * session the engine has not yet answered reports no windows: that keeps the
39 * last reading rather than blanking it.
40 */
41async function rememberWindows($: EngineInterface, fresh: readonly Window[]) {
42 const now = await $.clock.now()
43 const stored = parseWindows(await $.store.get(STORE_WINDOWS))
44 // The store holds the newest reading any session took, so it outranks this
45 // session's earlier copy; a fresh reading outranks both.
46 const merged = mergeWindows(mergeWindows(await read($, windows), stored, now), fresh, now)
47 await update($, windows, () => merged)
48 if (fresh.length > 0) await $.store.set(STORE_WINDOWS, merged)
49}
50
51/** `node` on PATH, then the newest nvm node, then Homebrew's and /usr/local's. */
52async function nodeCandidates($: EngineInterface): Promise<string[]> {
53 const found = ['node']
54 let home = ''
55 try {
56 home = (await $.env.get('HOME')) ?? ''
57 } catch {
58 // no environment here
59 }
60 if (home) {
61 try {
62 const versions = (await $.fs.list(`${home}/.nvm/versions/node`))
63 .filter(e => e.kind === 'dir' && /^v\d+/.test(e.name))
64 .map(e => e.name)
65 .sort((a, b) => Number(b.slice(1).split('.')[0]) - Number(a.slice(1).split('.')[0]))
66 for (const v of versions.slice(0, 2)) found.push(`${home}/.nvm/versions/node/${v}/bin/node`)
67 } catch {
68 // no nvm here
69 }
70 }
71 found.push('/opt/homebrew/bin/node', '/usr/local/bin/node')
72 return found
73}
74
75// Module memory: a reload starts the sampler over (the old child is killed).
76let isProbing = false
77
78async function runProbe($: EngineInterface) {
79 if (isProbing) return
80 isProbing = true
81 // The feed reads the JetDeck daemon (vitals and the plan usage) when it can,
82 // and samples locally when it cannot (bin/feed.mjs).
83 const script = `${$.plugin.root}/bin/feed.mjs`
84 // Desktop may start the engine without nvm's PATH, so after `node` itself
85 // the usual install places are tried by path: no shell, no rc files run.
86 const candidates = (await nodeCandidates($)).map(node => [node, script, String(INTERVAL_MS)])
87 const cpu: { at: number; value: number }[] = []
88 try {
89 for (let attempt = 0; attempt < RETRIES; attempt += 1) {
90 for (const argv of candidates) {
91 let gotLine = false
92 try {
93 const split = lineSplitter()
94 for await (const piece of $.process.spawn({ argv })) {
95 if (!('text' in piece) || piece.stream !== 'stdout') continue
96 for (const line of split(piece.text)) {
97 const quota = parseQuotaLine(line)
98 if (quota) {
99 await rememberWindows($, quota)
100 continue
101 }
102 const sample = parseSample(line)
103 if (!sample) continue
104 cpu.push({ at: sample.at, value: sample.cpu })
105 while (cpu.length > 1 && sample.at - cpu[0]!.at > CPU_WINDOW_MS) cpu.shift()
106 const mean = Math.round((cpu.reduce((a, b) => a + b.value, 0) / cpu.length) * 10) / 10
107 if (!gotLine) await update($, probe, () => 'live')
108 gotLine = true
109 await update($, sys, () => ({ ...sample, cpu: mean }))
110 }
111 }
112 } catch {
113 // Could not start (no node on this PATH) or the module is unloading.
114 }
115 if (gotLine) break
116 }
117 // A child that read out and then died leaves its last sample to go stale.
118 if ((await read($, sys)) === null) await update($, probe, () => 'failed')
119 await $.clock.sleep(10_000 * (attempt + 1))
120 }
121 } catch {
122 // The module unloaded mid-sleep.
123 } finally {
124 isProbing = false
125 }
126}
127
128let isPolling = false
129
130/** Picks up readings other sessions store, and drops windows that reset. */
131async function pollStoredWindows($: EngineInterface) {
132 if (isPolling) return
133 isPolling = true
134 try {
135 for (;;) {
136 await $.clock.sleep(STORE_POLL_MS)
137 await rememberWindows($, [])
138 }
139 } catch {
140 // The module unloaded mid-sleep.
141 } finally {
142 isPolling = false
143 }
144}
145
146export const register: Register = on => {
147 on('session.start', async ($, e, next) => {
148 const started = await next(e)
149 await $.command.register({ name: 'pillbar', description: 'Show or hide the quota and system meters above the prompt' })
150 try {
151 const usage = await $.session.usage()
152 await rememberWindows($, toWindows(usage.rateLimits))
153 await update($, context, () => toContext(usage.context))
154 } catch {}
155 void runProbe($)
156 void pollStoredWindows($)
157 return started
158 })
159
160 on('session.measure', async ($, e, next) => {
161 if (e.changed.includes('rateLimits')) await rememberWindows($, toWindows(e.rateLimits))
162 if (e.changed.includes('context')) await update($, context, () => toContext(e.context))
163 return next(e)
164 })
165
166 on('command.run', { command: 'pillbar' }, async $ => {
167 const hidden = await update($, isHidden, v => !v)
168 return { text: hidden ? 'Meters hidden. /pillbar shows them again.' : 'Meters shown.' }
169 })
170
171 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
172 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
173
174 const ms = meters({
175 windows: await read($, windows),
176 context: await read($, context),
177 sys: await read($, sys),
178 probe: await read($, probe),
179 now: await $.clock.now(),
180 })
181 const { Box, Text } = $.ui.resolve(e)
182 // Another plugin's band (clawd-run's game) may sit beneath: draw it above
183 // the meters rather than in its place, so the meters stay by the prompt.
184 const below = await next(e)
185 const stack = (row: RenderElement) => (
186 <Box key="pillbar-stack" flexDirection="column">
187 {below}
188 {row}
189 </Box>
190 )
191
192 if (e.surface === 'desktop') {
193 const { Svg } = $.ui.resolve(e)
194 const band = capsuleLayout(ms, e.props.bodyColumns)
195 // Capsules never shrink (a squeezed one drew over its neighbour); a row
196 // still too wide wraps whole capsules instead.
197 const capsule = (c: Capsule) => (
198 <Box key={`pillbar-${c.key}`} flexShrink={0}>
199 <Svg source={c.svg} alt={c.alt} width={c.width} height={32} />
200 </Box>
201 )
202 const group = (cs: Capsule[]) => (
203 <Box flexDirection="row" alignItems="center" flexWrap="wrap" gap={GAP_CAPSULE} flexShrink={0}>
204 {cs.map(capsule)}
205 </Box>
206 )
207 return stack(
208 <Box key="pillbar" flexDirection="row" alignItems="center" flexWrap="wrap" columnGap={GAP_GROUP} rowGap={1}>
209 {group(band.quota)}
210 {group(band.system)}
211 </Box>,
212 )
213 }
214
215 const band = terminalLayout(ms, e.props.bodyColumns)
216 const isRoomy = band.density === 'wide' || band.density === 'medium'
217 const parts = band.meters.flatMap((t, i) => {
218 const joint =
219 i === 0 ? [] : [<Text color="inactive">{isRoomy ? (ms[i]!.group !== ms[i - 1]!.group ? ' ║ ' : ' │ ') : ' '}</Text>]
220 return [
221 ...joint,
222 <Box key={`pillbar-${t.key}`} flexDirection="row" flexShrink={0}>
223 <Text color="text">{t.label}</Text>
224 <Text color={t.tone === 'unknown' ? 'inactive' : 'text'}>{`${t.sep}${t.value}`}</Text>
225 {t.filled || t.track ? <Text color={TERMINAL_FILL[t.tone]}>{`${t.sep}${t.filled}`}</Text> : null}
226 {t.track ? <Text color="inactive">{t.track}</Text> : null}
227 {t.detail ? <Text color="subtle">{` ${t.detail}`}</Text> : null}
228 </Box>,
229 ]
230 })
231 return stack(
232 <Box key="pillbar" flexDirection="row" flexWrap="nowrap" overflow="hidden">
233 {parts}
234 </Box>,
235 )
236 })
237}
238hooks/capsule.ts 210 lines1// Desktop capsules (design: Codex, round 3): each meter is one SVG image, a
2// tinted rounded shell with an icon, label, bar, value and detail, after the
3// polish of a reference band the user showed. Text is monospace, so a capsule's
4// width is exact: characters × size × 0.6, held with textLength.
5
6import type { Meter, MeterKey, Tone } from './model'
7import { percent } from './model'
8
9/**
10 * full: icon label bar value | clock detail; medium: icon bar value | detail;
11 * compact: quota and context keep a short bar, system meters drop theirs; tiny: icon value short-detail.
12 */
13export type CapsuleDensity = 'full' | 'medium' | 'compact' | 'tiny'
14
15const DENSITIES: readonly CapsuleDensity[] = ['full', 'medium', 'compact', 'tiny']
16
17// ---- Palette ----------------------------------------------------------------
18
19/** [stroke, fill, icon, bar, label, value, detail] */
20type Hue = readonly [string, string, string, string, string, string, string]
21
22const DARK: Record<MeterKey | 'amber' | 'red', Hue> = {
23 '5h': ['#10B981', '#10B981', '#34D399', '#4ADE80', '#A7F3D0', '#ECFDF5', '#6EE7B7'],
24 week: ['#8B5CF6', '#8B5CF6', '#A78BFA', '#A78BFA', '#C4B5FD', '#F5F3FF', '#C4B5FD'],
25 ctx: ['#6366F1', '#6366F1', '#818CF8', '#818CF8', '#C7D2FE', '#EEF2FF', '#A5B4FC'],
26 cpu: ['#06B6D4', '#06B6D4', '#22D3EE', '#22D3EE', '#A5F3FC', '#ECFEFF', '#67E8F9'],
27 mem: ['#EC4899', '#EC4899', '#F472B6', '#F472B6', '#FBCFE8', '#FDF2F8', '#F9A8D4'],
28 disk: ['#3B82F6', '#3B82F6', '#60A5FA', '#60A5FA', '#BFDBFE', '#EFF6FF', '#93C5FD'],
29 amber: ['#F59E0B', '#F59E0B', '#FBBF24', '#FBBF24', '#FDE68A', '#FFFBEB', '#FCD34D'],
30 red: ['#EF4444', '#EF4444', '#F87171', '#F87171', '#FECACA', '#FEF2F2', '#FCA5A5'],
31}
32
33const LIGHT: Record<MeterKey | 'amber' | 'red', Hue> = {
34 '5h': ['#059669', '#10B981', '#047857', '#059669', '#047857', '#052E16', '#047857'],
35 week: ['#7C3AED', '#8B5CF6', '#6D28D9', '#7C3AED', '#6D28D9', '#2E1065', '#7C3AED'],
36 ctx: ['#4F46E5', '#6366F1', '#4338CA', '#4F46E5', '#4338CA', '#1E1B4B', '#4F46E5'],
37 cpu: ['#0891B2', '#06B6D4', '#0E7490', '#0891B2', '#0E7490', '#164E63', '#0E7490'],
38 mem: ['#DB2777', '#EC4899', '#BE185D', '#DB2777', '#BE185D', '#500724', '#BE185D'],
39 disk: ['#2563EB', '#3B82F6', '#1D4ED8', '#2563EB', '#1D4ED8', '#172554', '#2563EB'],
40 amber: ['#D97706', '#F59E0B', '#B45309', '#D97706', '#92400E', '#451A03', '#B45309'],
41 red: ['#DC2626', '#EF4444', '#B91C1C', '#DC2626', '#991B1B', '#450A0A', '#B91C1C'],
42}
43
44const TRACK = { dark: 'rgba(148,163,184,.18)', light: 'rgba(15,23,42,.14)' }
45
46/** Severity replaces the meter's own hue; no reading keeps it, dimmed. */
47const hueKey = (key: MeterKey, tone: Tone) => (tone === 'critical' ? 'red' : tone === 'caution' ? 'amber' : key)
48
49// ---- Icons (24×24 strokes, lucide style) ------------------------------------
50
51const ICONS: Record<MeterKey | 'clock', string> = {
52 '5h': '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/><circle cx="12" cy="14" r="1"/>',
53 week: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/>',
54 ctx: '<path d="m12 2 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5"/><path d="m3 17 9 5 9-5"/>',
55 cpu: '<rect x="7" y="7" width="10" height="10" rx="1"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/><rect x="10" y="10" width="4" height="4" rx=".5"/>',
56 mem: '<rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h3v4H6zM12 10h3v4h-3zM18 10h.01M6 18v3M10 18v3M14 18v3M18 18v3"/>',
57 disk: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h.01M7 16h.01M11 16h6"/>',
58 clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
59}
60
61// ---- Geometry ---------------------------------------------------------------
62
63const H = 32
64const STROKE = 1.25
65const ICON = 16
66const CLOCK = 14
67const FONT = 'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace'
68
69/** Width of `text` at `size` px in the monospace stack. */
70export const textPx = (text: string, size: number) => Math.round([...text].length * size * 0.6 * 100) / 100
71
72/** XML-escaped, every non-ASCII character as a numeric reference (`—` → `—`). */
73const escape = (s: string) =>
74 s
75 .replace(/&/g, '&')
76 .replace(/</g, '<')
77 .replace(/>/g, '>')
78 .replace(/[^\x20-\x7e]/gu, c => `&#${c.codePointAt(0)};`)
79
80type Part =
81 | { kind: 'icon'; icon: keyof typeof ICONS; size: number; cls: 'icon' }
82 | { kind: 'text'; text: string; size: number; weight: number; cls: 'label' | 'value' | 'detail' }
83 | { kind: 'bar'; width: number }
84 | { kind: 'divider' }
85 | { kind: 'gap'; px: number }
86
87/** The parts of one capsule at a density, left to right, without padding. */
88function parts(m: Meter, density: CapsuleDensity): Part[] {
89 const value = percent(m)
90 const icon: Part = { kind: 'icon', icon: m.key, size: ICON, cls: 'icon' }
91 const gap = (px: number): Part => ({ kind: 'gap', px })
92 const text = (t: string, size: number, weight: number, cls: 'label' | 'value' | 'detail'): Part => ({ kind: 'text', text: t, size, weight, cls })
93 const detailParts = (size: number): Part[] => {
94 if (!m.detail) return []
95 const clock = m.marker !== null ? [{ kind: 'icon', icon: 'clock', size: CLOCK, cls: 'icon' } as Part, gap(5)] : []
96 return [gap(8), { kind: 'divider' }, gap(8), ...clock, text(m.detail, size, 600, 'detail')]
97 }
98 if (density === 'full') {
99 return [icon, gap(6), text(m.labels.long, 12, 600, 'label'), gap(8), { kind: 'bar', width: 48 }, gap(7), text(value, 12, 700, 'value'), ...detailParts(11)]
100 }
101 if (density === 'medium') {
102 return [icon, gap(6), { kind: 'bar', width: 36 }, gap(6), text(value, 11, 700, 'value'), ...detailParts(11)]
103 }
104 const short: Part[] = m.compact ? [gap(5), text(m.compact, 10, 600, 'detail')] : []
105 if (density === 'compact' && m.group === 'quota') {
106 return [icon, gap(6), { kind: 'bar', width: 28 }, gap(6), text(value, 11, 700, 'value'), ...short]
107 }
108 return [icon, gap(5), text(value, 11, 700, 'value'), ...short]
109}
110
111const partPx = (p: Part) =>
112 p.kind === 'icon' ? p.size : p.kind === 'text' ? textPx(p.text, p.size) : p.kind === 'bar' ? p.width : p.kind === 'divider' ? 1 : p.px
113
114const padding = (density: CapsuleDensity) => (density === 'full' ? 10 : 8)
115
116export function capsuleWidth(m: Meter, density: CapsuleDensity): number {
117 return Math.ceil(2 * padding(density) + parts(m, density).reduce((n, p) => n + partPx(p), 0))
118}
119
120const r2 = (n: number) => Math.round(n * 100) / 100
121
122/** One meter as a standalone SVG document. */
123export function capsuleSvg(m: Meter, density: CapsuleDensity): string {
124 const width = capsuleWidth(m, density)
125 const k = hueKey(m.key, m.tone)
126 const [dStroke, dFill, dIcon, dBar, dLabel, dValue, dDetail] = DARK[k]
127 const [lStroke, lFill, lIcon, lBar, lLabel, lValue, lDetail] = LIGHT[k]
128 const dim = m.tone === 'unknown'
129 const body: string[] = []
130 let x = padding(density)
131 for (const p of parts(m, density)) {
132 if (p.kind === 'icon') {
133 const y = (H - p.size) / 2
134 body.push(
135 `<g class="icon" color="${dIcon}" transform="translate(${r2(x)} ${r2(y)}) scale(${r2(p.size / 24)})" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[p.icon]}</g>`,
136 )
137 } else if (p.kind === 'text') {
138 const color = p.cls === 'label' ? dLabel : p.cls === 'value' ? dValue : dDetail
139 const y = p.size >= 11 ? 20 : 19.5
140 body.push(
141 `<text class="${p.cls}" x="${r2(x)}" y="${y}" fill="${color}" font-size="${p.size}" font-weight="${p.weight}" textLength="${textPx(p.text, p.size)}" lengthAdjust="spacingAndGlyphs">${escape(p.text)}</text>`,
142 )
143 } else if (p.kind === 'bar') {
144 const fill = m.pct === null ? 0 : r2((p.width * m.pct) / 100)
145 // Unique per capsule and density, in case a host inlines several documents.
146 const clip = `pillbar-${m.key}-${density}-bar`
147 body.push(`<clipPath id="${clip}"><rect x="${r2(x)}" y="13" width="${p.width}" height="6" rx="3"/></clipPath>`)
148 body.push(`<rect class="track" x="${r2(x)}" y="13" width="${p.width}" height="6" rx="3" fill="${TRACK.dark}"/>`)
149 if (fill > 0) body.push(`<rect class="bar" x="${r2(x)}" y="13" width="${fill}" height="6" fill="${dBar}" clip-path="url(#${clip})"/>`)
150 } else if (p.kind === 'divider') {
151 body.push(`<rect class="divider" x="${r2(x)}" y="8" width="1" height="16" rx=".5" fill="${dLabel}" fill-opacity=".28"/>`)
152 }
153 x += partPx(p)
154 }
155 // Dark colors as attributes (they hold if a host drops <style>); the light
156 // theme overrides them from the stylesheet, which outranks attributes. Rules
157 // are scoped to this capsule's root, so a host that inlines several capsules
158 // in one document cannot cross their palettes.
159 const root = `pillbar-${m.key}-${density}-${m.tone}`
160 const s = (cls: string) => `.${root} .${cls}`
161 const style =
162 `<style>.${root} text{font-family:${FONT}}@media (prefers-color-scheme:light){` +
163 `${s('shell')}{fill:${lFill};fill-opacity:.07;stroke:${lStroke};stroke-opacity:.45}` +
164 `${s('icon')}{color:${lIcon}}${s('track')}{fill:${TRACK.light}}${s('bar')}{fill:${lBar}}` +
165 `${s('label')}{fill:${lLabel}}${s('value')}{fill:${lValue}}${s('detail')}{fill:${lDetail}}${s('divider')}{fill:${lLabel}}}</style>`
166 const inset = STROKE / 2
167 return (
168 `<svg xmlns="http://www.w3.org/2000/svg" class="${root}" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}"${dim ? ' opacity=".6"' : ''}>` +
169 style +
170 `<rect class="shell" x="${inset}" y="${inset}" width="${r2(width - STROKE)}" height="${H - STROKE}" rx="${r2((H - STROKE) / 2)}" fill="${dFill}" fill-opacity=".10" stroke="${dStroke}" stroke-opacity=".55" stroke-width="${STROKE}"/>` +
171 body.join('') +
172 `</svg>`
173 )
174}
175
176// ---- Row layout -------------------------------------------------------------
177
178/** Desktop lays a Box gap cell out ~8 px wide (measured on a 765 px band). */
179export const CELL_PX = 8
180/** Gaps in cells: between capsules of a group, and between the two groups. */
181export const GAP_CAPSULE = 1
182export const GAP_GROUP = 3
183
184export type Capsule = { key: MeterKey; svg: string; width: number; alt: string }
185
186export type CapsuleBand = {
187 density: CapsuleDensity
188 quota: Capsule[]
189 system: Capsule[]
190 width: number
191 /** Even compact is wider than the band: the row wraps whole capsules. */
192 isOverflowing: boolean
193}
194
195/** One density for the whole row: the roomiest whose width fits `columns` cells. */
196export function capsuleLayout(ms: Meter[], columns: number): CapsuleBand {
197 const room = columns * CELL_PX
198 let band: CapsuleBand | undefined
199 for (const density of DENSITIES) {
200 const all = ms.map(m => ({ key: m.key, svg: capsuleSvg(m, density), width: capsuleWidth(m, density), alt: m.alt }))
201 const quota = all.filter((_, i) => ms[i]!.group === 'quota')
202 const system = all.filter((_, i) => ms[i]!.group === 'system')
203 const gaps = (Math.max(0, quota.length - 1) + Math.max(0, system.length - 1)) * GAP_CAPSULE * CELL_PX + GAP_GROUP * CELL_PX
204 const width = all.reduce((n, c) => n + c.width, 0) + gaps
205 band = { density, quota, system, width, isOverflowing: width > room }
206 if (!band.isOverflowing) return band
207 }
208 return band!
209}
210hooks/model.ts 327 lines1// Pure data for the band: what each meter says and its severity, plus the
2// terminal's text layout. Desktop capsules are drawn in capsule.ts. No `$`
3// here (it may not cross an import).
4
5import type { Context, ProbeStatus, SysSample, Window } from '../types'
6
7export type Tone = 'calm' | 'caution' | 'critical' | 'unknown'
8export type Density = 'wide' | 'medium' | 'narrow' | 'tiny'
9
10/** Terminal theme keys for a bar's filled cells. */
11export const TERMINAL_FILL: Record<Tone, string> = {
12 calm: 'success',
13 caution: 'warning',
14 critical: 'error',
15 unknown: 'inactive',
16}
17
18export type MeterKey = '5h' | 'week' | 'ctx' | 'cpu' | 'mem' | 'disk'
19
20export type Meter = {
21 key: MeterKey
22 group: 'quota' | 'system'
23 labels: { long: string; short: string; letter: string }
24 /** 0-100, or null when there is no reading. */
25 pct: number | null
26 tone: Tone
27 /** `4h 04m`, `84k/200k`, `18.3/24G`, `57G free`; empty when none. */
28 detail: string
29 /** The detail where room is short: `4h04`, `84k`, `18.3G`, `57G`. */
30 compact: string
31 /** How far through its window a quota is (0-1), for the time marker; else null. */
32 marker: number | null
33 /** Words for a reader that cannot see the drawing. */
34 alt: string
35}
36
37type Thresholds = [caution: number, critical: number]
38
39/** Amber at 75% used, red at 90%, for every percentage meter but memory. */
40const USED: Thresholds = [75, 90]
41/** macOS keeps memory full on purpose (cache, compression): 76% is ordinary. */
42const MEMORY: Thresholds = [85, 95]
43
44/** Disk severity by free space, not percent: 50 GB left on a 494 GB disk is fine. */
45const DISK_FREE_GB: Thresholds = [20, 10]
46
47/** Disks count in decimal gigabytes, as Finder does. */
48const GB_DECIMAL = 1e9
49const GIB = 1024 ** 3
50
51export function diskTone(freeBytes: number): Tone {
52 const gb = freeBytes / GB_DECIMAL
53 const [caution, critical] = DISK_FREE_GB
54 if (gb < critical) return 'critical'
55 if (gb < caution) return 'caution'
56 return 'calm'
57}
58
59/** A system sample older than this has no reading. */
60export const SYS_STALE_MS = 15_000
61
62const clamp = (n: number) => Math.min(100, Math.max(0, n))
63
64export function toneFor(pct: number, [caution, critical]: Thresholds = USED): Tone {
65 if (pct >= critical) return 'critical'
66 if (pct >= caution) return 'caution'
67 return 'calm'
68}
69
70/** `47m`, `4h 04m`, `2d 3h`; empty once past. */
71export function countdown(resetsAt: number, now: number): string {
72 const minutes = Math.floor((resetsAt - now) / 60_000)
73 if (minutes < 0) return ''
74 if (minutes < 60) return `${minutes}m`
75 const hours = Math.floor(minutes / 60)
76 if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
77 return `${Math.floor(hours / 24)}d ${hours % 24}h`
78}
79
80/** `4h04`, `2d3h`, `47m`: a countdown closed up. */
81export const tightCountdown = (s: string) => {
82 const t = s.replace(/ /g, '')
83 return t.includes('h') && !t.includes('d') ? t.replace(/m$/, '') : t
84}
85
86/** Memory in binary gigabytes, as Activity Monitor counts: `18.3/24G`. */
87export function gigabytes(used: number, total: number): string {
88 const totalGb = total / GIB
89 const digits = totalGb >= 100 ? 0 : 1
90 const trim = (n: number) => (digits === 0 ? String(Math.round(n)) : String(Number(n.toFixed(1))))
91 return `${(used / GIB).toFixed(digits)}/${trim(totalGb)}G`
92}
93
94/** `57G`; one decimal under 10 GB. */
95export function freeGigabytes(used: number, total: number): string {
96 const gb = Math.max(0, total - used) / GB_DECIMAL
97 return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}G`
98}
99
100/** `84k`, `1M`: a token count as the status line abbreviates it. */
101export function tokens(n: number): string {
102 if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`
103 if (n >= 1000) return `${Math.round(n / 1000)}k`
104 return String(n)
105}
106
107/**
108 * Known windows with fresh readings laid over them by kind, less any whose
109 * reset has passed. The account's windows are the same in every session, so a
110 * reading another session (or this one before a restart) stored still holds.
111 */
112export function mergeWindows(known: readonly Window[], fresh: readonly Window[], now: number): Window[] {
113 const byKind = new Map<string, Window>()
114 for (const w of [...known, ...fresh]) byKind.set(w.kind, w)
115 return [...byKind.values()].filter(w => !w.resetsAt || Date.parse(w.resetsAt) > now)
116}
117
118/**
119 * A feed `quota` line (the JetDeck daemon's plan-usage read) as windows in the
120 * engine's own shape; null when the line is not one or holds no reading yet.
121 */
122export function parseQuotaLine(line: string): Window[] | null {
123 let v: any
124 try {
125 v = JSON.parse(line)
126 } catch {
127 return null
128 }
129 if (v?.type !== 'quota' || !Number.isFinite(v.fetchedAt)) return null
130 const out: Window[] = []
131 for (const [kind, w] of [
132 ['five_hour', v.fiveHour],
133 ['seven_day', v.sevenDay],
134 ] as const) {
135 if (w && Number.isFinite(w.percentUsed)) out.push({ kind, percentUsed: w.percentUsed, ...(typeof w.resetsAt === 'string' ? { resetsAt: w.resetsAt } : {}) })
136 }
137 return out
138}
139
140/** Windows as `$.store` gave them back, checked; [] for anything else. */
141export function parseWindows(value: unknown): Window[] {
142 if (!Array.isArray(value)) return []
143 return value.flatMap(w =>
144 w && typeof w === 'object' && typeof w.kind === 'string' && Number.isFinite(w.percentUsed)
145 ? [{ kind: w.kind, percentUsed: w.percentUsed, ...(typeof w.resetsAt === 'string' ? { resetsAt: w.resetsAt } : {}) }]
146 : [],
147 )
148}
149
150export type Inputs = {
151 windows: Window[]
152 context: Context | null
153 sys: SysSample | null
154 probe: ProbeStatus
155 now: number
156}
157
158/** Each quota window the engine reports, and how long it runs. */
159const QUOTA_WINDOWS = {
160 fiveHour: { kind: 'five_hour', ms: 5 * 3_600_000 },
161 week: { kind: 'seven_day', ms: 7 * 86_400_000 },
162}
163
164const unknown = (key: MeterKey, group: Meter['group'], labels: Meter['labels'], alt: string): Meter => ({
165 key,
166 group,
167 labels,
168 pct: null,
169 tone: 'unknown',
170 detail: '',
171 compact: '',
172 marker: null,
173 alt,
174})
175
176export function meters({ windows, context, sys, probe, now }: Inputs): Meter[] {
177 const quota = (key: MeterKey, labels: Meter['labels'], name: string, spec: { kind: string; ms: number }): Meter => {
178 const w = windows.find(x => x.kind === spec.kind)
179 const at = w?.resetsAt ? Date.parse(w.resetsAt) : NaN
180 // A window whose reset has passed no longer says how much is used.
181 if (!w || (Number.isFinite(at) && at <= now)) return unknown(key, 'quota', labels, `${name}: unavailable`)
182 const pct = clamp(w.percentUsed)
183 const detail = Number.isFinite(at) ? countdown(at, now) : ''
184 const marker = Number.isFinite(at) ? Math.min(1, Math.max(0, 1 - (at - now) / spec.ms)) : null
185 const alt = `${name}: ${Math.round(pct)}% used${detail ? `, resets in ${detail}` : ''}${marker !== null ? `, ${Math.round(marker * 100)}% of the window gone` : ''}`
186 return { key, group: 'quota', labels, pct, tone: toneFor(pct), detail, compact: tightCountdown(detail), marker, alt }
187 }
188
189 const isLive = sys !== null && probe !== 'failed' && now - sys.at <= SYS_STALE_MS
190 const system = (key: MeterKey, labels: Meter['labels'], name: string, pct: number | undefined, tone?: Tone, detail = '', compact = ''): Meter => {
191 if (!isLive || pct === undefined || !Number.isFinite(pct)) return unknown(key, 'system', labels, `${name}: unavailable`)
192 const p = clamp(pct)
193 return { key, group: 'system', labels, pct: p, tone: tone ?? toneFor(p), detail, compact, marker: null, alt: `${name}: ${Math.round(p)}%${detail ? `, ${detail}` : ''}` }
194 }
195 const ratio = (b: { used: number; total: number } | undefined) => (b && b.total > 0 ? (b.used / b.total) * 100 : undefined)
196 const mem = sys ? gigabytes(sys.mem.used, sys.mem.total) : ''
197 const free = sys ? freeGigabytes(sys.disk.used, sys.disk.total) : ''
198
199 return [
200 quota('5h', { long: '5h', short: '5h', letter: '5h' }, '5-hour quota', QUOTA_WINDOWS.fiveHour),
201 quota('week', { long: '7d', short: '7d', letter: 'W' }, 'Weekly quota', QUOTA_WINDOWS.week),
202 contextMeter(context),
203 system('cpu', { long: 'CPU', short: 'CPU', letter: 'C' }, 'CPU', sys?.cpu),
204 system('mem', { long: 'MEM', short: 'MEM', letter: 'M' }, 'Memory', ratio(sys?.mem), toneFor(ratio(sys?.mem) ?? 0, MEMORY), mem, mem.split('/')[0] + 'G'),
205 system(
206 'disk',
207 { long: 'DISK', short: 'DSK', letter: 'D' },
208 'Disk',
209 ratio(sys?.disk),
210 sys ? diskTone(sys.disk.total - sys.disk.used) : undefined,
211 free ? `${free} free` : '',
212 free,
213 ),
214 ]
215}
216
217/**
218 * How full this session's context window is, the figure that says when to
219 * /compact or start fresh. Known once a response has reported it.
220 */
221function contextMeter(c: Context | null): Meter {
222 const labels = { long: 'Ctx', short: 'Ctx', letter: 'X' }
223 const pct = c?.percent ?? (c?.tokens !== undefined && c.window > 0 ? (c.tokens / c.window) * 100 : undefined)
224 if (!c || pct === undefined || !Number.isFinite(pct)) return unknown('ctx', 'quota', labels, 'Context window: no reading yet')
225 const p = clamp(pct)
226 const detail = c.tokens !== undefined ? `${tokens(c.tokens)}/${tokens(c.window)}` : ''
227 const compact = c.tokens !== undefined ? tokens(c.tokens) : ''
228 return {
229 key: 'ctx',
230 group: 'quota',
231 labels,
232 pct: p,
233 tone: toneFor(p),
234 detail,
235 compact,
236 marker: null,
237 alt: `Context window: ${Math.round(p)}%${detail ? `, ${detail} tokens` : ''}`,
238 }
239}
240
241export const percent = (m: Meter) => (m.pct === null ? '—' : `${Math.round(m.pct)}%`)
242
243// ---- Terminal: `5h 0% ──────── 2h24m │ ...` --------------------------------
244
245const TERMINAL_BAR: Record<Density, number> = { wide: 8, medium: 3, narrow: 3, tiny: 0 }
246
247/** Filled and track cells; a known nonzero reading fills at least one. */
248export function cells(pct: number | null, width: number): { filled: string; track: string } {
249 if (width === 0) return { filled: '', track: '' }
250 let n = pct === null ? 0 : Math.round((pct * width) / 100)
251 if (pct !== null && pct > 0 && n === 0) n = 1
252 return { filled: '━'.repeat(n), track: '─'.repeat(width - n) }
253}
254
255export type TerminalMeter = {
256 key: string
257 label: string
258 value: string
259 filled: string
260 track: string
261 detail: string
262 tone: Tone
263 /** Spaces between the parts: one, or none in narrow. */
264 sep: string
265}
266
267export type TerminalBand = { density: Density; meters: TerminalMeter[]; width: number }
268
269function terminalMeter(m: Meter, density: Density): TerminalMeter {
270 const { filled, track } = cells(m.pct, TERMINAL_BAR[density])
271 const label = density === 'wide' ? m.labels.short : m.labels.letter
272 const value = density === 'wide' ? percent(m).padStart(3) : percent(m)
273 // Wide keeps every detail (resets closed up); medium the resets and the disk's
274 // free space; narrow and tiny none.
275 const detail =
276 density === 'wide'
277 ? m.group === 'quota' && m.key !== 'ctx'
278 ? m.detail.replace(/ /g, '')
279 : m.detail
280 : density === 'medium' && ((m.group === 'quota' && m.key !== 'ctx') || m.key === 'disk')
281 ? m.key === 'disk'
282 ? m.detail
283 : m.detail.replace(/ /g, '')
284 : ''
285 return { key: m.key, label, value, filled, track, detail, tone: m.tone, sep: density === 'narrow' || density === 'tiny' ? '' : ' ' }
286}
287
288const textWidth = (t: TerminalMeter) =>
289 [t.label, t.value, t.filled + t.track, t.detail].filter(Boolean).reduce((n, s, i) => n + [...s].length + (i > 0 ? t.sep.length : 0), 0)
290
291/** Meters joined by ` │ ` (groups by ` ║ `), or one space in narrow and tiny. */
292export function terminalLayout(ms: Meter[], columns: number): TerminalBand {
293 for (const density of ['wide', 'medium', 'narrow', 'tiny'] as const) {
294 const meters = ms.map(m => terminalMeter(m, density))
295 const joints = density === 'wide' || density === 'medium' ? 3 : 1
296 const width = meters.reduce((n, t) => n + textWidth(t), 0) + joints * (meters.length - 1)
297 if (width <= columns || density === 'tiny') return { density, meters, width }
298 }
299 throw new Error('unreachable')
300}
301
302// ---- Sampler lines ----------------------------------------------------------
303
304/** Splits a stream of text pieces into whole lines, keeping the tail. */
305export function lineSplitter() {
306 let tail = ''
307 return (text: string): string[] => {
308 const parts = (tail + text).split('\n')
309 tail = parts.pop() ?? ''
310 return parts.filter(line => line.trim().length > 0)
311 }
312}
313
314/** One sampler line, checked; null when it is not one. */
315export function parseSample(line: string): SysSample | null {
316 try {
317 const v = JSON.parse(line)
318 if (v?.type !== undefined && v.type !== 'sys') return null
319 const bytes = (b: unknown) =>
320 typeof b === 'object' && b !== null && Number.isFinite((b as any).used) && Number.isFinite((b as any).total)
321 if (!Number.isFinite(v?.cpu) || !bytes(v.mem) || !bytes(v.disk) || !Number.isFinite(v.at)) return null
322 return { cpu: v.cpu, mem: { used: v.mem.used, total: v.mem.total }, disk: { used: v.disk.used, total: v.disk.total }, at: v.at }
323 } catch {
324 return null
325 }
326}
327types/index.d.ts 26 lines1export type Bytes = { used: number; total: number }
2
3/** One reading of the machine, from bin/sysprobe.mjs. */
4export type SysSample = { cpu: number; mem: Bytes; disk: Bytes; at: number }
5
6/** One rate-limit window as the engine reports it. */
7export type Window = { kind: string; percentUsed: number; resetsAt?: string }
8
9/** The live context window: its size, and its fill once a response reported one. */
10export type Context = { tokens?: number; window: number; percent?: number }
11
12/** Why the system meters have no reading. */
13export type ProbeStatus = 'starting' | 'live' | 'failed'
14
15declare module 'claude-code' {
16 interface PluginState {
17 pillbar: {
18 sys: SysSample | null
19 probe: ProbeStatus
20 windows: Window[]
21 context: Context | null
22 isHidden: boolean
23 }
24 }
25}
26