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

Your Claude usage limit, as a tiny torchbearer whose flame fades as you use it.
src/register.tsx 347 lines1import { 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}
347src/frames.ts 165 lines1// 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}
165src/logic.ts 45 lines1// 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]!
45src/text.ts 55 lines1// 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.'
55src/sprite.ts 87 lines1// 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]
87types/index.d.ts 33 lines1export 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