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

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.

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.
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.
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.
MIT
hooks/register.tsx 189 lines1import { 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}
189types/index.d.ts 8 lines1export 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