Animated usage bars (context, rate limits, cost) beside the prompt hint

Mods para o Claude Code.
Painel Source Control no estilo do VS Code. Lê os folders do .code-workspace da pasta onde o Claude abriu (sem ele, usa o próprio repositório ou os repositórios das subpastas) e mostra, por projeto, a branch, ↑/↓ em relação ao remoto, e os grupos Staged Changes e Changes com a letra de status nas cores do VS Code. Clicar num arquivo mostra o diff do arquivo inteiro; ← Back (ou b) volta.


/git-changes.launch.configurations e compounds e as tasks do .code-workspace e do .vscode/launch.json/.vscode/tasks.json de cada pasta, e os scripts do package.json de cada pasta (rodados com npm, pnpm, yarn ou bun conforme o lock file). É uma linha só, como no VS Code: clicar no nome abre a lista para cima e escolhe o que rodar, ▶ roda, ■ para, e Output mostra a saída (← Back volta). Um +N diz quantos outros estão rodando. Roda configurações node (runtimeExecutable/runtimeArgs ou program/args) e node-terminal, tasks shell, process e npm (com dependsOn), com cwd, env, ${workspaceFolder}, ${workspaceFolder:nome} e ${env:X}; os outros tipos aparecem apagados. Roda sem debugger, e os processos morrem quando o Claude fecha ou o plugin recarrega.enabledPlugins no ~/.claude/settings.json (os plugins são chamados nessa ordem): ``json "enabledPlugins": { "git-changes@donwaztok": true, "ponytail@ponytail": true } ``Barras animadas de uso ao lado da dica do prompt: modelo, contexto, limites de 5h/7d e custo da sessão. Aparece na última linha dos prints acima.
No prompt do Claude Code:
/plugin install git-changes --marketplace Donwaztok/claude-mods
/plugin install usage-line --marketplace Donwaztok/claude-mods
Responda y para adicionar o marketplace e escolha o escopo de usuário (vale para todas as sessões).
Pelo terminal:
claude plugin install git-changes --marketplace Donwaztok/claude-mods
claude plugin install usage-line --marketplace Donwaztok/claude-mods
No app desktop, onde /plugin não roda: clique em + ao lado da caixa do prompt, depois Plugins, Add marketplace com Donwaztok/claude-mods, depois Add plugin.
claude plugin validate plugins/git-changes
claude plugin test plugins/git-changeshooks/register.tsx 234 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Limit, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-line', key: 'usage' } as const, null)
7const prev = atom({ plugin: 'usage-line', key: 'prev' } as const, null)
8const frame = atom({ plugin: 'usage-line', key: 'frame' } as const, 0)
9const animFrom = atom({ plugin: 'usage-line', key: 'animFrom' } as const, -100)
10const isWorking = atom({ plugin: 'usage-line', key: 'isWorking' } as const, false)
11const model = atom({ plugin: 'usage-line', key: 'model' } as const, '')
12
13const TICK_MS = 100
14const EASE_FRAMES = 10
15const BAR_CELLS = 10
16// Where along the bar each color sits: cool while there is room, hot near the end.
17const GRADIENT: [number, [number, number, number]][] = [
18 [0, [56, 189, 248]],
19 [0.35, [74, 222, 128]],
20 [0.6, [250, 204, 21]],
21 [0.8, [251, 146, 60]],
22 [1, [239, 68, 68]],
23]
24const TRACK = '#3f3f46'
25const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
26const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
27
28export const toUsage = (u: {
29 context: SessionContextUsage
30 rateLimits: SessionRateLimit[]
31 cost?: SessionCost
32}): Usage => ({
33 ctx: u.context.percent,
34 tokens: u.context.tokens,
35 window: u.context.window,
36 limits: u.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt })),
37 usd: u.cost?.usd,
38})
39
40// `claude-opus-5-5[1m]` reads as `Opus 5.5 1M`; anything else stays as given.
41export const modelName = (id: string) => {
42 const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(id.trim())
43 if (!m) return id.trim()
44 const [, family, major, minor, wide] = m
45 const name = family!.charAt(0).toUpperCase() + family!.slice(1)
46 return `${name} ${major}${minor ? `.${minor}` : ''}${wide ? ' 1M' : ''}`
47}
48
49async function refreshModel($: EngineInterface) {
50 const name = modelName(await $.session.model())
51 await update($, model, () => name)
52}
53
54const hex = (rgb: number[]) => '#' + rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')
55
56export const gradient = (x: number, lighten = 0) => {
57 const at = Math.max(0, Math.min(1, x))
58 const i = GRADIENT.findIndex(([stop]) => stop >= at)
59 const [s1, c1] = GRADIENT[Math.max(0, i - 1)]!
60 const [s2, c2] = GRADIENT[i]!
61 const k = s2 === s1 ? 0 : (at - s1) / (s2 - s1)
62 return hex(c1.map((c, j) => c + (c2[j]! - c) * k).map(c => c + (255 - c) * lighten))
63}
64
65// Whole cells as heavy line, a half cell as its left half, then the thin track.
66export const bar = (pct: number, cells = BAR_CELLS) => {
67 const halves = Math.round((Math.max(0, Math.min(100, pct)) / 100) * cells * 2)
68 const full = Math.floor(halves / 2)
69 const fill = '━'.repeat(full) + (halves % 2 ? '╸' : '')
70 return { fill, track: '─'.repeat(cells - fill.length) }
71}
72
73// `2026-10-22T18:30:00Z` seen at 15:12 reads as `3h18m`; days kick in past 24h (`2d5h`).
74export const timeLeft = (resetsAt: string | undefined, now = Date.now()) => {
75 const at = resetsAt ? Date.parse(resetsAt) : NaN
76 if (Number.isNaN(at)) return ''
77 const mins = Math.max(0, Math.ceil((at - now) / 60000))
78 if (mins < 60) return `${mins}m`
79 const h = Math.floor(mins / 60)
80 if (h < 24) return `${h}h${String(mins % 60).padStart(2, '0')}m`
81 return `${Math.floor(h / 24)}d${h % 24}h`
82}
83
84const MODES: Record<string, string> = { 'accept edits': 'edits', 'bypass permissions': 'bypass' }
85
86// `⏵⏵ auto mode on (shift+tab to cycle) · esc to …` reads as `⏵⏵ auto`.
87export const modeOnly = (hint: string) => {
88 const mode = hint.split(/\s*[(·]/)[0]!.trim().replace(/\s+(mode\s+)?on$/i, '')
89 return mode.replace(/[a-z][a-z ]*$/i, name => MODES[name.toLowerCase()] ?? name)
90}
91
92const ease = (t: number) => 1 - (1 - t) ** 3
93const lerp = (a: number | undefined, b: number, t: number) => (a ?? 0) + (b - (a ?? 0)) * t
94
95let ticker: Timer | undefined
96
97function tick($: EngineInterface) {
98 if (ticker) return
99 ticker = $.clock.every(TICK_MS, () => {
100 void (async () => {
101 const f = await update($, frame, n => n + 1)
102 const settled = f - (await read($, animFrom)) >= EASE_FRAMES
103 if (settled && !(await read($, isWorking))) {
104 ticker?.cancel()
105 ticker = undefined
106 }
107 })()
108 })
109}
110
111async function measure($: EngineInterface, next: Usage) {
112 const old = await read($, usage)
113 await update($, prev, () => old)
114 await update($, usage, () => next)
115 const f = await read($, frame)
116 await update($, animFrom, () => f)
117 tick($)
118}
119
120export const register: Register = on => {
121 on('session.start', async ($, e, next) => {
122 const r = await next(e)
123 // Clears the status line pinned by the first version; the bars live in PromptHint.
124 $.ui.status(undefined)
125 await refreshModel($)
126 await measure($, toUsage(await $.session.usage()))
127 return r
128 })
129
130 // A resumed or cleared session starts with empty state and no measure until a limit moves,
131 // and drawing may not write state: load it here.
132 on('classic.SessionStart', async ($, e, next) => {
133 const r = await next(e)
134 await refreshModel($)
135 await measure($, toUsage(await $.session.usage()))
136 return r
137 })
138
139 on('session.measure', async ($, e, next) => {
140 await refreshModel($)
141 await measure($, toUsage(e))
142 return next(e)
143 })
144
145 on('prompt.submit', async ($, e, next) => {
146 await update($, isWorking, () => true)
147 tick($)
148 return next(e)
149 })
150
151 on('classic.PostModelSwitch', async ($, e, next) => {
152 await update($, model, () => modelName(e.to_model))
153 return next(e)
154 })
155
156 on('turn.complete', async ($, e, next) => {
157 await update($, isWorking, () => false)
158 return next(e)
159 })
160
161 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
162 const u = await read($, usage)
163 if (!u) return next(e)
164
165 const { Box, Text } = $.ui.resolve(e)
166 const f = await read($, frame)
167 const working = await read($, isWorking)
168 const old = await read($, prev)
169 const modelLabel = await read($, model)
170 const t = ease(Math.min(1, (f - (await read($, animFrom))) / EASE_FRAMES))
171
172 const meter = (key: string, label: string, pct: number, from: number | undefined, resetsAt?: string) => {
173 const shown = lerp(from, pct, t)
174 const { fill, track } = bar(shown)
175 // While a turn runs, a soft highlight sweeps along the filled part.
176 const glint = working ? (f % (fill.length + 6)) - 1 : -9
177 const glow = (i: number) => Math.max(0, 0.55 - Math.abs(i - glint) * 0.25)
178 const tip = gradient(shown / 100)
179 const left = timeLeft(resetsAt)
180 return (
181 <Text key={key}>
182 <Text color="subtle">{label} </Text>
183 {[...fill].map((c, i) => (
184 <Text color={gradient((i + 0.5) / BAR_CELLS, glow(i))}>{c}</Text>
185 ))}
186 <Text color={TRACK}>{track}</Text>
187 <Text color={tip} weight="bold">
188 {' '}
189 {Math.round(shown)}%
190 </Text>
191 {left ? <Text color="subtle"> {left}</Text> : null}
192 </Text>
193 )
194 }
195
196 const sep = <Text color={TRACK}> · </Text>
197 const parts = []
198
199 if (u.ctx !== undefined) {
200 parts.push(meter('ctx', 'ctx', u.ctx, old?.ctx))
201 }
202 for (const l of u.limits) {
203 const before = old?.limits.find((o: Limit) => o.kind === l.kind)?.percent
204 parts.push(meter(l.kind, LABELS[l.kind] ?? l.kind, l.percent, before, l.resetsAt))
205 }
206 if (u.usd !== undefined) {
207 parts.push(
208 <Text key="usd" color="claude" weight="bold">
209 ${lerp(old?.usd, u.usd, t).toFixed(2)}
210 </Text>,
211 )
212 }
213
214 return (
215 <Box flexDirection="row">
216 <Text dimColor wrap="truncate">
217 {typeof e.props.hint === 'string' ? modeOnly(e.props.hint) : e.props.hint}
218 </Text>
219 <Text> </Text>
220 <Text color="claude">{working ? SPINNER[f % SPINNER.length] : '◆'} </Text>
221 {modelLabel ? (
222 <Text>
223 <Text color="claude" weight="bold">
224 {modelLabel}
225 </Text>
226 {parts.length ? sep : null}
227 </Text>
228 ) : null}
229 {parts.flatMap((p, i) => (i ? [sep, p] : [p]))}
230 </Box>
231 )
232 })
233}
234types/index.d.ts 9 lines1export type Limit = { kind: string; percent: number; resetsAt?: string }
2export type Usage = { ctx?: number; tokens?: number; window: number; limits: Limit[]; usd?: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'usage-line': { usage: Usage | null; prev: Usage | null; frame: number; animFrom: number; isWorking: boolean; model: string }
7 }
8}
9