SLOPSHOPPER

fuel-gauge

Context and plan-limit meter: context fill, the 5-hour and weekly windows and session cost in the status line, a toast as a window crosses your thresholds, and…

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fuel-gauge
│ ┃ Fuel gauge ✕ › fix the failing auth test and add an audit log call │ ┃ ⛽ ctx 49% · 5h 31% (now) · $0.42 │ ┃ 5h ████░░░░░░░░ 31%, resets in now ⏺ Read(src/auth.ts) │ ┃ No linked session is running. ⎿ Read 6 lines │ ┃ Refresh ⏺ 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 │ │ › /fuel │ ⎿ fuel-gauge: Fuel gauge opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ fuel-gauge: ⛽ ctx 49% · 5h 31% (now) · $0.42

Draws

Pane · Fuel gauge
⛽ ctx 49% · 5h 31% (now) · $0.42 5h ████░░░░░░░░ 31%, resets in now No linked session is running. Refresh
README

crab-workshop

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.

The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.

ModWhat you getCommands
where-am-iStatus line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container./where
roll-callA pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message.`/rollcall [open\linked] [text], tool report`
fuel-gauge⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot./fuel [text]
gpu-crabWhile a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud./gpu
vitals-stripUnraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green.`/vitals [show\quiet]`
reefAn aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name./reef [text]
keymasterMasks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan./keymaster, /keymaster allow <tag>
trailer-guardRefuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable)./trailers
gatekeeperA pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you./gates [text], tool gates
codex-cornerA boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch./codex-card
house-rulesNotes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own./rules, /rules add …, /rules off <id>

Data sources

Everything is read-only.

