Live pixel meters above the Claude Code prompt for the 5 hour and weekly usage limits, with a soft tone when one runs low

Live pixel meters above the Claude Code prompt for your 5 hour and weekly usage limits. The dots breathe slowly while Claude is idle and speed up while it works, a dashed line marks where an even pace would put you, and the meter turns amber when you are spending faster than that and red when little is left. A soft tone plays once when a window drops below 20%.
Based on zycck/claude-mods (plan-progress, MIT, by Kirill Serditov). Changes and additions by snmeric.
/plugin marketplace add snmeric/stride
/plugin install stride@snmeric-mods
/meters shows or hides the meters (the Usage button in the footer does the same)MIT. See LICENSE.
hooks/register.tsx 638 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Sample, Sign } from '../types'
5
6const isOpen = atom({ plugin: 'stride', key: 'isOpen' } as const, true)
7const limits = atom({ plugin: 'stride', key: 'limits' } as const, [])
8const busy = atom({ plugin: 'stride', key: 'busy' } as const, false)
9const sign = atom({ plugin: 'stride', key: 'sign' } as const, null)
10
11// a meter's colour follows how much of its window is left, traffic-light style: plenty, keep an eye on it, low,
12// very low, nearly gone (the last also beats the handle); `from` is the least left (in %) each status covers
13const STATUS = [
14 { from: 60, colour: '#5EC48C' },
15 { from: 30, colour: '#A5C95A' },
16 { from: 15, colour: '#E9C84A' },
17 { from: 5, colour: '#EF8E3C' },
18 { from: 0, colour: '#E5484D' },
19]
20const statusOf = (left: number) => STATUS.findIndex(s => left >= s.from)
21
22const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
23const hash = (a: number, b: number, k: number) => {
24 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
25 return x - Math.floor(x)
26}
27
28// ---------- figures ----------
29
30const LIMIT_NAME: Record<string, string> = { five_hour: '5 saat', seven_day: 'Haftalık' }
31const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
32
33const span = (ms: number) => {
34 const min = Math.max(0, Math.round(ms / 60000))
35 if (min >= 2880) return `${Math.round(min / 1440)}g`
36 if (min >= 60) return `${Math.floor(min / 60)}s${String(min % 60).padStart(2, '0')}`
37 return `${min}dk`
38}
39const untilReset = (iso: string | undefined, now: number) => (iso ? span(Date.parse(iso) - now) : '')
40
41// what would be left now had the window been spent evenly since it opened; null when its reset time is unknown
42function paceLeft(l: Limit, now: number): number | null {
43 const span = WINDOW_MS[l.kind]
44 if (!l.resetsAt || !span) return null
45 return Math.max(0, Math.min(100, ((Date.parse(l.resetsAt) - now) / span) * 100))
46}
47
48// readings of each window over the last hours, kept across sessions, to tell how fast it is being spent
49const KEEP_MS = 6 * 3_600_000
50const LOOKBACK_MS: Record<string, number> = { five_hour: 45 * 60_000, seven_day: 6 * 3_600_000 }
51let history: Record<string, Sample[]> = {}
52
53function record(list: readonly Limit[], now: number) {
54 for (const l of list) {
55 const kept = (history[l.kind] ?? []).filter(s => now - s.t < KEEP_MS)
56 const last = kept[kept.length - 1]
57 // a reset starts the count over
58 if (last && l.used < last.used - 0.5) kept.length = 0
59 if (!last || last.used !== l.used || now - last.t > 10 * 60_000) kept.push({ t: now, used: l.used })
60 history[l.kind] = kept
61 }
62}
63
64// at the pace of the last while, does the window run out before it resets? Nothing until there is a pace to go by
65function eta(l: Limit, now: number): { text: string; isWarn: boolean } | null {
66 const recent = (history[l.kind] ?? []).filter(s => now - s.t <= (LOOKBACK_MS[l.kind] ?? KEEP_MS))
67 const first = recent[0]
68 if (!first || l.used - first.used < 1 || now - first.t < 5 * 60_000) return null
69 const msLeft = (100 - l.used) / ((l.used - first.used) / (now - first.t))
70 const msReset = l.resetsAt ? Date.parse(l.resetsAt) - now : Infinity
71 return msLeft < msReset ? { text: `bu hızla ~${span(msLeft)} içinde biter`, isWarn: true } : { text: 'bu hızla yeter', isWarn: false }
72}
73
74// ---------- drawing ----------
75
76// Each meter follows the DSH Claude-style reasoning slider: square pixels with softly rounded corners on a fixed grid
77// in a glass track, dim and grey at the far end, brighter and turning to the tint towards the handle. Every pixel blinks
78// at random (see PULSE). While a turn runs the field is at full brightness; when it ends the field does not stop at
79// once but eases down to its quiet brightness over a few seconds (and back up as the next turn starts), the pixels
80// blinking on as before. The pixels never move; all the motion is brightness. No stripes sweep across it, nothing
81// flashes white or glows.
82
83const PITCH = 4.2
84const ROWS = 5 // five rows in the same track height: small pixels, and room for legible signs
85const SQ = 3.2 // the pixel's side; the rest of the pitch is the gap between pixels
86const INSET = (PITCH - SQ) / 2
87const RX = 0.8
88// a small lighter bar along the top edge of the pixels near the handle
89const HL = { dx: 1, dy: 0.8, w: SQ - 2, h: 1.1 }
90const TRACK_Y = 20
91const BH = 22
92const DOT_Y = TRACK_Y + (BH - ROWS * PITCH) / 2
93const ROW_H = 54
94const THUMB_W = 16
95const MAX_COLS = 200
96
97// the row's mood: `calm` is the quiet dot matrix, `busy` and `hot` the working field (`hot` pulses quicker); the
98// weekly window runs slower than the 5 hour one, so the two rows read apart at a glance
99const BEAT = { calm: 1, busy: 1, hot: 0.7 } as const
100type Beat = keyof typeof BEAT
101const SLOW = 1.8
102const EASE_MS = 2500 // how long the field takes to ease between its working and quiet brightness
103const QUIET = 0.6 // the quiet field's brightness, as a share of the working one's
104
105// Every cell of the grid is a pixel and they are all alike: none stays lit, none stays dark. Each runs a long cycle
106// holding a strong and a faint soft pulse at uneven moments, picked from a set of such cycles (SHAPES); its cycle's
107// length is one of a few that never line up with one another (`lengths`, scaled by the row's mood), and it starts at a
108// random point of it. So from moment to moment a different scatter of pixels swells and fades, anywhere on the bar.
109// Under the pulses every pixel breathes: it swells and settles slowly, twice per cycle, so with the cycles all
110// different the whole field breathes unevenly, never in step. Towards the handle they rest a little brighter, breathe
111// deeper, pulse brighter and more often (shorter cycles); towards the far end they are dim and pulse rarely, which is
112// what makes the bar look full at the handle and thin out away from it.
113const PULSE = {
114 lengths: [2.6, 3.1, 3.7, 4.3, 4.9, 5.6, 6.4, 7.3, 8.9],
115 moments: 16,
116 rest: [0.04, 0.07, 0.1, 0.15, 0.22], // by the pixel's place along the bar, far end first
117 peak: [0.35, 0.5, 0.66, 0.83, 1],
118 breath: [0.05, 0.08, 0.11, 0.14, 0.18], // how much brighter a pixel gets at the top of a breath
119}
120// a cycle's brightness at `t` (0-1): two slow breaths, plus a strong and a faint soft pulse at the shape's moments,
121// as how far it stands above the rest level, in breaths (`br`) and in pulse heights (`pu`)
122function cycleAt(centres: readonly number[], t: number) {
123 const br = 0.5 - 0.5 * Math.cos(t * Math.PI * 4)
124 const pu = centres.reduce((sum, c, n) => sum + (n === 0 ? 1 : 0.45) * Math.exp(-Math.pow((t - c) / 0.03, 2)), 0)
125 return { br, pu: Math.min(1, pu) }
126}
127const SHAPES = Array.from({ length: 8 }, (_, k) => {
128 const first = 0.06 + hash(k, 1, 91) * 0.4
129 return [first, first + 0.2 + hash(k, 2, 92) * 0.3].map(c => Math.min(0.94, c))
130})
131const pixelCss = () =>
132 `.px rect,.hb rect{opacity:var(--b);animation-timing-function:linear;animation-iteration-count:infinite;animation-duration:var(--d);animation-delay:calc(var(--d) * var(--f) * -1)}
133.px rect{width:${SQ}px;height:${SQ}px;rx:${RX}px}.hb rect{width:${HL.w}px;height:${HL.h}px;rx:${HL.h / 2}px}.hb{fill-opacity:.5}
134${SHAPES.map(centres => {
135 // sampled every 5% for the breaths, and closely around each pulse so its peak is kept
136 const ts = new Set<number>(Array.from({ length: 21 }, (_, n) => n / 20))
137 for (const c of centres) for (const d of [-0.06, -0.03, 0, 0.03, 0.06]) ts.add(Math.round((c + d) * 1000) / 1000)
138 return [...ts].filter(t => t >= 0 && t <= 1).sort((a, b) => a - b)
139}).map(
140 (stops, k) =>
141 `.s${k}{animation-name:p${k}}@keyframes p${k}{${stops
142 .map(t => {
143 const { br, pu } = cycleAt(SHAPES[k] ?? [], t)
144 return `${(t * 100).toFixed(1)}%{opacity:calc(var(--b) + var(--a) * ${br.toFixed(2)} + (var(--p) - var(--b)) * ${pu.toFixed(2)})}`
145 })
146 .join('')}}`,
147).join('\n')}
148.calm{--k:1.5}.busy{--k:1}.hot{--k:.7}.slow{--s:${SLOW}}
149${PULSE.lengths.map((s, k) => `.d${k}{--d:calc(${s}s * var(--k) * var(--s, 1))}`).join('')}
150${Array.from({ length: PULSE.moments }, (_, k) => `.f${k}{--f:${(k / PULSE.moments).toFixed(3)}}`).join('')}
151${PULSE.rest.map((v, k) => `.b${k}{--b:${v};--p:${PULSE.peak[k]};--a:${PULSE.breath[k]}}`).join('')}
152`
153
154// the shared pieces: a cell at every place of the grid (the faint grid of empty cells) and the glass track's fine grain
155function defs(W: number) {
156 const cells = Array.from({ length: ROWS }, (_, r) => `<rect x="${INSET}" y="${r * PITCH + INSET}" width="${SQ}" height="${SQ}" rx="${RX}"/>`).join('')
157 return (
158 `<pattern id="ta" x="0" y="${DOT_Y}" width="${PITCH}" height="${ROWS * PITCH}" patternUnits="userSpaceOnUse"><g fill="#fff">${cells}</g></pattern>` +
159 `<filter id="nz" x="0" y="0" width="1" height="1"><feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>`
160 )
161}
162
163// Now and then the pixels gather into a small sign near the handle, hold it a moment and scatter back into the field:
164// on what happens (a turn starting or ending, a window running low or coming back) and, while things are quiet, once
165// in a while for fun. Every sign is five pixels high, drawn in the meter's own pixels; `colour` overrides the
166// meter's tint. Each of its pixels lights a moment apart from the others, so it gathers out of the twinkle.
167const SIGNS: Record<string, { art: string[]; colour?: string }> = {
168 bolt: { art: ['...##', '..##.', '.####', '..##.', '.##..'], colour: '#7FB2F0' }, // a turn starts
169 check: { art: ['.....', '....#', '...#.', '#.#..', '.#...'], colour: '#5EC48C' }, // a turn ends
170 warn: { art: ['..#..', '.#.#.', '.#.#.', '#...#', '#####'], colour: '#E09A1E' }, // a window drops below 20% or 10% left
171 heart: { art: ['.#.#.', '#####', '#####', '.###.', '..#..'], colour: '#E5484D' }, // a window has reset
172 moon: { art: ['.###', '##..', '#...', '##..', '.###'], colour: '#9DB8FF' }, // a long while with nothing running
173 star: { art: ['..#..', '.###.', '#####', '.###.', '..#..'], colour: '#F2C14E' },
174 smile: { art: ['.#.#.', '.#.#.', '.....', '#...#', '.###.'], colour: '#F59AC1' },
175 arrow: { art: ['..#..', '...#.', '#####', '...#.', '..#..'], colour: '#5EC4B8' }, // only while the 5 hour window is on or ahead of an even pace
176}
177// A sign teleports: it gathers, holds, scatters, and gathers again a few columns further from the handle, three times,
178// dimmer at each stop; while it is between stops a few cells on the way flash, as if its pixels flew across.
179const SIGN_STOP_MS = 2200 // one stop: gather, hold, scatter
180const SIGN_HOP_S = 1.5 // seconds from one stop to the next (the stops overlap a little, so one scatters as the next gathers)
181const SIGN_STOPS = [1, 0.72, 0.45] // brightness at each stop
182const SIGN_MS = SIGN_STOP_MS + (SIGN_STOPS.length - 1) * SIGN_HOP_S * 1000
183// while a sign shows, every pixel of the meters slowly takes on its colour and then slowly returns to its own
184const TINT_MS = 6500
185const SURPRISES = ['star', 'smile', 'arrow']
186const MOON_AFTER_MS = 10 * 60_000
187const SIGN_GAP_MS = 3 * 60_000 // a surprise waits at least this long after the last sign
188
189type Row = {
190 kind: string
191 status: number // index into STATUS; 0 is plenty left
192 id: string
193 name: string
194 value: string
195 detail: string
196 note: { text: string; isWarn: boolean } | null
197 fill: number // 0-100: how much of the track the pixels and handle take
198 color: string
199 beat: Beat
200 isSlow: boolean
201 pace: number | null
202 isLow: boolean
203 isRefill: boolean
204}
205
206// pixels that break off the handle and drift back while usage is being spent
207const CRUMBS = [
208 { y: -4, s: 2.5, dy: -5, d: 0 },
209 { y: -1, s: 2, dy: 3, d: 0.25 },
210 { y: 2, s: 3, dy: -2, d: 0.5 },
211 { y: -3, s: 1.5, dy: 6, d: 0.75 },
212 { y: 0, s: 2.5, dy: -7, d: 1 },
213 { y: 3, s: 2, dy: 4, d: 1.2 },
214]
215
216// `signNow`: the sign this row shows (if any) and how long ago it appeared, so a redraw picks it up where it was
217// `place`: where the meter sits (its left edge and its line); `W` is the meter's own width
218function rowSvg(row: Row, i: number, W: number, isBusy: boolean, signNow: { name: string; age: number } | null, tintNow: { colour: string; age: number } | null, place = { x: 0, y: i * ROW_H }) {
219 const { id, color: c } = row
220 const fw = (row.fill / 100) * W
221 const hx = Math.max(0, fw - THUMB_W / 2) // the handle's left edge
222 const dw = Math.max(0, hx) // the pixels run right up to it
223 const base = hex(c)
224 // the last column reaches in under the handle, so no gap is left between the pixels and the handle
225 const cols = Math.ceil(dw / PITCH)
226 const light1 = rgb(mix(base, WHITE, 0.55))
227 const at = (col: number, r: number) => `x="${(col * PITCH + INSET).toFixed(1)}" y="${(DOT_Y + r * PITCH + INSET).toFixed(1)}" width="${SQ}" height="${SQ}" rx="${RX}"`
228
229 // the pixels: one at every cell, coloured grey → tint → light along the bar, all blinking at random (see PULSE)
230 const tint = tintNow ? hex(tintNow.colour) : null
231 const stop = (offset: string, own: string, taken: (t: number[]) => number[]) =>
232 `<stop offset="${offset}" stop-color="${own}">${tint && tintNow ? `<animate attributeName="stop-color" values="${own};${rgb(taken(tint))};${rgb(taken(tint))};${own}" keyTimes="0;.25;.7;1" calcMode="spline" keySplines=".4 0 .2 1;0 0 1 1;.4 0 .2 1" dur="${TINT_MS / 1000}s" begin="-${(tintNow.age / 1000).toFixed(2)}s" fill="freeze"/>` : ''}</stop>`
233 const colour = `<linearGradient id="${id}c" x1="0" x2="${dw.toFixed(1)}" gradientUnits="userSpaceOnUse">${stop('0', rgb(GREY), t => mix(GREY, t, 0.35))}${stop('.6', c, t => t)}${stop('1', rgb(mix(base, WHITE, 0.5)), t => mix(t, WHITE, 0.5))}</linearGradient>`
234 // Each grid row is a group, so a pixel is just its x and its classes. To stay well inside the engine's size limit a
235 // meter animates at most MAX_COLS columns (on a very wide window the far, dimmest end is left to the faint grid),
236 // and wide meters leave out the highlight bars
237 const from = Math.max(0, cols - MAX_COLS)
238 const hasBars = SQ >= 5 && cols < 200 // the bars only read on large pixels
239 let cells = ''
240 let bars = ''
241 for (let r = 0; r < ROWS; r++) {
242 let line = ''
243 let barLine = ''
244 for (let col = from; col < cols; col++) {
245 const nX = ((col + 0.5) * PITCH) / Math.max(1, dw)
246 const level = Math.min(PULSE.rest.length - 1, Math.floor(Math.pow(nX, 0.8) * PULSE.rest.length))
247 const pick = (k: number, n: number) => Math.floor(hash(col, r, i + k) * n)
248 // shorter cycles (more pulses) towards the handle, longer ones towards the far end, always with some spread
249 const d = Math.min(PULSE.lengths.length - 1, Math.floor(hash(col, r, i + 61) * 5 + (1 - nX) * 4))
250 const cls = `s${pick(60, SHAPES.length)} d${d} f${pick(62, PULSE.moments)} b${level}`
251 const x = (col * PITCH + INSET).toFixed(1)
252 line += `<rect x="${x}" class="${cls}"/>`
253 if (hasBars && nX > 0.7) barLine += `<rect x="${(col * PITCH + INSET + HL.dx).toFixed(1)}" class="${cls}"/>`
254 }
255 const y = DOT_Y + r * PITCH + INSET
256 cells += `<g transform="translate(0 ${y.toFixed(1)})">${line}</g>`
257 if (barLine) bars += `<g transform="translate(0 ${(y + HL.dy).toFixed(1)})">${barLine}</g>`
258 }
259 const pixels = `<g fill="url(#${id}c)" class="px">${cells}</g><g fill="${light1}" class="hb">${bars}</g>`
260
261 // the spent part: a few of the faint grid's cells (see the track) now and then light up in the tint
262 const sx = hx + THUMB_W + 1
263 const sw = W - sx
264 let spent = ''
265 if (sw > PITCH) {
266 const first = Math.ceil(sx / PITCH)
267 const n = Math.floor(sw / PITCH)
268 for (let k = 0; k < Math.min(8, Math.floor(n / 8)); k++) {
269 const col = first + Math.floor(hash(k, i, 51) * n)
270 const r = Math.floor(hash(k, i, 52) * ROWS)
271 const d = 2.4 + hash(k, i, 53) * 2.4
272 spent += `<rect ${at(col, r)} fill="${c}" class="gh" style="animation-duration:${d.toFixed(2)}s;animation-delay:-${(hash(k, i, 54) * d).toFixed(2)}s"/>`
273 }
274 }
275
276 // the glass track: a faint fill, a fine grain, the faint grid of empty cells end to end (under the pixels and the
277 // handle alike, so no gap opens beside either), a shade along the top edge and a glint along the bottom
278 const track =
279 `<clipPath id="${id}t"><rect x="0" y="${TRACK_Y}" width="${W}" height="${BH}" rx="${BH / 2}"/></clipPath>` +
280 `<rect x="0" y="${TRACK_Y}" width="${W}" height="${BH}" rx="${BH / 2}" fill="#808080" fill-opacity=".14"/>` +
281 `<g clip-path="url(#${id}t)"><rect x="0" y="${TRACK_Y}" width="${W}" height="${BH}" filter="url(#nz)" opacity=".05"/><rect x="0" y="${TRACK_Y}" width="${W}" height="${BH}" fill="url(#ta)" opacity=".08"/><rect x="0" y="${TRACK_Y}" width="${W}" height="1" fill="#000" fill-opacity=".35"/><rect x="0" y="${TRACK_Y + BH - 1}" width="${W}" height="1" fill="#fff" fill-opacity=".06"/></g>`
282
283 // the handle: a light vertical gradient, a thin edge and two fine grip lines
284 const handle =
285 fw > 0
286 ? `<linearGradient id="${id}h" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="${rgb(mix(WHITE, base, 0.2))}"/></linearGradient>` +
287 `<rect x="${hx.toFixed(1)}" y="${TRACK_Y - 2}" width="${THUMB_W}" height="${BH + 4}" rx="6" fill="url(#${id}h)" stroke="#000" stroke-opacity=".28"${row.isLow ? ' class="hl"' : ''}/>` +
288 [0.42, 0.58].map(p => `<line x1="${(hx + THUMB_W * p).toFixed(1)}" x2="${(hx + THUMB_W * p).toFixed(1)}" y1="${TRACK_Y + 6}" y2="${TRACK_Y + BH - 6}" stroke="#000" stroke-opacity=".22"/>`).join('')
289 : ''
290
291 const refill = row.isRefill ? `<animate attributeName="width" from="0" to="${fw.toFixed(1)}" dur="1.6s" calcMode="spline" keyTimes="0;1" keySplines=".3 .6 .2 1" fill="freeze"/>` : ''
292 // the even pace: two small notches, on the track's top and bottom edges, where the handle would be had the window been
293 // spent evenly; the track itself stays clear
294 const px = row.pace !== null && row.pace > 0 && row.pace < 100 ? (row.pace / 100) * W : null
295 const paceLine =
296 px !== null
297 ? `<g fill="#fff" fill-opacity=".8"><rect x="${(px - 3).toFixed(1)}" y="${TRACK_Y - 1}" width="6" height="4" rx="1.5"/><rect x="${(px - 3).toFixed(1)}" y="${TRACK_Y + BH - 3}" width="6" height="4" rx="1.5"/></g>`
298 : ''
299 const crumbs = isBusy && hx > 6 ? CRUMBS.map(k => `<rect x="${(hx - 2).toFixed(1)}" y="${TRACK_Y + BH / 2 + k.y}" width="${k.s}" height="${k.s}" fill="${c}" class="cr" style="--dy:${k.dy}px;animation-delay:${k.d}s"/>`).join('') : ''
300 // the run-out estimate sits on a small line under the meter
301 const note = row.note ? `<text x="0" y="${TRACK_Y + BH + NOTE_H - 2}" class="${row.note.isWarn ? 'nw' : 'nd'}">${esc(row.note.text)}</text>` : ''
302
303 return `<g transform="translate(${place.x.toFixed(1)} ${place.y})" class="${row.beat}${row.isSlow ? ' slow' : ''}">${colour}
304<text x="0" y="12" class="ul">${esc(row.name)}</text>${note}
305<text x="${W}" y="12" text-anchor="end" class="un"${row.status > 0 ? ` style="fill:${c}"` : ''}>${esc(row.value)}<tspan class="ul" dx="6">${esc(row.detail)}</tspan></text>
306${track}
307<clipPath id="${id}k"><rect x="0" y="${TRACK_Y}" width="${fw.toFixed(1)}" height="${BH}" rx="${BH / 2}">${refill}</rect></clipPath>
308<g clip-path="url(#${id}k)"><rect x="0" y="${TRACK_Y}" width="${fw.toFixed(1)}" height="${BH}" fill="${c}" fill-opacity=".06"/><g class="mood">${pixels}</g>${signSvg(signNow, cols, i, c)}</g>${spent}${paceLine}${crumbs}${handle}</g>`
309}
310
311// The sign's pixels. It shows up in a few places at once along the bar, each copy a beat after the one before: the
312// first just short of the handle, the others further back (two or three in all, as the bar has room; a bar too short
313// for even one shows none). The field around it carries on as it was; the sign simply lights up on top of it, then
314// teleports back from the handle stop by stop (see SIGN_STOPS), never sliding.
315const SIGN_SPOTS = [0.94, 0.62, 0.3] // where the copies first gather, as shares of the bar's pixel columns
316const SIGN_STAGGER = 0.35 // seconds between one copy and the next
317
318function signSvg(signNow: { name: string; age: number } | null, cols: number, i: number, tint: string) {
319 const def = signNow ? SIGNS[signNow.name] : undefined
320 if (!signNow || !def || signNow.age >= SIGN_MS + SIGN_STAGGER * SIGN_SPOTS.length * 1000) return ''
321 const w = def.art[0]?.length ?? 0
322 const hop = w + 5 // columns from one stop to the next
323 const fill = rgb(mix(hex(def.colour ?? tint), WHITE, 0.6))
324 const starts: number[] = []
325 for (const spot of SIGN_SPOTS) {
326 const start = Math.min(cols - 3 - w, Math.round(spot * cols - w / 2))
327 // each copy needs its own room: off the far end and clear of the copies already placed
328 if (start < 2 || starts.some(s => Math.abs(s - start) < w + 4)) continue
329 starts.push(start)
330 }
331 const pixels = (at: number, salt: number) => {
332 let out = ''
333 def.art.forEach((line, r) =>
334 [...line].forEach((ch, k) => {
335 if (ch !== '#') return
336 const x = (at + k) * PITCH + INSET
337 const y = DOT_Y + r * PITCH + INSET
338 out += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" style="--j:${(hash(k + salt, r, i + 7) * 0.4).toFixed(2)}s"/>`
339 }),
340 )
341 return out
342 }
343 return starts
344 .map((start, n) => {
345 const age = (signNow.age / 1000 - n * SIGN_STAGGER).toFixed(2)
346 let out = ''
347 SIGN_STOPS.forEach((strength, k) => {
348 const at = start - k * hop
349 if (at < 1) return
350 const begins = k * SIGN_HOP_S
351 out += `<g opacity="${strength}" style="--o:${begins}s">${pixels(at, n * 11 + k * 5)}</g>`
352 // between this stop and the next, a few cells on the way flash as the pixels fly across
353 const next = at - hop
354 if (k === SIGN_STOPS.length - 1 || next < 1) return
355 let sparks = ''
356 for (let m = 0; m < 6; m++) {
357 const col = next + Math.floor(hash(m, k, n + i * 3 + 21) * (hop + w))
358 const r = Math.floor(hash(m, k, n + i * 3 + 22) * ROWS)
359 const when = begins + SIGN_HOP_S * (0.75 + hash(m, k, n + 23) * 0.5)
360 sparks += `<rect x="${(col * PITCH + INSET).toFixed(1)}" y="${(DOT_Y + r * PITCH + INSET).toFixed(1)}" style="--o:${when.toFixed(2)}s"/>`
361 }
362 out += `<g class="fly" opacity="${(strength * 0.8).toFixed(2)}">${sparks}</g>`
363 })
364 return `<g class="sg" fill="${fill}" style="--e:${age}s">${out}</g>`
365 })
366 .join('')
367}
368
369const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
370const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
371const rgb = (c: number[]) => `rgb(${c.join(',')})`
372const GREY = [112, 110, 120]
373const WHITE = [255, 255, 255]
374
375const LOW_ALARM = 5 // below this much left the handle beats red
376const REFILL_MS = 4000 // how long after a reset the meter still plays its refill
377// when each window last reset, so its meter fills up again from empty once
378const refilledAt = new Map<string, number>()
379
380function limitRow(l: Limit, i: number, now: number, isBusy: boolean): Row {
381 const left = Math.max(0, Math.min(100, 100 - l.used))
382 const pace = paceLeft(l, now)
383 const isLow = left < LOW_ALARM
384 const status = Math.max(0, statusOf(left))
385 return {
386 id: `u${i}`,
387 kind: l.kind,
388 name: LIMIT_NAME[l.kind] ?? l.kind,
389 value: `%${Math.round(left)}`,
390 detail: untilReset(l.resetsAt, now),
391 note: eta(l, now),
392 fill: left,
393 color: STATUS[status]?.colour ?? STATUS[0]!.colour,
394 status,
395 beat: isLow ? 'hot' : isBusy ? 'busy' : 'calm',
396 isSlow: l.kind === 'seven_day',
397 pace,
398 isLow,
399 isRefill: now - (refilledAt.get(l.kind) ?? -Infinity) < REFILL_MS,
400 }
401}
402
403// The 5 hour and weekly meters share one line, side by side: the 5 hour one takes two thirds of the width, the weekly
404// one the rest. Any other number of meters stacks, one per line.
405const SIDE_GAP = 16
406// a line is taller by NOTE_H when some meter has an estimate to show under it
407const NOTE_H = 14
408const lineH = (rows: readonly Row[]) => ROW_H + (rows.some(r => r.note) ? NOTE_H : 0)
409function layout(rows: readonly Row[], W: number): { x: number; w: number; y: number }[] {
410 if (rows.length !== 2) return rows.map((_, i) => ({ x: 0, w: W, y: i * lineH(rows) }))
411 const first = Math.round(((W - SIDE_GAP) * 2) / 3)
412 return [
413 { x: 0, w: first, y: 0 },
414 { x: first + SIDE_GAP, w: W - first - SIDE_GAP, y: 0 },
415 ]
416}
417const metersHeight = (rows: readonly Row[]) => (rows.length === 2 ? 1 : rows.length) * lineH(rows) - 6
418
419// `moodFor`: how long ago the turn started (while busy) or ended (while not), so the field eases from where it was
420function metersSvg(rows: readonly Row[], total: number, isBusy: boolean, moodFor: number, shown: Sign | null = null, now = 0) {
421 const shownColour = shown ? SIGNS[shown.name]?.colour : undefined
422 const tintNow = shown && shownColour && now - shown.at < TINT_MS ? { colour: shownColour, age: now - shown.at } : null
423 const [from, to] = isBusy ? [QUIET, 1] : [1, QUIET]
424 const easing = moodFor < EASE_MS ? `animation:mood ${EASE_MS / 1000}s ease-in-out -${(moodFor / 1000).toFixed(2)}s 1 both;` : ''
425 const moodCss = `.mood{opacity:${to};${easing}}@keyframes mood{from{opacity:${from}}to{opacity:${to}}}`
426 const PAD = 6 // room for the handle at 100%, so nothing is cut at the edge
427 const W = total - PAD * 2
428 const H = metersHeight(rows)
429 const spots = layout(rows, W)
430
431 return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${H}" viewBox="0 0 ${total} ${H}"><style>
432.ul{font:400 12px 'Anthropic Sans',ui-sans-serif,system-ui,sans-serif;fill:#fff;fill-opacity:.65}.un{font:600 13px 'Anthropic Sans',ui-sans-serif,system-ui,sans-serif;fill:#fff}
433.nd,.nw{font:400 11px 'Anthropic Sans',ui-sans-serif,system-ui,sans-serif}.nd{fill:#fff;fill-opacity:.5}.nw{fill:#F0B44C}
434${pixelCss()}
435${moodCss}
436.gh{opacity:0;animation:gh ease-in-out infinite}@keyframes gh{0%,100%{opacity:0}50%{opacity:.5}}
437.cr{opacity:0;animation:cr 1.5s cubic-bezier(.2,.6,.3,1) infinite}
438@keyframes cr{0%{transform:translate(0,0);opacity:0}15%{opacity:1}100%{transform:translate(-36px,var(--dy));opacity:0}}
439.hl{animation:hl 1.1s ease-out infinite}@keyframes hl{0%,28%,70%,100%{fill:#fff}14%{fill:#FFD9D9}}
440.sg rect{width:${SQ}px;height:${SQ}px;rx:${RX}px;opacity:0;animation:sg ${SIGN_STOP_MS / 1000}s ease-in-out both;animation-delay:calc(var(--j, 0s) + var(--o) - var(--e))}
441@keyframes sg{0%{opacity:0}14%{opacity:.6}22%{opacity:1}68%{opacity:1}82%{opacity:.35}100%{opacity:0}}
442.sg .fly rect{animation:fly .45s ease-out both;animation-delay:calc(var(--o) - var(--e))}
443@keyframes fly{0%,100%{opacity:0}35%{opacity:.85}}
444@media (prefers-reduced-motion:reduce){.px rect,.hb rect,.gh,.cr,.hl,.mood,.sg{animation:none}.gh,.cr,.sg{display:none}}
445</style><defs>${defs(W)}</defs><g transform="translate(${PAD} 0)">${rows.map((r, i) => rowSvg(r, i, spots[i]?.w ?? W, isBusy, shown && (shown.kind === r.kind || (i === 0 && !rows.some(x => x.kind === shown.kind))) ? { name: shown.name, age: now - shown.at } : null, tintNow, spots[i])).join('')}</g></svg>`
446}
447
448// ---------- engine glue ----------
449
450// when the main conversation's turn last started or ended, so the field eases between its brightnesses from there
451let moodSince = 0
452
453// the lines a window warns at as it runs low, and which of them each window has already warned at (cleared once it
454// is back above); the first sounds the limit tone too
455const LOW_LEFT = [20, 10]
456const warned = new Set<string>()
457
458// a window that drops below 20% or 10% left shows the warning sign once per line (the tone at 20%), not again until it
459// recovers; the windows that just crossed a line are returned
460function warnLow($: EngineInterface, list: readonly Limit[]): string[] {
461 const crossed: string[] = []
462 for (const l of list) {
463 const left = 100 - l.used
464 for (const line of LOW_LEFT) {
465 const key = `${l.kind}:${line}`
466 if (left >= line) warned.delete(key)
467 else if (!warned.has(key)) {
468 warned.add(key)
469 if (line === LOW_LEFT[0]) play($, 'limit')
470 crossed.push(l.kind)
471 }
472 }
473 }
474 return crossed
475}
476
477async function showSign($: EngineInterface, name: string, kind = 'five_hour') {
478 const at = await $.clock.now()
479 const fill: Sign = { name, kind, at }
480 await update($, sign, () => fill)
481}
482
483// while nothing runs: the moon once after a long quiet spell, and now and then a surprise
484let isMoonShown = false
485async function idleSigns($: EngineInterface) {
486 const now = await $.clock.now()
487 if (await read($, busy)) return
488 if (!isMoonShown && now - moodSince > MOON_AFTER_MS) {
489 isMoonShown = true
490 await showSign($, 'moon')
491 return
492 }
493 const last = await read($, sign)
494 if (last && now - last.at < SIGN_GAP_MS) return
495 if (Math.random() > 0.3) return
496 const five = (await read($, limits)).find(l => l.kind === 'five_hour')
497 const pace = five ? paceLeft(five, now) : null
498 const isOnPace = five !== undefined && (pace === null || 100 - five.used >= pace)
499 const pool = SURPRISES.filter(n => n !== 'arrow' || isOnPace)
500 await showSign($, pool[Math.floor(Math.random() * pool.length)] ?? 'star')
501}
502
503// the engine reports a window only after the session's first API response; until then the last reading kept
504// in the store (a window that has reset since reads as unused) stands in, so the meters show from the first second
505async function syncLimits($: EngineInterface, rate: readonly { kind: string; percentUsed: number; resetsAt?: string }[]) {
506 const fresh: Limit[] = rate.filter(r => r.kind in LIMIT_NAME).map(r => ({ kind: r.kind, used: r.percentUsed, resetsAt: r.resetsAt }))
507 if (fresh.length > 0) {
508 const crossed = warnLow($, fresh)
509 const before = await read($, limits)
510 const now = await $.clock.now()
511 // a window that gave back most of what was used has reset: its meter fills up again once, and the person hears of it
512 for (const l of fresh) {
513 const was = before.find(b => b.kind === l.kind)
514 if (was && was.used - l.used >= 30) {
515 refilledAt.set(l.kind, now)
516 $.ui.toast(`${LIMIT_NAME[l.kind] ?? l.kind} hakkı yenilendi`)
517 play($, 'reset')
518 await showSign($, 'heart', l.kind)
519 }
520 }
521 if (crossed[0]) await showSign($, 'warn', crossed[0])
522 record(fresh, now)
523 await $.store.set('history', history)
524 await update($, limits, () => fresh)
525 await $.store.set('limits', fresh)
526
527 return
528 }
529 if ((await read($, limits)).length > 0) return
530 const saved = await $.store.get('limits')
531 if (!Array.isArray(saved)) return
532 const now = await $.clock.now()
533 const kept: Limit[] = saved
534 .filter((l): l is Limit => typeof l?.kind === 'string' && typeof l?.used === 'number' && l.kind in LIMIT_NAME)
535 .map(l => (l.resetsAt && Date.parse(l.resetsAt) < now ? { kind: l.kind, used: 0 } : l))
536 if (kept.length > 0) await update($, limits, () => kept)
537}
538
539// the engine's player first (afplay on macOS); PowerShell where it cannot play
540function play($: EngineInterface, name: 'limit' | 'reset') {
541 const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\')
542 void $.audio.play({ asset: `sounds/${name}.wav` }).catch(() =>
543 $.process
544 .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
545 .catch(() => undefined),
546 )
547}
548
549
550
551export const register: Register = on => {
552 // the main conversation's turn runs: the meters show usage being spent
553 on('turn.start', async ($, e, next) => {
554 moodSince = await $.clock.now()
555 isMoonShown = false
556 await showSign($, 'bolt')
557 await update($, busy, () => true)
558
559 return next(e)
560 })
561
562 on('turn.complete', async ($, e, next) => {
563 if (!e.agentId) {
564 moodSince = await $.clock.now()
565 await showSign($, 'check')
566 await update($, busy, () => false)
567 }
568
569 return next(e)
570 })
571
572 on('session.start', async ($, e, next) => {
573 const saved = await $.store.get('history')
574 if (saved && typeof saved === 'object') history = saved as Record<string, Sample[]>
575 // the first API response may come late and a window moves while idle: look again once a minute
576 $.clock.every(60_000, async () => {
577 await syncLimits($, (await $.session.usage()).rateLimits)
578 await idleSigns($)
579 })
580 await syncLimits($, (await $.session.usage()).rateLimits)
581 await $.command.register({ name: 'meters', description: 'Show or hide the 5 hour and weekly usage meters' })
582
583 return next(e)
584 })
585
586 on('session.measure', async ($, e, next) => {
587 await syncLimits($, e.rateLimits)
588
589 return next(e)
590 })
591
592 on('command.run', { command: 'meters' }, async $ => {
593 const open = await read($, isOpen)
594 await update($, isOpen, () => !open)
595
596 return { text: open ? 'Meters hidden.' : 'Meters shown.' }
597 })
598
599 // always drawn, so the person sees the mod is loaded; dim while there is nothing to show
600 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
601 const count = (await read($, limits)).length
602 const open = await read($, isOpen)
603 const { Box, Button } = $.ui.resolve(e)
604 // other mods add their labels to modes beneath us; keep them
605 const below = await next(e)
606 const press = () => (count === 0 ? $.ui.toast('stride is on. The meters appear after the first reply.') : update($, isOpen, () => !open))
607
608 return (
609 <Box flexDirection="row" alignItems="center" gap={1}>
610 <Button key="meters-toggle" dimColor={count === 0 || !open} label="Usage" onPress={press} />
611 {below}
612 </Box>
613 )
614 })
615
616 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
617 const lim = await read($, limits)
618 if (lim.length === 0 || e.props.hasSurvey || !(await read($, isOpen))) return next(e)
619 const t = $.ui.resolve(e)
620 const { Box, Text } = t
621 const Svg = 'Svg' in t ? t.Svg : null
622 const now = await $.clock.now()
623 const isBusy = await read($, busy)
624 // the 5 hour meter first, so it takes the wider place
625 const order = (k: string) => (k === 'five_hour' ? 0 : 1)
626 const rows = [...lim].sort((a, b) => order(a.kind) - order(b.kind)).map((l, i) => limitRow(l, i, now, isBusy))
627 const alt = rows.map(r => `${r.name} ${r.value}${r.note ? ` (${r.note.text})` : ''}`).join('; ')
628 // Desktop reports ~8 CSS px per column
629 const total = Math.max(320, (e.props.bodyColumns || 100) * 8)
630
631 return (
632 <Box flexDirection="column">
633 {Svg ? <Svg key="usage" source={metersSvg(rows, total, isBusy, now - moodSince, await read($, sign), now)} alt={alt} width={total} height={metersHeight(rows)} /> : <Text dimColor>{alt}</Text>}
634 </Box>
635 )
636 })
637}
638types/index.d.ts 21 lines1// one rate-limit window: percent used and when it resets
2export type Limit = { kind: string; used: number; resetsAt?: string }
3// a small sign the pixels gather into for a moment: which, on which window's meter, and when it appeared
4export type Sign = { name: string; kind: string; at: number }
5// one reading of a window, kept to estimate how fast it is being spent
6export type Sample = { t: number; used: number }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'stride': {
11 isOpen: boolean
12 // the 5 hour and weekly windows, from the engine's rate-limit figures (empty off a subscription)
13 limits: Limit[]
14 // true while the main conversation's turn runs, so the meters show usage being spent
15 busy: boolean
16 // the sign on show (it fades by itself a few seconds after `at`)
17 sign: Sign | null
18 }
19 }
20}
21