SLOPSHOPPER

Usage Torch

Your Claude usage limit, as a tiny torchbearer whose flame fades as you use it.

newbandcommandtoasttimer
v0.5.0MITupdated 2026-10-03hicrandn/claude-mods/plugins/usage-torch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-torch
› 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 › /torch ⎿ usage-torch: Usage Torch band: compact. Torch ▰▰▰▰▰▰▰▱▱▱ 69% left · 5-hour limit ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Torch ▰▰▰▰▰▰▰▱▱▱ 69% left · 5-hour limit
README

Usage Torch

Your Claude usage limit, as a tiny torchbearer whose flame fades as you use it.

Source 6 files
src/register.tsx 347 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { TorchDemo, TorchLook, TorchSize, TorchView } from '../types'
5import { packCells, poseFor, SCENE_COLUMNS, SCENE_ROWS, sceneCells, WAKE_STEPS, wakePose } from './frames.ts'
6import { DEFAULT_THRESHOLDS, formatClock, formatCountdown, lineText, lookFor, nextLine } from './logic.ts'
7import type { Thresholds } from './logic.ts'
8import * as text from './text.ts'
9import type { WindowLeft } from './text.ts'
10
11const demo = atom({ plugin: 'usage-torch', key: 'demo' } as const, null)
12const view = atom({ plugin: 'usage-torch', key: 'view' } as const, null)
13const countdown = atom({ plugin: 'usage-torch', key: 'countdown' } as const, '')
14
15const RASTER = 'torch'
16const SIZE_KEY = 'size'
17const SIZES: readonly TorchSize[] = ['compact', 'full']
18const DEFAULT_SIZE: TorchSize = 'full'
19const BAR_CELLS = 10
20// The bar takes the flame's color for the state: bright, low, out.
21const BAR_COLORS: Record<TorchLook, string> = { awake: '#EF9F27', tired: '#D85A30', sleep: '#888780' }
22const DEMOS: readonly TorchDemo[] = ['awake', 'tired', 'sleep', 'wake']
23const DEMO_PERCENT_LEFT: Record<TorchLook, number> = { awake: 82, tired: 18, sleep: 0 }
24const DEMO_RESET_MS = 12 * 60_000 + 30_000
25// The wake-up holds the sleeping pose this many steps before the eyes open.
26const WAKE_LEAD = 4
27const WAKE_STEP_MS = 400
28
29// Module state: a reload starts it over, the host keeps what the drawing reads in $.state.
30let reduceMotion = false
31let thresholds: Thresholds = DEFAULT_THRESHOLDS
32// The last real reading: the window closest to its limit, and what is left of every window.
33let real: { percentUsed: number; resetsAt: number | null; kind: string | null; windows: WindowLeft[] } | null = null
34// Whether a measurement came in: before one, the band shows the torchbearer waiting.
35let measured = false
36let bandId: string | undefined
37let drawnSize: TorchSize = DEFAULT_SIZE
38let isWorking = false
39let tick = 0
40let wakeStep = 0
41let timer: Timer | undefined
42
43function poseOf(v: TorchView) {
44  if (!v.isWaking) return poseFor(v.look, reduceMotion ? 0 : tick)
45  return wakeStep < WAKE_LEAD ? poseFor('sleep', tick) : wakePose(wakeStep - WAKE_LEAD)
46}
47
48const viewFor = (
49  look: TorchLook,
50  now: number,
51  percentLeft: number,
52  resetsAt: number | null,
53  kind: string | null = null,
54  windows: WindowLeft[] | null = null,
55): TorchView => ({
56  look,
57  lineIndex: 0,
58  lineAt: now,
59  percentLeft,
60  resetsAt,
61  kind,
62  windows,
63  isWaking: false,
64})
65
66const clockOf = (resetsAt: number | null): string =>
67  resetsAt === null ? '' : formatClock(resetsAt, -new Date(resetsAt).getTimezoneOffset())
68
69const barOf = (percentLeft: number): string => {
70  const lit = Math.round((Math.min(100, Math.max(0, percentLeft)) / 100) * BAR_CELLS)
71  return '▰'.repeat(lit) + '▱'.repeat(BAR_CELLS - lit)
72}
73
74async function sizeOf($: EngineInterface): Promise<TorchSize> {
75  const stored = await $.store.get(SIZE_KEY)
76  return SIZES.find(s => s === stored) ?? DEFAULT_SIZE
77}
78
79function realView(now: number): TorchView | null {
80  if (real === null) return null
81  const percentLeft = leftOf(real.percentUsed)
82  return viewFor(lookFor(real.percentUsed, thresholds), now, percentLeft, real.resetsAt, real.kind, real.windows)
83}
84
85// Before the first measurement the torchbearer waits; after one with no windows (no subscription) it hides.
86function shownView(now: number): TorchView | null {
87  return realView(now) ?? (measured ? null : { ...viewFor('awake', now, 100, null), isPending: true })
88}
89
90const leftOf = (percentUsed: number): number => Math.max(0, Math.round(100 - percentUsed))
91
92// The 5-hour window reads first, then the weekly one, then any other in the order reported.
93const WINDOW_ORDER = ['five_hour', 'seven_day']
94const rankOf = (kind: string): number => {
95  const i = WINDOW_ORDER.indexOf(kind)
96  return i === -1 ? WINDOW_ORDER.length : i
97}
98
99// The window that binds first: the most used of those the last response reported.
100function readingOf(limits: readonly SessionRateLimit[]) {
101  if (limits.length === 0) return null
102  const w = limits.reduce((a, b) => (b.percentUsed > a.percentUsed ? b : a))
103  const resetsAt = w.resetsAt === undefined ? null : Date.parse(w.resetsAt)
104  const windows = [...limits]
105    .sort((a, b) => rankOf(a.kind) - rankOf(b.kind))
106    .map(l => ({ kind: l.kind, percentLeft: leftOf(l.percentUsed) }))
107  return { percentUsed: w.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt, kind: w.kind, windows }
108}
109
110async function relight($: EngineInterface, now: number) {
111  wakeStep = 0
112  const isDemo = (await read($, demo)) !== null
113  await update($, view, () => (isDemo ? viewFor('awake', now, 100, null) : shownView(now)))
114  $.ui.toast(text.WAKE_TOAST)
115}
116
117// Draws the real reading unless a demo holds the band; leaving sleep plays the wake-up.
118async function showReal($: EngineInterface) {
119  if ((await read($, demo)) !== null) return
120  const now = await $.clock.now()
121  const next = shownView(now)
122  const cur = await read($, view)
123  if (next?.look === 'sleep' && next.resetsAt !== null) {
124    const left = formatCountdown(next.resetsAt - now)
125    await update($, countdown, () => left)
126  }
127  if (next === null || cur === null || cur.isWaking) {
128    if (cur?.isWaking !== true) await update($, view, () => next)
129    return
130  }
131  if (cur.look === 'sleep' && next.look !== 'sleep') {
132    if (reduceMotion) return relight($, now)
133    wakeStep = 0
134    await update($, view, () => ({ ...cur, isWaking: true }))
135    return
136  }
137  await update($, view, () =>
138    next.look === cur.look
139      ? {
140          ...cur,
141          percentLeft: next.percentLeft,
142          resetsAt: next.resetsAt,
143          kind: next.kind,
144          windows: next.windows,
145          isPending: next.isPending,
146        }
147      : next,
148  )
149}
150
151// One animation step, then the next one scheduled at the pace the state asks for.
152async function step($: EngineInterface) {
153  const v = await read($, view)
154  const now = await $.clock.now()
155  let delay = isWorking ? 500 : 1000
156
157  if (v !== null) {
158    tick += 1
159    if (v.isWaking) {
160      delay = WAKE_STEP_MS
161      wakeStep += 1
162      if (wakeStep >= WAKE_LEAD + WAKE_STEPS) await relight($, now)
163    } else {
164      const line = nextLine(v.look, { look: v.look, index: v.lineIndex, at: v.lineAt }, now)
165      if (line.index !== v.lineIndex || line.at !== v.lineAt) {
166        await update($, view, cur => (cur === null ? cur : { ...cur, lineIndex: line.index, lineAt: line.at }))
167      }
168    }
169
170    // No turn runs while the limit is hit, so the window's reset is noticed here.
171    if (v.look === 'sleep' && !v.isWaking && v.resetsAt !== null && now >= v.resetsAt && real !== null) {
172      if ((await read($, demo)) === null) {
173        const kind = real.kind
174        const windows = real.windows.map(w => (w.kind === kind ? { ...w, percentLeft: 100 } : w))
175        real = { percentUsed: 0, resetsAt: null, kind, windows }
176        await showReal($)
177      }
178    }
179
180    if (v.look === 'sleep' && v.resetsAt !== null) {
181      const left = formatCountdown(v.resetsAt - now)
182      if (left !== (await read($, countdown))) await update($, countdown, () => left)
183    }
184
185    const isMoving = v.isWaking || !reduceMotion
186    if (bandId !== undefined && isMoving && drawnSize !== 'compact') {
187      const cells = packCells(sceneCells(poseOf(v)))
188      $.ui.blit({ requestId: bandId, key: RASTER, cells }).catch(() => undefined)
189    }
190  }
191
192  timer = $.clock.after(delay, () => void step($))
193}
194
195export const register: Register = (on, options) => {
196  reduceMotion = options.reduceMotion === true
197  const { tiredBelowPercent, sleepAtPercentUsed } = options
198  thresholds = {
199    tiredBelowPercent: typeof tiredBelowPercent === 'number' ? tiredBelowPercent : DEFAULT_THRESHOLDS.tiredBelowPercent,
200    sleepAtPercentUsed: typeof sleepAtPercentUsed === 'number' ? sleepAtPercentUsed : DEFAULT_THRESHOLDS.sleepAtPercentUsed,
201  }
202
203  on('session.start', async ($, e, next) => {
204    await $.command.register({
205      name: 'torch',
206      description: text.COMMAND_DESCRIPTION,
207      argumentHint: text.COMMAND_HINT,
208    })
209    const settings = await $.settings.read()
210    reduceMotion = reduceMotion || settings.prefersReducedMotion === true
211    timer?.cancel()
212    void step($)
213
214    const result = await next(e)
215    real = readingOf((await $.session.usage()).rateLimits)
216    await showReal($)
217    return result
218  })
219
220  // Every measurement counts: the first one with no windows is what hides the waiting band.
221  on('session.measure', async ($, e, next) => {
222    measured = true
223    real = readingOf(e.rateLimits)
224    await showReal($)
225    return next(e)
226  })
227
228  on('command.run', { command: 'torch' }, async ($, e) => {
229    const [sub, arg] = e.args.trim().split(/\s+/)
230
231    if (sub === 'demo' && arg === 'off') {
232      await update($, demo, () => null)
233      await update($, view, () => null)
234      await showReal($)
235      return { text: text.DEMO_OFF }
236    }
237
238    const named = SIZES.find(s => s === sub)
239    if (named !== undefined) {
240      await $.store.set(SIZE_KEY, named)
241      return { text: text.SIZE_SET(named) }
242    }
243
244    // A bare /torch switches between the sizes.
245    if (sub === undefined || sub === '') {
246      const size = SIZES[(SIZES.indexOf(await sizeOf($)) + 1) % SIZES.length]!
247      await $.store.set(SIZE_KEY, size)
248      return { text: text.SIZE_SET(size) }
249    }
250
251    if (sub === 'status') {
252      const v = await read($, view)
253      if (v === null || v.isPending === true) return { text: text.NO_READING }
254      return { text: `Usage Torch: ${text.usageLine(v.percentLeft, v.kind, clockOf(v.resetsAt), v.windows)}` }
255    }
256
257    const chosen = DEMOS.find(d => d === arg)
258    if (sub === 'demo' && chosen !== undefined) {
259      const now = await $.clock.now()
260      const look: TorchLook = chosen === 'wake' ? 'sleep' : chosen
261      const resetsAt = look === 'sleep' ? now + DEMO_RESET_MS : null
262      await update($, demo, () => chosen)
263      await update($, countdown, () => (resetsAt === null ? '' : formatCountdown(resetsAt - now)))
264      if (chosen === 'wake' && reduceMotion) {
265        await relight($, now)
266      } else {
267        wakeStep = 0
268        await update($, view, () => ({ ...viewFor(look, now, DEMO_PERCENT_LEFT[look], resetsAt), isWaking: chosen === 'wake' }))
269      }
270      return { text: text.DEMO_ON(chosen) }
271    }
272
273    return { text: text.USAGE }
274  })
275
276  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
277    if (e.props.hasSurvey) return next(e)
278    const v = await read($, view)
279    if (v === null) return next(e)
280
281    bandId = e.requestId
282    isWorking = e.props.isWorking
283
284    const clock = clockOf(v.resetsAt)
285    const usage = v.isPending === true ? text.WAITING : text.usageLine(v.percentLeft, v.kind, clock, v.windows)
286
287    if (e.surface !== 'terminal') {
288      const { Text } = $.ui.resolve(e)
289      const head = v.isPending === true ? 'Usage Torch' : text.oneLine(v.percentLeft)
290      return <Text dimColor>{`${head} · ${usage}`}</Text>
291    }
292
293    const { Box, Raster, Text } = $.ui.resolve(e)
294    const isResting = v.look === 'sleep' && !v.isWaking && v.resetsAt !== null
295    const left = isResting ? await read($, countdown) : ''
296    const size = await sizeOf($)
297    drawnSize = size
298
299    if (size === 'compact') {
300      const isOut = v.look === 'sleep' && !v.isWaking
301      return (
302        <Box flexDirection="row" gap={1}>
303          <Text color={BAR_COLORS[v.look]} bold>
304            {text.COMPACT_LABEL}
305          </Text>
306          {v.isPending !== true && <Text color={BAR_COLORS[v.look]}>{barOf(v.percentLeft)}</Text>}
307          <Text wrap="truncate-end" dimColor>
308            {isOut
309              ? [text.OUT_OF_LIGHT, isResting ? `${left} · ${text.relightsAt(clock)}` : ''].filter(Boolean).join(' · ')
310              : usage}
311          </Text>
312        </Box>
313      )
314    }
315
316    return (
317      <Box flexDirection="row">
318        <Box flexDirection="column" width={SCENE_COLUMNS} flexShrink={0}>
319          <Raster key={RASTER} columns={SCENE_COLUMNS} rows={SCENE_ROWS} cells={packCells(sceneCells(poseOf(v)))} />
320          {isResting && (
321            <Box flexDirection="row" gap={1}>
322              <Text bold>{left}</Text>
323              <Text dimColor>{text.relightsAt(clock)}</Text>
324            </Box>
325          )}
326        </Box>
327        {!v.isWaking && (
328          <Box flexDirection="column" marginTop={3} marginLeft={1} flexShrink={1}>
329            <Box borderStyle="round" borderDimColor paddingX={1}>
330              <Text wrap="truncate-end" dimColor={v.look === 'sleep'}>
331                {lineText({ look: v.look, index: v.lineIndex, at: v.lineAt })}
332              </Text>
333            </Box>
334            {!isResting && (
335              <Box paddingX={1}>
336                <Text wrap="truncate-end" dimColor>
337                  {usage}
338                </Text>
339              </Box>
340            )}
341          </Box>
342        )}
343      </Box>
344    )
345  })
346}
347
src/frames.ts 165 lines
1// Composes sprite frames and packs them into half-block cells. Pure: no engine calls,
2// so the preview tool and the tests import it directly.
3
4import {
5  BODY,
6  EYE_ROWS,
7  FLAME_ORIGIN,
8  FLAMES,
9  PALETTE,
10  SLEEP_EYES,
11  SPRITE_SIZE,
12  SWEAT,
13  TIRED_EYES,
14} from './sprite.ts'
15
16export type Look = 'awake' | 'tired' | 'sleep'
17
18export const TRANSPARENT = -1
19export const DEFAULT_COLOR = 0x01000000
20const UPPER_HALF = 0x2580
21const LOWER_HALF = 0x2584
22const SPACE = 0x20
23
24const swap = (row: string, map: Readonly<Record<string, string>>): string =>
25  [...row].map(c => map[c] ?? c).join('')
26
27/** What one frame shows: whose eyes, which flame and its frame, the sweat, the bounce, the z's. */
28export type Pose = {
29  eyes: Look
30  flame: Look
31  flameFrame: number
32  sweat: boolean
33  lift: number
34  zz: number | null
35}
36
37/** The pixel rows of one pose: the body with its eyes and sweat, the flame frame on top. */
38export function composeRows(pose: Pose): string[] {
39  const rows = BODY.map(row => row)
40
41  if (pose.eyes !== 'awake') {
42    const eyes = pose.eyes === 'tired' ? TIRED_EYES : SLEEP_EYES
43    for (const r of EYE_ROWS) rows[r] = swap(rows[r]!, eyes)
44  }
45
46  if (pose.sweat) {
47    for (const [r, c] of SWEAT) rows[r] = put(rows[r]!, c, 'S')
48  }
49
50  const flame = FLAMES[pose.flame][pose.flameFrame % 2]!
51  flame.forEach((line, i) => {
52    const r = FLAME_ORIGIN.row + i
53    ;[...line].forEach((c, j) => {
54      if (c !== '.') rows[r] = put(rows[r]!, FLAME_ORIGIN.column + j, c)
55    })
56  })
57
58  return rows
59}
60
61/** A state's pose at animation tick `tick`; tick 0 is the still frame reduce motion keeps. */
62export function poseFor(look: Look, tick: number): Pose {
63  switch (look) {
64    case 'awake':
65      return { eyes: look, flame: look, flameFrame: tick % 2, sweat: false, lift: tick % 2, zz: null }
66    case 'tired':
67      return { eyes: look, flame: look, flameFrame: tick % 2, sweat: true, lift: Math.floor(tick / 2) % 2, zz: null }
68    case 'sleep':
69      return { eyes: look, flame: look, flameFrame: tick % 2, sweat: false, lift: 0, zz: tick % 4 }
70  }
71}
72
73export const WAKE_STEPS = 6
74
75/** The wake-up: eyes open through tired to awake while the flame catches, then a hop. */
76export function wakePose(step: number): Pose {
77  if (step < 2) return { eyes: 'sleep', flame: 'tired', flameFrame: step, sweat: false, lift: 0, zz: null }
78  if (step < 3) return { eyes: 'tired', flame: 'tired', flameFrame: step, sweat: false, lift: 0, zz: null }
79  return { eyes: 'awake', flame: 'awake', flameFrame: step, sweat: false, lift: (step + 1) % 2, zz: null }
80}
81
82/** The still frames the preview tool prints. */
83export const composeFrame = (look: Look, tick: number): string[] => composeRows(poseFor(look, tick % 2))
84
85const put = (row: string, column: number, c: string): string =>
86  row.slice(0, column) + c + row.slice(column + 1)
87
88export const hexToRgb = (hex: string): number => parseInt(hex.slice(1), 16)
89
90/** One pixel's color as 0x00RRGGBB, or TRANSPARENT. */
91export function colorAt(rows: readonly string[], row: number, column: number): number {
92  const c = rows[row]?.[column]
93  if (c === undefined || c === '.') return TRANSPARENT
94  const hex = PALETTE[c]
95  if (hex === undefined) throw new Error(`no palette entry for "${c}"`)
96  return hexToRgb(hex)
97}
98
99/** A half-block cell for the two pixels it covers. */
100export function cell(top: number, bottom: number): [number, number, number] {
101  if (top === TRANSPARENT && bottom === TRANSPARENT) return [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
102  if (bottom === TRANSPARENT) return [UPPER_HALF, top, DEFAULT_COLOR]
103  if (top === TRANSPARENT) return [LOWER_HALF, bottom, DEFAULT_COLOR]
104  return [UPPER_HALF, top, bottom]
105}
106
107// The scene: the sprite, then four columns where the z's rise. One pixel of headroom for the hop.
108export const SCENE_COLUMNS = SPRITE_SIZE + 4
109const SCENE_PIXEL_ROWS = SPRITE_SIZE + 1
110export const SCENE_ROWS = Math.ceil(SCENE_PIXEL_ROWS / 2)
111
112/** The z's at phase 0-3: a small z and a big Z drifting up beside the torch. [row, column, char]. */
113export function zzGlyphs(phase: number): [number, number, string][] {
114  return [
115    [7 - phase, SPRITE_SIZE + (phase % 2), 'z'],
116    [4 - phase, SPRITE_SIZE + 2 + ((phase + 1) % 2), 'Z'],
117  ]
118}
119
120const ZZ_COLOR: Readonly<Record<string, number>> = {
121  z: hexToRgb(PALETTE.z!),
122  Z: hexToRgb(PALETTE.g!),
123}
124
125/** Cells for a pose, row-major `[codePoint, fg, bg]`, SCENE_COLUMNS by SCENE_ROWS. */
126export function sceneCells(pose: Pose): Uint32Array {
127  const rows = composeRows(pose)
128  const offset = 1 - pose.lift
129  const glyphs = pose.zz === null ? [] : zzGlyphs(pose.zz)
130  const out = new Uint32Array(SCENE_COLUMNS * SCENE_ROWS * 3)
131  let i = 0
132  for (let r = 0; r < SCENE_ROWS; r++) {
133    for (let c = 0; c < SCENE_COLUMNS; c++) {
134      const glyph = glyphs.find(([gr, gc]) => gr === r && gc === c)
135      const [cp, fg, bg] = glyph
136        ? [glyph[2].codePointAt(0)!, ZZ_COLOR[glyph[2]]!, DEFAULT_COLOR]
137        : cell(colorAt(rows, r * 2 - offset, c), colorAt(rows, r * 2 + 1 - offset, c))
138      out[i++] = cp
139      out[i++] = fg
140      out[i++] = bg
141    }
142  }
143  return out
144}
145
146const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
147
148/** Cells as Raster wants them: little-endian u32s, standard padded base64. */
149export function packCells(words: Uint32Array): string {
150  const bytes = new Uint8Array(words.length * 4)
151  const view = new DataView(bytes.buffer)
152  words.forEach((w, i) => view.setUint32(i * 4, w, true))
153  let out = ''
154  for (let i = 0; i < bytes.length; i += 3) {
155    const a = bytes[i]!
156    const b = bytes[i + 1]
157    const c = bytes[i + 2]
158    out += B64[a >> 2]
159    out += B64[((a & 3) << 4) | ((b ?? 0) >> 4)]
160    out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]
161    out += c === undefined ? '=' : B64[c & 63]
162  }
163  return out
164}
165
src/logic.ts 45 lines
1// Pure rules: usage to state, countdown text, speech line rotation.
2
3import type { Look } from './frames.ts'
4import { LINES } from './text.ts'
5
6export type Thresholds = { tiredBelowPercent: number; sleepAtPercentUsed: number }
7
8export const DEFAULT_THRESHOLDS: Thresholds = { tiredBelowPercent: 30, sleepAtPercentUsed: 100 }
9
10/** The state for a window's fill: asleep at the limit, tired below the threshold left. */
11export function lookFor(percentUsed: number, t: Thresholds = DEFAULT_THRESHOLDS): Look {
12  if (percentUsed >= t.sleepAtPercentUsed) return 'sleep'
13  if (100 - percentUsed < t.tiredBelowPercent) return 'tired'
14  return 'awake'
15}
16
17const pad = (n: number): string => String(n).padStart(2, '0')
18
19/** Time left as h:mm from an hour up, m:ss below; never 0:00 while time remains. */
20export function formatCountdown(ms: number): string {
21  const total = Math.max(0, Math.ceil(ms / 1000))
22  if (total >= 3600) return `${Math.floor(total / 3600)}:${pad(Math.floor((total % 3600) / 60))}`
23  return `${Math.floor(total / 60)}:${pad(total % 60)}`
24}
25
26/** A wall clock time as HH:MM, given the local offset in minutes east of UTC. */
27export function formatClock(epochMs: number, offsetMinutes: number): string {
28  const minutes = Math.floor((epochMs + offsetMinutes * 60_000) / 60_000)
29  const day = ((minutes % 1440) + 1440) % 1440
30  return `${pad(Math.floor(day / 60))}:${pad(day % 60)}`
31}
32
33export const LINE_PERIOD_MS = 10 * 60_000
34
35export type Line = { look: Look; index: number; at: number }
36
37/** Keeps the line within a state for ten minutes, then moves to the next; a new state starts at its first. */
38export function nextLine(look: Look, prev: Line | null, now: number): Line {
39  if (prev === null || prev.look !== look) return { look, index: 0, at: now }
40  if (now - prev.at < LINE_PERIOD_MS) return prev
41  return { look, index: (prev.index + 1) % LINES[look].length, at: now }
42}
43
44export const lineText = (line: Line): string => LINES[line.look][line.index % LINES[line.look].length]!
45
src/text.ts 55 lines
1// Every string the mod shows. English only; no em dashes (a test holds this).
2
3import type { Look } from './frames.ts'
4
5export const LINES: Readonly<Record<Look, readonly string[]>> = {
6  awake: ["Let's go!", 'The path is lit.', 'Plenty of light left.'],
7  tired: ['My flame is low. Save the big task for later?', 'Running low. Maybe slow down a bit?'],
8  sleep: ['Out of light. Resting for a bit.'],
9}
10
11export const relightsAt = (clock: string): string => `Relights at ${clock}`
12export const WAKE_TOAST = 'Torch relit'
13export const oneLine = (percentLeft: number): string => `Usage Torch ${percentLeft}%`
14
15const WINDOW_NAMES: Readonly<Record<string, string>> = {
16  five_hour: '5-hour limit',
17  seven_day: 'weekly limit',
18  spend_limit: 'spend limit',
19}
20export const windowName = (kind: string): string => WINDOW_NAMES[kind] ?? kind.replace(/_/g, ' ')
21
22const SHORT_NAMES: Readonly<Record<string, string>> = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
23const shortName = (kind: string): string => SHORT_NAMES[kind] ?? kind.replace(/_/g, ' ')
24
25export type WindowLeft = { kind: string; percentLeft: number }
26
27/** The figures in one line: what is left side by side when there are several windows, and when it refills. */
28// `kind` and `windows` may be missing on a view an older version of the mod left in the session's state.
29export function usageLine(
30  percentLeft: number,
31  kind: string | null | undefined,
32  clock: string,
33  windows?: readonly WindowLeft[] | null,
34): string {
35  const parts =
36    windows != null && windows.length > 1
37      ? windows.map(w => `${shortName(w.kind)} ${w.percentLeft}%`)
38      : [`${percentLeft}% left`, ...(typeof kind === 'string' ? [windowName(kind)] : [])]
39  if (clock !== '') parts.push(`resets ${clock}`)
40  return parts.join(' · ')
41}
42
43export const COMPACT_LABEL = 'Torch'
44export const OUT_OF_LIGHT = 'out of light'
45
46export const COMMAND_DESCRIPTION = 'Switch the Usage Torch band between compact and full, show usage, or simulate a state'
47export const COMMAND_HINT = '[compact|full|status|demo awake|tired|sleep|wake|off]'
48export const DEMO_ON = (look: string): string => `Usage Torch demo: ${look}. Real usage is ignored until /torch demo off.`
49export const DEMO_OFF = 'Usage Torch demo off.'
50export const USAGE =
51  'Usage: /torch, /torch compact, /torch full, /torch status, /torch demo awake|tired|sleep|wake, /torch demo off'
52export const SIZE_SET = (size: string): string => `Usage Torch band: ${size}.`
53export const WAITING = 'Usage shows after the first reply'
54export const NO_READING = 'Usage Torch has no usage reading yet. It arrives with the first reply.'
55
src/sprite.ts 87 lines
1// Pixel data and palette for the torchbearer. Data only: composition lives in frames.ts.
2// Every string is one pixel row; "." is transparent.
3
4export const SPRITE_SIZE = 24
5
6export const PALETTE: Readonly<Record<string, string>> = {
7  o: '#2C2C2A', // outline
8  d: '#2C2C2A', // hood interior
9  h: '#D3D1C7', // hood
10  H: '#F1EFE8', // hood highlight
11  s: '#B4B2A9', // hood shadow
12  E: '#FAC775', // eye top, bright
13  e: '#EF9F27', // eye bottom
14  t: '#1D9E75', // scarf
15  T: '#5DCAA5', // scarf highlight
16  u: '#0F6E56', // tunic
17  b: '#712B13', // belt
18  B: '#4A1B0C', // boot
19  k: '#854F0B', // torch handle
20  K: '#633806', // handle tip
21  w: '#BA7517', // torch wrap
22  X: '#D85A30', // flame outer
23  O: '#EF9F27', // flame
24  Y: '#FAC775', // flame inner
25  y: '#FAEEDA', // flame core
26  g: '#B4B2A9', // smoke
27  G: '#854F0B', // tired eye
28  z: '#888780', // closed eye
29  S: '#85B7EB', // sweat drop
30}
31
32export const BODY: readonly string[] = [
33  '............oo..........',
34  '...........ohho.........',
35  '.........oohhho.........',
36  '.......oohhhHho.........',
37  '.....oohhhhHHho.........',
38  '....ohhhhhhhHhho........',
39  '...ohhhhhhhhhHhho.......',
40  '..ohhhoooooooohhho..ww..',
41  '..ohhoddddddddohho..ww..',
42  '.ohhsddddddddddshho.kk..',
43  '.ohhsddEEddEEddshho.kk..',
44  '.ohhsddeeddeeddshho.kk..',
45  '.ohhsddddddddddshho.kk..',
46  '.ohhhoddddddddohhho.kk..',
47  '..ohhhoooooooohhho..kk..',
48  '..ottTTtttttttttuo..kk..',
49  '..ohhsuuuuuuuushhohhkk..',
50  '..ohhsuuuuuuuushhohhkk..',
51  '..ohhsbbbbbbbbshho..kk..',
52  '..ohhsuuuuuuuushho..KK..',
53  '...ohsuuuuuuuusho.......',
54  '....oouuuuuuuuoo........',
55  '......oBBooBBo..........',
56  '......oooooooo..........',
57]
58
59// Flame frames are 4 wide and 6 tall, laid over columns 19-22, rows 1-6.
60export const FLAME_ORIGIN = { column: 19, row: 1 } as const
61export const FLAME_WIDTH = 4
62export const FLAME_HEIGHT = 6
63
64export const FLAMES = {
65  awake: [
66    ['..X.', '.XX.', 'XOOX', 'XOYO', 'OYyO', 'XYYX'],
67    ['.X..', '.XX.', 'XOOX', 'OYOX', 'OyYO', 'XYYX'],
68  ],
69  tired: [
70    ['....', '....', '....', '..X.', '.XO.', '.OY.'],
71    ['....', '....', '....', '.X..', '.OX.', '.YO.'],
72  ],
73  sleep: [
74    ['..g.', '.g..', '..g.', '....', '....', '.gg.'],
75    ['.g..', '..g.', '.g..', '....', '....', '.gg.'],
76  ],
77} as const satisfies Record<string, readonly (readonly string[])[]>
78
79// State variations on the body. Coordinates are [row, column], 0-based.
80export const EYE_ROWS = [10, 11] as const
81export const TIRED_EYES = { E: 'd', e: 'G' } as const
82export const SLEEP_EYES = { E: 'd', e: 'z' } as const
83export const SWEAT: readonly (readonly [number, number])[] = [
84  [8, 1],
85  [9, 0],
86]
87
types/index.d.ts 33 lines
1export type TorchLook = 'awake' | 'tired' | 'sleep'
2
3export type TorchDemo = TorchLook | 'wake'
4
5/** How much room the band takes: one line, or the torchbearer. */
6export type TorchSize = 'compact' | 'full'
7
8/** What the band shows: the state, its speech line, and the window it reads. */
9export type TorchView = {
10  look: TorchLook
11  lineIndex: number
12  lineAt: number
13  percentLeft: number
14  resetsAt: number | null
15  /** The rate-limit window shown (`five_hour`, `seven_day`); null in a demo. */
16  kind: string | null
17  /** What is left of every window the last reading reported, 5-hour first; null in a demo. */
18  windows?: { kind: string; percentLeft: number }[] | null
19  isWaking: boolean
20  /** True while the session has had no reading yet: the torchbearer shows, waiting for figures. */
21  isPending?: boolean
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'usage-torch': {
27      demo: TorchDemo | null
28      view: TorchView | null
29      countdown: string
30    }
31  }
32}
33