ModReads
roll-call, fuel-gauge, reefThe desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage)
vitals-stripThe HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read
reefunraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview)
gpu-crab, reefOllama GET /api/ps at the ollamaUrl option (default http://localhost:11434)
gatekeepergh api graphql, one search query every 5 minutes and shortly after a push
where-am-i, trailer-guard, codex-cornergit in the session's checkout

Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:

"pluginConfigs": {
  "gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
  "vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
  "where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}

Trying them

For one terminal session:

claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge

For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}

Or install from this folder as a marketplace, so /reload-plugins picks up edits:

claude plugin marketplace add ~/dev/crab-workshop

Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.

Developing

sh scripts/check.sh            # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod

shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.

Known limits

  • The desktop Code tab's drawing of AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.
  • The Unraid Management Agent's own API (:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead.
  • keymaster masks what looks like a secret. A real value that slips past its patterns is not caught, and a fake one in a test fixture is masked until you /keymaster allow its tag.
  • gatekeeper reads the first 50 PRs of its search (newest first).

License

MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.

Source 5 files
hooks/register.tsx 270 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FleetRow, FuelReading, FuelWindow } from '../types'
5import { bar, compactTokens, dueWarnings, fleetOrder, paceShort, paceText, parseUsage, shortName, statusText, until } from './logic'
6import { crab } from './shared/crab'
7import { FONT, fit, meter, svg, widthFor, xml } from './shared/draw'
8
9const reading = atom({ plugin: 'fuel-gauge', key: 'reading' } as const, { windows: [], at: 0 })
10const fleet = atom({ plugin: 'fuel-gauge', key: 'fleet' } as const, { rows: [], at: 0, isBusy: false })
11const now = atom({ plugin: 'fuel-gauge', key: 'now' } as const, 0)
12
13const PANE = 'fuel-gauge'
14const TITLE = 'Fuel gauge'
15const MGMT = 'ccd_session_mgmt'
16const SAID = 'fuel-gauge.said'
17
18let warnAt = 80
19let alarmAt = 95
20let contextWarnAt = 85
21
22const textOf = (r: { content: readonly unknown[] }): string =>
23  r.content.map(b => (b && typeof b === 'object' && 'text' in b && typeof b.text === 'string' ? b.text : '')).join('')
24
25type Measured = { context?: { percent?: number; tokens?: number; window?: number }; rateLimits?: readonly FuelWindow[]; cost?: { usd: number } }
26
27async function take($: EngineInterface, m: Measured): Promise<void> {
28  const at = await $.clock.now()
29  const r = await update($, reading, prev => ({
30    ...prev,
31    ...(m.context?.percent !== undefined ? { contextPercent: m.context.percent } : {}),
32    ...(m.context?.tokens !== undefined ? { contextTokens: m.context.tokens } : {}),
33    ...(m.context?.window !== undefined ? { contextWindow: m.context.window } : {}),
34    // The engine's windows when it has some; else keep the app's (from get_usage).
35    windows: m.rateLimits && m.rateLimits.length ? m.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })) : prev.windows,
36    ...(m.cost ? { costUsd: m.cost.usd } : {}),
37    at,
38  }))
39  $.ui.status(statusText(r, at))
40  await warn($, r)
41}
42
43async function warn($: EngineInterface, r: FuelReading): Promise<void> {
44  const said = new Set(((await $.store.get(SAID)) as string[] | undefined) ?? [])
45  const due = dueWarnings(r.windows, [warnAt, alarmAt], said, r.at)
46  const id = await $.session.id()
47  const ctxKey = `ctx:${id}`
48  if (r.contextPercent !== undefined && r.contextPercent >= contextWarnAt && !said.has(ctxKey))
49    due.push({ key: ctxKey, text: `Context at ${Math.round(r.contextPercent)}%: a good moment to wrap up, hand off or compact` })
50  if (!due.length) return
51  for (const d of due) {
52    said.add(d.key)
53    $.ui.toast(`⛽ ${d.text}`)
54  }
55  // Keep the newest few hundred keys; old windows' keys can go.
56  await $.store.set(SAID, [...said].slice(-300))
57}
58
59/** Plan windows and extra usage from the app (richer than the engine's own), plus every running linked session's context. */
60async function refreshFleet($: EngineInterface): Promise<string> {
61  await update($, fleet, f => ({ ...f, isBusy: true, error: undefined }))
62  try {
63    const self = await $.mcp.call(MGMT, 'get_usage', {})
64    const mine = parseUsage(textOf(self))
65    // Stamp the reading with the time of this read, so pace is judged against fresh usage.
66    const readAt = await $.clock.now()
67    if (mine) await update($, reading, r => ({ ...r, windows: mine.windows.length ? mine.windows : r.windows, ...(mine.extra ? { extra: mine.extra } : {}), at: readAt }))
68    const listed = await $.mcp.call(MGMT, 'list_sessions', { linked: true, limit: 60 })
69    let rows: { sessionId: string; name?: string; title?: string; isRunning?: boolean }[] = []
70    try {
71      const t = textOf(listed)
72      rows = JSON.parse(t.slice(t.indexOf('[')))
73    } catch {
74      rows = []
75    }
76    const running = rows.filter(r => r.isRunning && typeof r.sessionId === 'string')
77    const out: FleetRow[] = await Promise.all(
78      running.slice(0, 20).map(async r => {
79        const name = r.name ?? r.title ?? r.sessionId
80        try {
81          const u = parseUsage(textOf(await $.mcp.call(MGMT, 'get_usage', { session_id: r.sessionId })))
82          const c = u?.context
83          return { id: r.sessionId, name, ...(c?.percent !== undefined ? { percent: c.percent } : {}), ...(c?.tokens !== undefined ? { tokens: c.tokens } : {}), ...(c?.window !== undefined ? { window: c.window } : {}), ...(c?.note ? { note: c.note } : {}) }
84        } catch {
85          return { id: r.sessionId, name, note: 'unavailable' }
86        }
87      }),
88    )
89    const at = await $.clock.now()
90    await update($, fleet, () => ({ rows: fleetOrder(out), at, isBusy: false }))
91    const r = await read($, reading)
92    $.ui.status(statusText(r, at))
93    await warn($, { ...r, at })
94    return fleetText(r, fleetOrder(out), at)
95  } catch (err) {
96    const error = err instanceof Error ? err.message : String(err)
97    await update($, fleet, f => ({ ...f, isBusy: false, error: error.slice(0, 200) }))
98    return `Couldn't read usage from the app: ${error.slice(0, 200)}`
99  }
100}
101
102function fleetText(r: FuelReading, rows: FleetRow[], at: number): string {
103  const lines = [statusText(r, at) ?? 'No readings yet.']
104  for (const w of r.windows) {
105    lines.push(`  ${shortName(w.kind).padEnd(10)} ${bar(w.percentUsed)} ${Math.round(w.percentUsed)}%${w.resetsAt ? `, resets in ${until(w.resetsAt, at)}` : ''}`)
106    const pace = paceText(w, at)
107    if (pace) lines.push(`  ${''.padEnd(10)} ${pace}`)
108  }
109  if (r.extra) lines.push(`  extra      ${r.extra.currency === 'USD' ? '$' : ''}${r.extra.spent.toFixed(2)} of ${r.extra.limit.toFixed(0)}${r.extra.isEnabled ? '' : ' (off)'}`)
110  if (rows.length) {
111    lines.push('Running sessions, by context:')
112    for (const f of rows) lines.push(`  ${f.name.padEnd(26).slice(0, 26)} ${f.percent !== undefined ? `${bar(f.percent)} ${Math.round(f.percent)}% of ${compactTokens(f.window)}` : f.note ?? ''}`)
113  } else lines.push('No linked session is running.')
114  return lines.join('\n')
115}
116
117async function togglePane($: EngineInterface): Promise<boolean> {
118  if ((await $.ui.panes()).some(p => p.id === PANE)) {
119    await $.ui.close({ id: PANE })
120    return false
121  }
122  await update($, now, () => Date.now())
123  await $.ui.open({ id: PANE, title: TITLE })
124  return true
125}
126
127const isHot = (r: FuelReading): boolean => r.windows.some(w => w.percentUsed >= warnAt) || (r.contextPercent ?? 0) >= contextWarnAt
128
129// ---------------------------------------------------------------------------
130// Drawing
131
132const SWEAT_CSS = `<style>.drop{fill:#5aa9e6;animation:drip 1.1s ease-in infinite}.drop2{animation-delay:-.55s}
133@keyframes drip{0%{transform:translateY(0);opacity:1}100%{transform:translateY(9px);opacity:0}}</style>`
134
135const sweat = (x: number, y: number): string =>
136  `<path class="drop" d="M${x} ${y} q2 3 0 4 q-2 -1 0 -4z"/><path class="drop drop2" d="M${x + 7} ${y - 2} q2 3 0 4 q-2 -1 0 -4z"/>`
137
138function paneSvg(W: number, r: FuelReading, rows: FleetRow[], at: number): { source: string; height: number } {
139  const lines: string[] = []
140  let y = 8
141  const hot = isHot(r)
142  // The crab sits by the gauges; it sweats when a window runs hot.
143  lines.push(crab(4, 6, 'medium', hot ? 'working' : 'idle', 1.3))
144  if (hot) lines.push(sweat(30, 6))
145  const x = 56
146  const mW = Math.max(80, W - x - 150)
147  const row = (label: string, pct: number | undefined, right: string, warn = warnAt, bad = alarmAt) => {
148    lines.push(`<text class="t" x="${x}" y="${y + 10}" font-family="${FONT}" font-size="12" font-weight="600">${xml(label)}</text>`)
149    if (pct !== undefined) lines.push(meter(x + 70, y + 2, mW - 70, 9, pct, warn, bad))
150    lines.push(`<text class="s" x="${W}" y="${y + 10}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(right)}</text>`)
151    y += 20
152  }
153  if (r.contextPercent !== undefined) row('Context', r.contextPercent, `${Math.round(r.contextPercent)}% of ${compactTokens(r.contextWindow)}`, contextWarnAt, 97)
154  for (const w of r.windows) {
155    row(shortName(w.kind) === '5h' ? '5-hour' : shortName(w.kind) === 'wk' ? 'Weekly' : fit(w.kind, 12, 70), w.percentUsed, `${Math.round(w.percentUsed)}%${w.resetsAt ? ` · ${until(w.resetsAt, at)}` : ''}`)
156    const pace = paceShort(w, at)
157    if (pace) row('', undefined, pace)
158  }
159  if (r.extra && r.extra.limit > 0) row('Extra', (r.extra.spent / r.extra.limit) * 100, `$${r.extra.spent.toFixed(0)} / $${r.extra.limit.toFixed(0)}${r.extra.isEnabled ? '' : ' off'}`)
160  if (r.costUsd !== undefined) row('Session', undefined, `$${r.costUsd.toFixed(2)}`)
161  y = Math.max(y, 48) + 6
162  if (rows.length) {
163    lines.push(`<text class="s" x="0" y="${y + 10}" font-family="${FONT}" font-size="11" font-weight="600">RUNNING SESSIONS · CONTEXT</text>`)
164    y += 18
165    for (const f of rows) {
166      lines.push(`<text class="t" x="0" y="${y + 10}" font-family="${FONT}" font-size="12">${xml(fit(f.name, 12, 150))}</text>`)
167      if (f.percent !== undefined) lines.push(meter(160, y + 2, Math.max(60, W - 160 - 90), 9, f.percent, contextWarnAt, 97))
168      lines.push(`<text class="s" x="${W}" y="${y + 10}" text-anchor="end" font-family="${FONT}" font-size="11">${xml(f.percent !== undefined ? `${Math.round(f.percent)}% of ${compactTokens(f.window)}` : f.note ?? '')}</text>`)
169      y += 18
170    }
171  }
172  const height = y + 4
173  return { source: svg(W, height, lines.join('\n'), SWEAT_CSS), height }
174}
175
176export const register: Register = (on, options) => {
177  warnAt = Number(options.warnAt ?? 80) || 80
178  alarmAt = Number(options.alarmAt ?? 95) || 95
179  contextWarnAt = Number(options.contextWarnAt ?? 85) || 85
180
181  on('session.start', async ($, e, next) => {
182    const started = await next(e)
183    await $.command.register({ name: 'fuel', description: 'Context and plan-limit gauges, with every running side session\'s context', argumentHint: '[text]' })
184    try {
185      const u = await $.session.usage()
186      await take($, { context: u.context, rateLimits: u.rateLimits, ...(u.cost ? { cost: u.cost } : {}) })
187    } catch {
188      // First reading comes with the first response instead.
189    }
190    // The app's plan windows (with extra usage) once the session server is up.
191    $.clock.after(5000, () => void refreshFleet($))
192    $.clock.every(60_000, () => {
193      void (async () => {
194        const at = Date.now()
195        await update($, now, () => at)
196        const r = await read($, reading)
197        $.ui.status(statusText(r, at))
198      })()
199    })
200    return started
201  })
202
203  on('session.measure', async ($, e, next) => {
204    const r = await next(e)
205    await take($, { context: e.context, rateLimits: e.rateLimits, ...(e.cost ? { cost: e.cost } : {}) })
206    return r
207  }).catch(($, e, next) => next(e))
208
209  on('command.run', { command: 'fuel' }, async ($, e) => {
210    if (/\btext\b/.test(e.args)) return { text: await refreshFleet($) }
211    const isOpen = await togglePane($)
212    if (isOpen) $.clock.after(0, () => void refreshFleet($))
213    return { text: isOpen ? 'Fuel gauge opened.' : 'Fuel gauge closed.' }
214  })
215
216  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217    const ui = $.ui.resolve(e)
218    const { Box, Text, Button } = ui
219    const r = await read($, reading)
220    const f = await read($, fleet)
221    const at = Math.max(await read($, now), r.at, f.at)
222    const controls = (
223      <Box key="controls" flexDirection="row" gap={2}>
224        <Button key="refresh" label={f.isBusy ? 'Reading…' : 'Refresh'} plain onPress={() => void refreshFleet($)} />
225        {f.error ? <Text color="red">{f.error.slice(0, 80)}</Text> : null}
226      </Box>
227    )
228    if (e.surface === 'desktop' && 'Svg' in ui) {
229      const { Svg } = ui
230      const W = widthFor(e.props.bodyColumns)
231      const d = paneSvg(W, r, f.rows, at)
232      return (
233        <Box flexDirection="column">
234          <Svg key="gauges" source={d.source} alt={fleetText(r, f.rows, at)} width={W} height={d.height} />
235          {controls}
236        </Box>
237      )
238    }
239    return (
240      <Box flexDirection="column">
241        {fleetText(r, f.rows, at)
242          .split('\n')
243          .map((line, i) => (
244            <Text key={`l${i}`} wrap="truncate-end">
245              {line}
246            </Text>
247          ))}
248        {controls}
249      </Box>
250    )
251  })
252
253  // Above the prompt only while something runs hot.
254  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255    const r = await read($, reading)
256    if (e.props.hasSurvey || !isHot(r)) return next(e)
257    const { Box, Text } = $.ui.resolve(e)
258    const at = await read($, now)
259    const hot = [
260      ...r.windows.filter(w => w.percentUsed >= warnAt).map(w => `${shortName(w.kind) === '5h' ? '5-hour' : shortName(w.kind) === 'wk' ? 'weekly' : w.kind} ${Math.round(w.percentUsed)}%${w.resetsAt ? ` (resets ${until(w.resetsAt, at || r.at)})` : ''}`),
261      ...((r.contextPercent ?? 0) >= contextWarnAt ? [`context ${Math.round(r.contextPercent ?? 0)}%`] : []),
262    ]
263    return (
264      <Box>
265        <Text color="yellow">⛽ Running hot: {hot.join(' · ')}</Text>
266      </Box>
267    )
268  })
269}
270
hooks/logic.ts 149 lines
1// Pure logic for fuel-gauge.
2
3import type { FleetRow, FuelReading, FuelWindow } from '../types'
4
5export const WINDOW_NAME: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
6
7export const shortName = (kind: string): string =>
8  WINDOW_NAME[kind] ?? (/5-hour/i.test(kind) ? '5h' : /weekly · all/i.test(kind) ? 'wk' : /weekly · (\w+)/i.test(kind) ? `wk ${kind.match(/weekly · (\w+)/i)?.[1]?.toLowerCase()}` : kind)
9
10/** "34m", "5d 14h", "2h 5m" until an ISO time. */
11export function until(iso: string | undefined, now: number): string {
12  if (!iso) return ''
13  const ms = Date.parse(iso) - now
14  if (!Number.isFinite(ms) || ms <= 0) return 'now'
15  const m = Math.round(ms / 60_000)
16  if (m < 60) return `${m}m`
17  const h = Math.floor(m / 60)
18  if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`
19  const d = Math.floor(h / 24)
20  return h % 24 ? `${d}d ${h % 24}h` : `${d}d`
21}
22
23const DAY_MS = 86_400_000
24const WEEK_MS = 7 * DAY_MS
25/** Pace warning threshold, as a multiple of an even burn. */
26export const PACE_WARN = 1.2
27
28export type Pace = { pace: number; runsOutAt?: number; perDay: number; daysLeft: number }
29
30/**
31 * How fast a weekly window is burning against an even spend. The window starts
32 * 7 days before it resets. Undefined for other windows, or in the first hour
33 * (too noisy), or when the reset has passed.
34 */
35export function paceOf(w: FuelWindow, now: number): Pace | undefined {
36  if (!shortName(w.kind).startsWith('wk') || !w.resetsAt) return undefined
37  const end = Date.parse(w.resetsAt)
38  const elapsed = now - (end - WEEK_MS)
39  if (!Number.isFinite(end) || elapsed < WEEK_MS / 168 || end <= now) return undefined
40  const pace = w.percentUsed / ((elapsed / WEEK_MS) * 100)
41  const left = Math.max(0, 100 - w.percentUsed)
42  const daysLeft = (end - now) / DAY_MS
43  const runsOutAt = pace > 1 && w.percentUsed > 0 ? now + (left * elapsed) / w.percentUsed : undefined
44  return { pace, ...(runsOutAt !== undefined ? { runsOutAt } : {}), perDay: left / daysLeft, daysLeft }
45}
46
47const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
48/** "Wed 05:00", local time. */
49export function dayTime(ms: number): string {
50  const d = new Date(ms)
51  return `${DAYS[d.getDay()]} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
52}
53
54/** "1.4× pace · runs out Wed 05:00 at this pace · 9%/day left", or undefined for non-weekly windows. */
55export function paceText(w: FuelWindow, now: number): string | undefined {
56  const p = paceOf(w, now)
57  if (!p) return undefined
58  const parts = [`${p.pace.toFixed(1)}× pace`]
59  if (w.percentUsed >= 100) parts.push('limit reached')
60  else {
61    if (p.runsOutAt !== undefined) parts.push(`runs out ${dayTime(p.runsOutAt)} at this pace`)
62    parts.push(`${Math.round(p.perDay * 10) / 10}%/day budget`)
63  }
64  return parts.join(' · ')
65}
66
67/** The short form for narrow panes: "1.8× · out Wed 05:00", or "0.6× · 20%/day". */
68export function paceShort(w: FuelWindow, now: number): string | undefined {
69  const p = paceOf(w, now)
70  if (!p) return undefined
71  if (w.percentUsed >= 100) return `${p.pace.toFixed(1)}× · limit reached`
72  return `${p.pace.toFixed(1)}× · ${p.runsOutAt !== undefined ? `out ${dayTime(p.runsOutAt)}` : `${Math.round(p.perDay * 10) / 10}%/day`}`
73}
74
75/** The status line: `ctx 14% · 5h 26% (34m) · wk 36% (1.8× · out Mon 12:00)`. */
76export function statusText(r: FuelReading, now: number): string | undefined {
77  const parts: string[] = []
78  if (r.contextPercent !== undefined) parts.push(`ctx ${Math.round(r.contextPercent)}%`)
79  for (const w of r.windows) {
80    const name = shortName(w.kind)
81    const left = name === '5h' ? until(w.resetsAt, now) : ''
82    const p = paceOf(w, now)
83    const pace = p ? `${p.pace.toFixed(1)}×${p.runsOutAt !== undefined ? ` · ${w.percentUsed >= 100 ? 'out' : `out ${dayTime(p.runsOutAt)}`}` : ''}` : ''
84    const note = left || pace
85    parts.push(`${name} ${Math.round(w.percentUsed)}%${note ? ` (${note})` : ''}`)
86  }
87  if (r.costUsd !== undefined && r.costUsd >= 0.01) parts.push(`$${r.costUsd.toFixed(2)}`)
88  return parts.length ? `⛽ ${parts.join(' · ')}` : undefined
89}
90
91/**
92 * Which threshold toasts are due: one per window and threshold, keyed by the
93 * window's reset time so the next window warns afresh.
94 */
95export function dueWarnings(windows: FuelWindow[], thresholds: number[], said: ReadonlySet<string>, now?: number): { key: string; text: string }[] {
96  const out: { key: string; text: string }[] = []
97  for (const w of windows) {
98    // Pace: once per window when a weekly window burns faster than PACE_WARN times an even spend.
99    const p = now === undefined ? undefined : paceOf(w, now)
100    const paceKey = `${w.kind}@${w.resetsAt ?? ''}:pace`
101    if (p && p.pace >= PACE_WARN && w.percentUsed < 100 && !said.has(paceKey))
102      out.push({ key: paceKey, text: `${shortName(w.kind) === 'wk' ? 'Weekly' : w.kind} pace ${p.pace.toFixed(1)}×${p.runsOutAt !== undefined ? `: runs out ${dayTime(p.runsOutAt)} at this pace` : ''}, budget ${Math.round(p.perDay * 10) / 10}%/day` })
103    const crossed = thresholds.filter(t => w.percentUsed >= t).sort((a, b) => b - a)[0]
104    if (crossed === undefined) continue
105    const key = `${w.kind}@${w.resetsAt ?? ''}:${crossed}`
106    if (said.has(key)) continue
107    out.push({ key, text: `${shortName(w.kind) === '5h' ? '5-hour' : shortName(w.kind) === 'wk' ? 'Weekly' : w.kind} limit at ${Math.round(w.percentUsed)}%` })
108  }
109  return out
110}
111
112/** The app's get_usage answer, parsed loosely (its shape is the app's, not the mod API's). */
113export function parseUsage(text: string): { windows: FuelWindow[]; extra?: FuelReading['extra']; context?: { percent?: number; tokens?: number; window?: number; note?: string } } | null {
114  let v: unknown
115  try {
116    v = JSON.parse(text.slice(text.indexOf('{')))
117  } catch {
118    return null
119  }
120  if (!v || typeof v !== 'object') return null
121  const o = v as {
122    plan?: { windows?: { label?: string; percentUsed?: number; resetsAt?: string }[]; extraUsage?: { enabled?: boolean; spent?: string | number; monthlyLimit?: string | number; currency?: string } }
123    context?: { status?: string; tokensUsed?: number; contextWindow?: number; percentUsed?: number; note?: string }
124  }
125  const windows = (o.plan?.windows ?? [])
126    .filter(w => typeof w.label === 'string' && typeof w.percentUsed === 'number')
127    .map(w => ({ kind: String(w.label), percentUsed: Number(w.percentUsed), ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) }))
128  const x = o.plan?.extraUsage
129  const extra = x && x.spent !== undefined ? { spent: Number(x.spent), limit: Number(x.monthlyLimit ?? 0), currency: x.currency ?? 'USD', isEnabled: x.enabled === true } : undefined
130  const c = o.context
131  const context = c
132    ? c.status === 'ok'
133      ? { percent: c.percentUsed, tokens: c.tokensUsed, window: c.contextWindow }
134      : { note: c.status ?? 'unavailable' }
135    : undefined
136  return { windows, ...(extra ? { extra } : {}), ...(context ? { context } : {}) }
137}
138
139export const fleetOrder = (rows: FleetRow[]): FleetRow[] => [...rows].sort((a, b) => (b.percent ?? -1) - (a.percent ?? -1))
140
141/** A text bar for the terminal: ████░░░░ 42%. */
142export function bar(pct: number, width = 12): string {
143  const n = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
144  return '█'.repeat(n) + '░'.repeat(width - n)
145}
146
147export const compactTokens = (n: number | undefined): string =>
148  n === undefined ? '' : n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
149
hooks/shared/crab.ts 230 lines
1// The crew's crabs: pixel art, costumes and motion copied from savvy-progress
2// (https://github.com/JohnnyVizz/claude-kit/tree/main/plugins/savvy-progress,
3// MIT License, Copyright (c) 2026 johnnyvizz; see THIRD_PARTY_NOTICES.md),
4// which credits the pixel crab to DockCrab (Clawdy).
5//
6// Changed here: a pose per session status (closed eyes and tucked legs to rest,
7// happy eyes, both claws up and a hop to celebrate), and costumes named for
8// what they wear instead of savvy-flow's tiers.
9
10/** A crab's pose, by what its owner is doing. */
11export type CrewStatus = 'working' | 'idle' | 'done' | 'proposed' | 'dismissed'
12
13// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
14// The body keeps the brand clay; the tier's color lives in the costume's accent.
15const CLAY = '#D97757'
16const INK = '#1F1E1D'
17
18export const COSTUME_COLOR: Record<string, string> = {
19  fable: '#7F77DD',
20  heavy: '#D85A30',
21  careful: '#BA7517',
22  medium: '#378ADD',
23  light: '#1D9E75',
24  explore: '#55514C',
25  other: '#888780',
26}
27
28/** What a `faces` rule or `/crew face` may name, to the costume it draws. */
29export const COSTUME_NAMES: Record<string, string> = {
30  astronaut: 'fable',
31  detective: 'heavy',
32  engineer: 'careful',
33  chef: 'medium',
34  racer: 'light',
35  pirate: 'explore',
36  plain: 'other',
37  // savvy-flow's own tier names work too.
38  fable: 'fable',
39  heavy: 'heavy',
40  careful: 'careful',
41  medium: 'medium',
42  light: 'light',
43  explore: 'explore',
44  other: 'other',
45}
46
47export const WEARS: Record<string, string> = {
48  fable: 'astronaut',
49  heavy: 'detective',
50  careful: 'engineer',
51  medium: 'chef',
52  light: 'racer',
53  explore: 'pirate',
54  other: 'plain',
55}
56
57/** Sessions with no rule take these in turn, in the order they joined. */
58export const ROTATION = ['fable', 'heavy', 'careful', 'medium', 'light', 'explore'] as const
59
60export const costumeKey = (name: string): string | undefined => COSTUME_NAMES[name.trim().toLowerCase()]
61
62// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
63// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
64type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
65
66type Pose = { eyes: 'open' | 'closed' | 'happy'; isCheering: boolean }
67
68const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
69  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
70
71const eyes = (f: Fill, pose: Pose): void => {
72  if (pose.eyes === 'closed') {
73    f(9, 13, 2, 1, INK)
74    f(19, 13, 2, 1, INK)
75  } else if (pose.eyes === 'happy') {
76    f(9, 13, 1, 1, INK); f(10, 12, 1, 1, INK)
77    f(19, 12, 1, 1, INK); f(20, 13, 1, 1, INK)
78  } else {
79    f(9, 12, 2, 2, INK)
80    f(19, 12, 2, 2, INK)
81  }
82}
83
84// `armCls` lets a raised claw travel with the prop it holds. A cheering crab
85// raises the back claw too, in `ar`, which waves.
86const crabBody = (f: Fill, pose: Pose, armFront = 0, armCls?: string): void => {
87  f(7, 10, 16, 12, CLAY)
88  if (pose.isCheering) f(3, 10, 4, 4, CLAY, 'ar')
89  else f(3, 14, 4, 4, CLAY)
90  f(23, 14 + (pose.isCheering && !armCls ? -4 : armFront), 4, 4, CLAY, armCls ?? (pose.isCheering ? 'ar' : undefined))
91  eyes(f, pose)
92  f(7, 22, 2, 4, CLAY, 'la')
93  f(17, 22, 2, 4, CLAY, 'la')
94  f(11, 22, 2, 4, CLAY, 'lb')
95  f(21, 22, 2, 4, CLAY, 'lb')
96}
97
98// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
99// once-a-second redraw of a running row restarts them in phase. Every crab walks;
100// each costume adds its prop's own motion on top.
101export const CRAB_CSS = `<style>
102.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
103.run .bd{animation:bob .5s steps(1) infinite -.125s}
104.run g{transform-box:fill-box}
105@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
106.c-fable.run{animation:float 1s ease-in-out infinite}
107.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
108.c-fable.run .ant{animation:blink 1s steps(1) infinite}
109.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
110@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
111.c-heavy.run .it{animation:scan 1s steps(1) infinite}
112.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
113@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
114.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
115@keyframes twist{50%{transform:rotate(-35deg)}}
116.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
117.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
118@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
119.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
120.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
121@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
122.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
123@keyframes fence{50%{transform:rotate(25deg)}}
124.rest g{transform-box:fill-box}
125.rest .bd{transform:translateY(2px)}.rest .la,.rest .lb{transform-origin:50% 100%;transform:scaleY(.5)}
126.rest{transform-box:fill-box;transform-origin:50% 100%;animation:breathe 4s ease-in-out infinite}
127@keyframes breathe{50%{transform:scaleY(.96)}}
128.cheer{animation:hop .6s ease-in-out infinite}.cheer g{transform-box:fill-box}
129.cheer .ar{animation:clap .3s steps(1) infinite}
130@keyframes hop{50%{transform:translateY(-3px)}}@keyframes clap{50%{transform:translateY(-1px)}}
131.z{opacity:0;animation:zz 3s linear infinite}.z2{animation-delay:1s}.z3{animation-delay:2s}
132@keyframes zz{0%{opacity:0;transform:translate(0,0)}25%{opacity:.9}100%{opacity:0;transform:translate(-5px,-12px)}}
133.cf{transform-box:fill-box;transform-origin:center;animation:fall 1.8s linear infinite}
134.cf1{animation-delay:-.4s}.cf2{animation-delay:-.9s}.cf3{animation-delay:-1.3s}
135@keyframes fall{0%{transform:translateY(-4px) rotate(0);opacity:1}100%{transform:translateY(28px) rotate(300deg);opacity:0}}
136.q{animation:blink 1.6s steps(1) infinite}
137@media (prefers-reduced-motion: reduce){.run,.run g,.rest,.cheer,.cheer g,.z,.cf,.q{animation:none!important}.z{opacity:.7}}
138</style>`
139
140const COSTUMES: Record<string, (f: Fill, t: string, pose: Pose) => void> = {
141  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
142  fable: (f, t, pose) => {
143    crabBody(f, pose)
144    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
145    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
146    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
147    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
148  },
149  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
150  heavy: (f, t, pose) => {
151    crabBody(f, pose, -4, 'it')
152    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
153    f(6, 9, 18, 1, t)
154    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
155    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
156  },
157  // Careful: engineer in a hard hat; the wrench turns a bolt.
158  careful: (f, t, pose) => {
159    crabBody(f, pose)
160    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
161    f(13, 5, 4, 2, t)
162    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
163  },
164  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
165  medium: (f, t, pose) => {
166    crabBody(f, pose, -4, 'pan')
167    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
168    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
169  },
170  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
171  light: (f, t, pose) => {
172    crabBody(f, pose, -4)
173    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
174    f(25, 1, 1, 9, '#8E929A')
175    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
176  },
177  // Explore: pirate scouting the code; the cutlass fences.
178  explore: (f, _t, pose) => {
179    crabBody(f, pose)
180    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
181    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
182    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
183  },
184  other: (f, _t, pose) => crabBody(f, pose),
185}
186
187const MOTION: Record<CrewStatus, string> = {
188  working: ' run',
189  idle: ' rest',
190  done: ' cheer',
191  proposed: '',
192  dismissed: '',
193}
194
195// What floats around the crab, in its own 30×28 grid: top left, the one corner no costume's prop takes.
196const EXTRAS: Partial<Record<CrewStatus, string>> = {
197  idle:
198    `<text class="z" x="0" y="8" font-family="sans-serif" font-size="6" font-weight="700" fill="#9a9a96">z</text>` +
199    `<text class="z z2" x="0" y="8" font-family="sans-serif" font-size="7" font-weight="700" fill="#9a9a96">z</text>` +
200    `<text class="z z3" x="0" y="8" font-family="sans-serif" font-size="9" font-weight="700" fill="#9a9a96">Z</text>`,
201  done: ['#D85A30', '#378ADD', '#F5C542', '#7F77DD'].map((c, i) => `<rect class="cf cf${i}" x="${2 + i * 8}" y="0" width="2" height="3" fill="${c}"/>`).join(''),
202  proposed: `<text class="q" x="0" y="9" font-family="sans-serif" font-size="9" font-weight="700" fill="#BA7517">?</text>`,
203}
204
205export const CRAB_SCALE = 1.1
206
207/**
208 * One crab at (x, y) in `costume`, posed for `status`. Body and props nest
209 * inside `bd` so a prop rides the bob and adds its own motion; legs stay
210 * outside it and step on their own.
211 */
212export function crab(x: number, y: number, costume: string, status: CrewStatus, scale = CRAB_SCALE): string {
213  const pose: Pose = {
214    eyes: status === 'idle' ? 'closed' : status === 'done' ? 'happy' : 'open',
215    isCheering: status === 'done',
216  }
217  const groups = new Map<string, string[]>([['bd', []]])
218  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
219    if (!groups.has(cls)) groups.set(cls, [])
220    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
221  }
222  const draw = COSTUMES[costume] ?? COSTUMES.other
223  draw?.(f, COSTUME_COLOR[costume] ?? '#888780', pose)
224  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
225  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
226  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
227  const dim = status === 'proposed' ? ' opacity="0.45"' : status === 'dismissed' ? ' opacity="0.3"' : ''
228  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g${dim}><g class="c-${costume}${MOTION[status]}">${body}${group('la')}${group('lb')}</g></g>${EXTRAS[status] ?? ''}</g>`
229}
230
hooks/shared/draw.ts 50 lines
1// Shared drawing helpers for the crab-workshop mods (copied into each mod's
2// hooks/shared/ by scripts/sync-shared.sh; edit the copy in /shared).
3
4import { CRAB_CSS } from './crab'
5
6export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
7export const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
8
9export const xml = (s: string): string =>
10  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
11
12/** Cuts `s` to what fits `maxW` pixels at `size`, with an ellipsis. */
13export const fit = (s: string, size: number, maxW: number): string => {
14  const max = Math.max(4, Math.floor(maxW / (size * 0.56)))
15  return s.length > max ? s.slice(0, max - 1) + '…' : s
16}
17
18// Text and line colours for both themes; `.ok`, `.warn`, `.bad` are state colours.
19export const THEME_CSS = `<style>
20.t{fill:#1f1f1f}.s{fill:#6b6b68}.ln{stroke:#e4e4e1}.ok{fill:#2f8a57}.warn{fill:#b07012}.bad{fill:#c2412d}
21.trk{fill:#ececea}.bar{fill:#378ADD}
22@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.ln{stroke:#333331}.ok{fill:#6fcf97}.warn{fill:#f0c46a}.bad{fill:#f28b75}.trk{fill:#2b2b29}.bar{fill:#6aaef0}}
23@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
24</style>`
25
26/** An SVG document with the theme and crab styles, `extraCss` (a <style> block) after them. */
27export const svg = (W: number, H: number, body: string, extraCss = ''): string =>
28  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${THEME_CSS}${CRAB_CSS}${extraCss}${body}</svg>`
29
30/** Width in CSS pixels for a pane or band `bodyColumns` wide. */
31export const widthFor = (bodyColumns: number | undefined, min = 240, max = 900): number =>
32  Math.max(min, Math.min(max, (bodyColumns || 40) * 8 - 8))
33
34/** A horizontal meter: track plus a bar `pct` full, coloured by thresholds. */
35export const meter = (x: number, y: number, w: number, h: number, pct: number, warnAt = 80, badAt = 95): string => {
36  const p = Math.max(0, Math.min(100, pct))
37  const cls = p >= badAt ? 'bad' : p >= warnAt ? 'warn' : 'bar'
38  return `<rect class="trk" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/><rect class="${cls}" x="${x}" y="${y}" width="${Math.max(h, (w * p) / 100)}" height="${h}" rx="${h / 2}"/>`
39}
40
41/** "3m", "2h 5m", "4d" from milliseconds. */
42export const ago = (ms: number): string => {
43  const m = Math.round(ms / 60_000)
44  if (m < 1) return 'now'
45  if (m < 60) return `${m}m`
46  const h = Math.floor(m / 60)
47  if (h < 48) return m % 60 && h < 10 ? `${h}h ${m % 60}m` : `${h}h`
48  return `${Math.floor(h / 24)}d`
49}
50
types/index.d.ts 37 lines
1export type FuelWindow = {
2  /** five_hour, seven_day, or a label from the app (`Weekly · Fable`). */
3  kind: string
4  percentUsed: number
5  resetsAt?: string
6}
7
8export type FuelReading = {
9  contextPercent?: number
10  contextTokens?: number
11  contextWindow?: number
12  windows: FuelWindow[]
13  costUsd?: number
14  /** Extra (pay-as-you-go) usage from the app's usage card. */
15  extra?: { spent: number; limit: number; currency: string; isEnabled: boolean }
16  at: number
17}
18
19export type FleetRow = {
20  id: string
21  name: string
22  percent?: number
23  tokens?: number
24  window?: number
25  note?: string
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'fuel-gauge': {
31      reading: FuelReading
32      fleet: { rows: FleetRow[]; at: number; isBusy: boolean; error?: string }
33      now: number
34    }
35  }
36}
37