SLOPSHOPPER

usage-bars

Dos barras de píxeles sobre el prompt con el uso de los límites de 5 horas y semanal de Claude

newbandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bars
› 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 ● 5h ━━━━━━━━───────────────── 31% · NaNm ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● 5h ━━━━━━━━───────────────── 31% · NaNm
README

usage-bars

Un mod para Claude Code que muestra sobre el prompt cuánto te queda de tus límites de uso: la ventana de 5 horas y la semanal, lado a lado.

usage-bars: dos barras de píxeles con 15 % y 24 % sobre el prompt

  • Una barra de píxeles por límite, con los píxeles titilando y más densos hacia la cabeza.
  • Marcas cada 5 % y cápsulas cada 25 %, que se iluminan al superarlas.
  • Una píldora en la cabeza de la barra que alterna cada pocos segundos entre el porcentaje usado y el tiempo que falta para el reinicio.
  • Colores: verde bajo el 80 %, naranjo desde el 80 % y rojo al llegar al límite.
  • Cuando el uso cambia, el relleno se desliza hasta su nueva posición en lugar de saltar.
  • En la terminal se ve una versión de texto simple; el estilo de píxeles es de la app de escritorio.

Instalación

En Claude Code:

/plugin marketplace add AxelDreemurr/UsageBars
/plugin install usage-bars@axel-mods

O copia plugins/usage-bars a ~/.claude/skills/usage-bars para cargarlo en todas las sesiones.

Requisitos y límites

  • Los datos de los límites los entrega Claude Code solo en cuentas de suscripción (Pro, Max...). Con una API key no hay límites que mostrar y verás "esperando datos".
  • Las barras se actualizan con cada respuesta de Claude, no en tiempo real. Antes de la primera respuesta de la sesión puede aparecer "esperando datos".
  • Usa la API de mods de Claude Code (hooks de función). Si tu versión no la tiene, el mod no se carga.

Cómo funciona

El mod escucha session.measure, que el motor lanza cuando un límite avanza un punto entero, y guarda las ventanas (five_hour, seven_day) en el estado de la sesión. Un hook de ui.render sobre el prompt dibuja una barra SVG por ventana. El titileo y la alternancia de la píldora son animaciones dentro del propio SVG, así que no hace falta redibujar por temporizador. Solo se redibuja cuando cambia el uso y una vez por minuto para refrescar el tiempo de reinicio.

Créditos

El estilo visual (barra de píxeles, marcas, píldora) está inspirado en plan-progress de zycck. El código de este mod es propio.

Licencia

MIT

