SLOPSHOPPER

stride

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

newbandspinnercommandtoastprocess
v0.1.0MITupdated 2026-10-08snmeric/stride/plugins/stride
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stride
› 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 › /meters ⎿ stride: Meters hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Usage ] ⟨Claude Code's own drawing⟩
README

stride

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.

Install

/plugin marketplace add snmeric/stride
/plugin install stride@snmeric-mods

Commands

  • /meters shows or hides the meters (the Usage button in the footer does the same)

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 638 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
638
types/index.d.ts 21 lines
1// 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