SLOPSHOPPER

usage-line

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

newspinnerstatusprompttimer
v0.2.2no licenseupdated 2026-10-23Donwaztok/claude-mods/plugins/usage-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-line
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◆ Opus 5.5 · ctx ━━━━━───── 49% · 5h ━━━─────── 31% · $0.42

Draws

Prompt hint
? for shortcuts ◆ Opus 5.5 · ctx ━━━━━───── 49% · 5h ━━━─────── 31% · $0.42
README

claude-mods

Mods para o Claude Code.

git-changes

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.

Painel git-changes

Diff de um arquivo

  • Em cada arquivo: +/− faz stage/unstage, ↶ descarta as mudanças (arquivo novo é apagado), sempre pedindo confirmação, e ⋯ abre um menu com Reveal in File Explorer.
  • Abre sozinho a partir de 144 colunas, ou com /git-changes.
  • Atualiza a cada 4 s e depois de cada ferramenta que o Claude roda.
  • Com o painel aberto, a faixa de plugins acima do prompt (a do ponytail, por exemplo) vai para o rodapé do painel e libera espaço. Os botões de outros plugins viram texto; Review e Audit do ponytail continuam clicáveis. Board, Details e os níveis ficam na faixa original, que volta ao fechar o painel.
  • Abaixo da faixa de plugins fica Run and Debug, com o que o VS Code roda: as 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.
  • Fechado no ✕, a faixa volta com um botão ⎇ Source Control para reabrir.
  • Para pegar a faixa de outro plugin, o git-changes precisa vir antes dele em enabledPlugins no ~/.claude/settings.json (os plugins são chamados nessa ordem): ``json "enabledPlugins": { "git-changes@donwaztok": true, "ponytail@ponytail": true } ``

usage-line

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.

Instalar

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.

Desenvolver

claude plugin validate plugins/git-changes
claude plugin test plugins/git-changes
Source 2 files
hooks/register.tsx 234 lines
1import { 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}
234
types/index.d.ts 9 lines
1export 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