SLOPSHOPPER

pillbar

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

newbandcommandprocess
v0.3.0MITupdated 2026-10-22SheldonChangL/claude-mods/mods/pillbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pillbar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /pillbar ⎿ pillbar: Meters hidden. /pillbar shows them again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods for Claude Code (CLI and the Desktop app's Code tab), each in its own folder under mods/.

ModWhat it does
pillbarA 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-runWhile Claude works on a long turn, Clawd plays a side-scrolling platformer above the prompt, with the turn's tokens and cost.
token-tankA 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-narratorA 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.

token-tank above the prompt, pillbar beneath

Install

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.

Develop

Each mod is self-contained. From a mod's folder:

claude plugin test .
claude plugin validate .

What they touch

  • Nothing leaves your machine except calls to the Claude API through your own Claude Code session, and only plain-narrator makes any (to Haiku, and only after /narrator).
  • token-tank keeps its save in the plugin's own store under ~/.claude; it reads the language row of your settings to pick its words.
  • pillbar runs a small Node child from its own folder that samples CPU, memory and disk; it talks to the private JetDeck daemon only when PILLBAR_JETDECK_DIR names it.
  • No mod writes outside ~/.claude, downloads code, or runs a shell.

Support

Buy the fish some food on Ko-fi

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.

Source 4 files
hooks/register.tsx 238 lines
1// 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}
238
hooks/capsule.ts 210 lines
1// 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,&quot;Liberation Mono&quot;,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 (`—` → `&#8212;`). */
73const escape = (s: string) =>
74  s
75    .replace(/&/g, '&amp;')
76    .replace(/</g, '&lt;')
77    .replace(/>/g, '&gt;')
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}
210
hooks/model.ts 327 lines
1// 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}
327
types/index.d.ts 26 lines
1export 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