Source 2 files
hooks/register.tsx 189 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Limit } from '../types'
5
6const limits = atom({ plugin: 'usage-bars', key: 'limits' } as const, [] as Limit[])
7const tick = atom({ plugin: 'usage-bars', key: 'tick' } as const, 0)
8
9const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'Semana' }
10const label = (k: string) => LABELS[k] ?? k
11const ORDER = ['five_hour', 'seven_day']
12const rank = (k: string) => (ORDER.includes(k) ? ORDER.indexOf(k) : 9)
13const TRACK_H = 22
14const NARROW = 200
15const INK = '#FFFFFF'
16const FONT = "'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"
17
18// verde bajo 80 %, naranjo desde 80 %, rojo al llegar al límite
19const colorFor = (pct: number) => (pct >= 100 ? '#C5353E' : pct >= 80 ? '#C96A00' : '#18883A')
20
21const hex = (c: string) => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
22const mix = (a: number[], b: number[], t: number) => a.map((v, i) => Math.round(v + (b[i] - v) * t))
23const rgb = (c: number[]) => `rgb(${c[0]},${c[1]},${c[2]})`
24const hash = (a: number, b: number, c: number) => {
25  let h = (a * 374761393 + b * 668265263 + c * 2147483647) | 0
26  h = Math.imul(h ^ (h >>> 13), 1274126177)
27  return ((h ^ (h >>> 16)) >>> 0) / 4294967295
28}
29
30const resetIn = (iso?: string) => {
31  if (!iso) return ''
32  const mins = Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 60000))
33  const d = Math.floor(mins / 1440)
34  const h = Math.floor((mins % 1440) / 60)
35  const m = mins % 60
36  return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
37}
38
39// última posición de la cabeza por ventana, para que el relleno se deslice desde donde estaba
40const lastHead = new Map<string, number>()
41
42function trackSvg(l: Limit, W: number): string {
43  const H = TRACK_H
44  const frac = Math.max(0, Math.min(1, l.percentUsed / 100))
45  const fx = frac * W
46  const from = lastHead.get(l.kind) ?? fx
47  lastHead.set(l.kind, fx)
48  const glide = Math.abs(from - fx) > 0.5
49  const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
50
51  const color = colorFor(l.percentUsed)
52  const acc = hex(color)
53  const light = mix(acc, [255, 255, 255], 0.32)
54  const grey = [132, 130, 138]
55
56  // píxeles: rejilla de 3px, 7 filas, más densos hacia la cabeza, titilando
57  const buckets = [0, 1, 2, 3, 4].map(b => {
58    const m = b / 4
59    const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
60    return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
61  })
62  const dots = new Map<string, string>()
63  for (let col = 0; col * 3 < fx; col++) {
64    const x = col * 3
65    const u = Math.min(1, (x + 1.5) / fx)
66    const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
67    const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
68    for (let r = 0; r < 7; r++) {
69      if (hash(col, r, 1) > dense + 0.1) continue
70      const key = `b${bucket} t${Math.floor(hash(col, r, 2) * 12)}`
71      dots.set(key, (dots.get(key) ?? '') + `M${x} ${1 + r * 3}h2v2h-2z`)
72    }
73  }
74  const px = [...dots].map(([cls, d]) => {
75    const n = Number(cls.split('t')[1])
76    const dur = (1.1 + (n % 5) * 0.37).toFixed(2)
77    const begin = (-(n * 0.53) % 3).toFixed(2)
78    return `<path class="${cls}" d="${d}"><animate attributeName="opacity" values="1;.15;1" keyTimes="0;.5;1" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></path>`
79  }).join('')
80
81  // marcas cada 25 % (cápsulas) y cada 5 % (puntos), brillantes una vez superadas
82  let marks = ''
83  for (let k = 1; k < 20; k++) {
84    const x = (k / 20) * W
85    const passed = x < fx - 1
86    const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#A8A69E'
87    marks += k % 5 === 0
88      ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" fill="${fill}" opacity="${passed ? 0.95 : 0.75}"/>`
89      : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.4" fill="${fill}" opacity="${passed ? 0.8 : 0.6}"/>`
90  }
91
92  // píldora en la cabeza: tiempo hasta el reinicio (o punto redondo si es estrecho)
93  const reset = resetIn(l.resetsAt)
94  const pctText = `${Math.round(l.percentUsed)}%`
95  const widest = Math.max(reset.length, pctText.length)
96  const kw = W < NARROW ? H : Math.round(widest * 6.6 + 22)
97  const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
98  const kx = clampX(fx)
99  const kFrom = clampX(from)
100  // la píldora alterna entre el porcentaje y el tiempo hasta el reinicio (ciclo de 6 s, con fundido)
101  const swap = (values: string) =>
102    `<animate attributeName="opacity" values="${values}" keyTimes="0;.42;.5;.92;1" dur="6s" repeatCount="indefinite"/>`
103  const knob =
104    `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>` +
105    (W < NARROW
106      ? ''
107      : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${pctText}${swap('1;1;0;0;1')}</text>` +
108        (reset ? `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt" opacity="0">${reset}${swap('0;0;1;1;0')}</text>` : ''))
109
110  const style = `<style>
111${buckets.map((b, i) => `.b${i}{fill:${b.color};fill-opacity:${b.opacity}}`).join('')}
112.kt{font:500 12px ${FONT};fill:${INK}}
113</style>`
114  const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
115  const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
116
117  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}
118<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
119<linearGradient id="base" x1="0" x2="${Math.max(1, fx).toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity=".05"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
120<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
121<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
122<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
123}
124
125export const register: Register = on => {
126  on('session.start', async ($, e, next) => {
127    try {
128      const u = await $.session.usage()
129      if (u.rateLimits.length > 0) await update($, limits, () => u.rateLimits as Limit[])
130    } catch {}
131    // refresca el tiempo de reinicio cada minuto
132    $.clock.every(60000, () => update($, tick, n => n + 1))
133    return next(e)
134  })
135
136  on('session.measure', async ($, e, next) => {
137    if (e.changed.includes('rateLimits') && e.rateLimits.length > 0) {
138      await update($, limits, () => e.rateLimits as Limit[])
139    }
140    return next(e)
141  })
142
143  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
144    const all = await read($, limits)
145    if (e.props.hasSurvey) return next(e)
146    await read($, tick)
147
148    const t = $.ui.resolve(e)
149    const { Box, Text } = t
150    if (all.length === 0) {
151      return <Text dimColor>Límites de uso: esperando datos (aparecen tras la próxima respuesta)</Text>
152    }
153    const Svg = 'Svg' in t ? t.Svg : null
154    const rows = [...all].sort((a, b) => rank(a.kind) - rank(b.kind))
155
156    const total = Math.max(320, (e.props.bodyColumns || 100) * 8)
157    const half = Math.floor((total - 24) / 2)
158    // etiqueta y porcentaje ocupan ~110px de cada mitad
159    const trackW = Math.max(100, Math.min(700, half - 110))
160
161    return (
162      <Box flexDirection="row" gap={3}>
163        {rows.map(l => {
164          const pct = Math.round(l.percentUsed)
165          const color = colorFor(l.percentUsed)
166          const alt = `${label(l.kind)}: ${pct}% usado, reinicia en ${resetIn(l.resetsAt)}`
167          const cells = Math.round(pct / 4)
168          return (
169            <Box key={l.kind} flexGrow={1} flexDirection="row" alignItems="center" gap={1}>
170              <Text color={color}>●</Text>
171              <Text bold>{label(l.kind)}</Text>
172              <Box flexGrow={1} />
173              {Svg ? (
174                <Svg source={trackSvg(l, trackW)} alt={alt} width={trackW} height={TRACK_H} />
175              ) : (
176                <Text>
177                  <Text color={color}>{'━'.repeat(cells)}</Text>
178                  <Text dimColor>{'─'.repeat(25 - cells)}</Text>
179                  <Text dimColor> {pct}% · {resetIn(l.resetsAt)}</Text>
180                </Text>
181              )}
182            </Box>
183          )
184        })}
185      </Box>
186    )
187  })
188}
189
types/index.d.ts 8 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'usage-bars': { limits: Limit[]; tick: number }
6  }
7}
8