SLOPSHOPPER

usage-band

Prompt üstünde model, effort, context, 5 saatlik ve haftalık kullanım ile oturum maliyetini gösteren bant

bandcommandtoasttimer
v0.2.0no licenseupdated 2026-10-17aliyusufergin/claude-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 › /usage-band ⎿ usage-band: Kullanım bandı gizlendi. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

usage-band

Claude Code için prompt'un üstünde tek satırlık bir kullanım bandı:

Opus 5.5 · high │ ctx ▓▓▓░░░░░ 42% · compact'a 60k │ 5h ▓▓░░░░░░ 23% ↻1s12d │ 7d ▓▓▓▓▓░░░ 61% ↻3g │ $1.24
  • Model ve effort: ana döngünün gönderdiği son istekten
  • ctx: context doluluğu ve auto-compact'a kalan token
  • 5h / 7d: 5 saatlik ve haftalık kullanım limiti, sıfırlanmaya kalan süre (g = gün, s = saat, d = dakika)
  • Maliyet: oturumun API maliyeti
  • Renkler: %70'e kadar yeşil, %70–90 sarı, %90 üstü kırmızı
  • Dar terminalde önce çubuklar, sonra geri sayım düşer

5h/7d bilgisi yalnızca Claude aboneliğiyle gelir; API anahtarıyla bu bölümler görünmez.

Kurulum

Claude Code'da prompt'a yazın:

/plugin install usage-band --marketplace aliyusufergin/claude-usage-band

Marketplace'i eklemek için y, ardından kapsam olarak user'ı seçin.

Güncellemek için:

claude plugin update usage-band

ardından açık oturumda /reload-plugins.

Uyarılar

5h veya 7d kullanımı uyarı eşiğini (%80) geçince bir toast, kritik eşiği (%95) geçince toast ve sistem bildirimi gelir. Her eşik, her limit penceresi için bir kez uyarır; bu bilgi oturumlar arası hatırlanır, pencere sıfırlanınca uyarılar yeniden devreye girer.

Komutlar

  • /usage-band: bandı gizle / göster
  • /usage-band kompakt: çubukları kapat / aç

Bu tercihler oturumlar arası hatırlanır.

Ayarlar (/config)

AyarVarsayılanAçıklama
Uyarı eşiği (%)80Toast
Kritik eşik (%)95Toast ve sistem bildirimi
Sistem bildirimiaçıkKritik eşikte masaüstü bildirimi
Sarı / kırmızı renk eşiği (%)70 / 90Renk geçişleri
Çubuk genişliği80 verilirse çubuk yok
Modeli / effort'u / compact mesafesini / geri sayımı / maliyeti gösteraçıkBölümleri tek tek aç/kapa

Geliştirme

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 267 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { ModelInfo, Prefs, Snapshot } from '../types'
5import { readConfig } from './config'
6import type { Config } from './config'
7import {
8  bar,
9  colorOf,
10  countdown,
11  formatCost,
12  formatPercent,
13  formatTokens,
14  isSameWindow,
15  labelOf,
16  levelOf,
17  nameOf,
18  prettyModel,
19  toSnapshot,
20} from './format'
21
22const snapAtom = atom({ plugin: 'usage-band', key: 'snap' } as const, null)
23const modelAtom = atom({ plugin: 'usage-band', key: 'model' } as const, {})
24const compactAtom = atom({ plugin: 'usage-band', key: 'compactAt' } as const, null)
25const prefsAtom = atom({ plugin: 'usage-band', key: 'prefs' } as const, { isHidden: false, isCompact: false })
26
27// $.store keys, kept across sessions.
28const PREFS_KEY = 'prefs'
29const ALERTED_KEY = 'alerted'
30
31type Alerted = Record<string, { level: number; resetsAt?: string }>
32
33type Part = { text: string; color?: string; dim?: boolean }
34type Layout = { barWidth: number; showReset: boolean; showCompact: boolean }
35
36const SEP: Part = { text: ' │ ', dim: true }
37
38/** Widest first; the band takes the first one that fits its columns. */
39const layoutsFor = (config: Config, prefs: Prefs): Layout[] => {
40  const widest = prefs.isCompact ? 0 : config.barWidth
41  return [
42    { barWidth: widest, showReset: config.showReset, showCompact: config.showCompact },
43    { barWidth: Math.min(widest, 5), showReset: config.showReset, showCompact: config.showCompact },
44    { barWidth: 0, showReset: config.showReset, showCompact: config.showCompact },
45    { barWidth: 0, showReset: false, showCompact: config.showCompact },
46    { barWidth: 0, showReset: false, showCompact: false },
47  ]
48}
49
50export const buildParts = (
51  snap: Snapshot | null,
52  model: ModelInfo,
53  compactAt: number | null,
54  now: number,
55  layout: Layout,
56  config: Config,
57): Part[] => {
58  const meter = (label: string, percent: number | undefined): Part[] => {
59    if (percent === undefined) return [{ text: `${label} —`, dim: true }]
60    const color = colorOf(percent, config.warnAt, config.dangerAt)
61    const parts: Part[] = [{ text: `${label} `, dim: true }]
62    if (layout.barWidth > 0) parts.push({ text: `${bar(percent, layout.barWidth)} `, color })
63    parts.push({ text: formatPercent(percent), color })
64    return parts
65  }
66
67  const groups: Part[][] = []
68
69  const effort = config.showEffort ? model.effort : undefined
70  if (config.showModel && model.model) {
71    const group: Part[] = [{ text: prettyModel(model.model), color: 'claude' }]
72    if (effort) group.push({ text: ` · ${effort}`, dim: true })
73    groups.push(group)
74  } else if (effort) {
75    groups.push([{ text: effort, dim: true }])
76  }
77
78  const ctx = meter('ctx', snap?.contextPercent)
79  if (layout.showCompact && compactAt !== null && snap?.contextTokens !== undefined) {
80    const left = compactAt - snap.contextTokens
81    ctx.push(
82      left > 0
83        ? { text: ` · compact'a ${formatTokens(left)}`, dim: true }
84        : { text: ' · compact yakın', color: 'warning' },
85    )
86  }
87  groups.push(ctx)
88
89  for (const limit of snap?.limits ?? []) {
90    const group = meter(labelOf(limit.kind), limit.percent)
91    const left = layout.showReset ? countdown(limit.resetsAt, now) : undefined
92    if (left) group.push({ text: ` ↻${left}`, dim: true })
93    groups.push(group)
94  }
95
96  if (config.showCost && snap?.costUsd !== undefined) groups.push([{ text: formatCost(snap.costUsd), dim: true }])
97
98  return groups.flatMap((group, i) => (i === 0 ? group : [SEP, ...group]))
99}
100
101const widthOf = (parts: Part[]) => parts.reduce((n, p) => n + p.text.length, 0)
102
103async function readAlerted($: EngineInterface): Promise<Alerted> {
104  const value = await $.store.get(ALERTED_KEY)
105  return value && typeof value === 'object' ? (value as Alerted) : {}
106}
107
108async function readPrefs($: EngineInterface): Promise<Prefs | undefined> {
109  const value = (await $.store.get(PREFS_KEY)) as Partial<Prefs> | undefined
110  if (!value || typeof value !== 'object') return undefined
111  return { isHidden: value.isHidden === true, isCompact: value.isCompact === true }
112}
113
114async function savePrefs($: EngineInterface, fn: (p: Prefs) => Prefs) {
115  await update($, prefsAtom, fn)
116  const prefs = await read($, prefsAtom)
117  await $.store.set(PREFS_KEY, prefs)
118  return prefs
119}
120
121// The model whose compaction threshold `compactAtom` holds.
122let compactFor: string | undefined
123let isCompactKnown = false
124
125async function refreshCompact($: EngineInterface, config: Config) {
126  if (!config.showCompact) return
127  const { model } = await read($, modelAtom)
128  if (isCompactKnown && compactFor === model) return
129  // `summary` estimates locally: no token-count requests.
130  const usage = await $.session.usage({ breakdown: 'summary' })
131  const breakdown = usage.context.breakdown
132  const at = breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null
133  await update($, compactAtom, () => at)
134  compactFor = model
135  isCompactKnown = breakdown !== undefined
136}
137
138async function alertLimits($: EngineInterface, limits: SessionRateLimit[], config: Config) {
139  const thresholds = [...new Set([config.toastAt, config.notifyAt])].sort((a, b) => a - b)
140  const alerted = await readAlerted($)
141  const next: Alerted = { ...alerted }
142  const now = await $.clock.now()
143  let isChanged = false
144
145  for (const limit of limits) {
146    const prev = alerted[limit.kind]
147    const isSame = prev !== undefined && isSameWindow(prev.resetsAt, limit.resetsAt)
148    const was = isSame ? prev.level : 0
149    const level = levelOf(limit.percentUsed, thresholds)
150    if (isSame && level === was) continue
151
152    next[limit.kind] = { level, resetsAt: limit.resetsAt }
153    isChanged = true
154    // Only rising past a threshold alerts; a new window or a drop re-arms it.
155    if (level <= was) continue
156
157    const left = countdown(limit.resetsAt, now)
158    const text = `${nameOf(limit.kind)} kullanım %${Math.round(limit.percentUsed)}${left ? ` · sıfırlanmaya ${left}` : ''}`
159    $.ui.toast(text, { timeoutMs: 8000 })
160    if (config.notify && level >= config.notifyAt && was < config.notifyAt) {
161      await $.ui.notify(text, { title: 'Claude kullanım limiti' })
162    }
163  }
164
165  if (isChanged) await $.store.set(ALERTED_KEY, next)
166}
167
168export const register: Register = (on, options) => {
169  const config = readConfig(options)
170  // A reload may bring new options: read the threshold afresh.
171  isCompactKnown = false
172
173  let tick: Timer | undefined
174
175  on('session.start', async ($, e, next) => {
176    const result = await next(e)
177
178    const prefs = await readPrefs($)
179    if (prefs) await update($, prefsAtom, () => prefs)
180
181    const model = await $.session.model()
182    await update($, modelAtom, m => (m.model ? m : { ...m, model }))
183
184    const usage = await $.session.usage()
185    await update($, snapAtom, () => toSnapshot(usage))
186    await refreshCompact($, config)
187
188    await $.command.register({
189      name: 'usage-band',
190      description: 'Kullanım bandını gizle/göster; "kompakt" ile çubukları aç/kapa',
191      argumentHint: '[kompakt]',
192    })
193
194    // Countdowns move while the session is idle: redraw once a minute.
195    tick?.cancel()
196    tick = $.clock.every(60_000, () => {
197      void $.ui.invalidate('ui.render')
198    })
199
200    return result
201  })
202
203  on('session.end', ($, e, next) => {
204    tick?.cancel()
205    tick = undefined
206    return next(e)
207  })
208
209  on('command.run', { command: 'usage-band' }, async ($, e) => {
210    const arg = e.args.trim().toLowerCase()
211    if (arg === 'kompakt' || arg === 'compact') {
212      const prefs = await savePrefs($, p => ({ isHidden: false, isCompact: !p.isCompact }))
213      return { text: prefs.isCompact ? 'Kullanım bandı kompakt modda.' : 'Kullanım bandı tam modda.' }
214    }
215    const prefs = await savePrefs($, p => ({ ...p, isHidden: !p.isHidden }))
216    return { text: prefs.isHidden ? 'Kullanım bandı gizlendi.' : 'Kullanım bandı gösteriliyor.' }
217  })
218
219  on('session.measure', async ($, e, next) => {
220    await update($, snapAtom, () => toSnapshot(e))
221    if (e.changed.includes('context')) await refreshCompact($, config)
222    if (e.changed.includes('rateLimits')) await alertLimits($, e.rateLimits, config)
223    return next(e)
224  })
225
226  // The model and effort the main loop actually sends, after /model or a fallback.
227  on('turn.step', async function* ($, e, next) {
228    if (!e.agentId) {
229      const effort = e.effort === undefined ? undefined : String(e.effort)
230      const current = await read($, modelAtom)
231      if (current.model !== e.model || current.effort !== effort) {
232        await update($, modelAtom, () => ({ model: e.model, effort }))
233      }
234    }
235    return yield* next(e)
236  })
237
238  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
239    if (e.props.hasSurvey) return next(e)
240
241    const prefs = await read($, prefsAtom)
242    if (prefs.isHidden) return next(e)
243
244    const snap = await read($, snapAtom)
245    const model = await read($, modelAtom)
246    if (!snap && !model.model) return next(e)
247
248    const compactAt = await read($, compactAtom)
249    const now = await $.clock.now()
250    const columns = e.props.bodyColumns
251    const candidates = layoutsFor(config, prefs).map(layout => buildParts(snap, model, compactAt, now, layout, config))
252    const parts = candidates.find(p => widthOf(p) <= columns) ?? candidates[candidates.length - 1] ?? []
253
254    const { Box, Text } = $.ui.resolve(e)
255
256    return (
257      <Box>
258        {parts.map((p, i) => (
259          <Text key={`p${i}`} color={p.color} dimColor={p.dim} wrap="truncate">
260            {p.text}
261          </Text>
262        ))}
263      </Box>
264    )
265  })
266}
267
hooks/config.ts 40 lines
1import type { PluginOptions } from 'claude-code'
2
3export type Config = {
4  toastAt: number
5  notifyAt: number
6  notify: boolean
7  warnAt: number
8  dangerAt: number
9  barWidth: number
10  showModel: boolean
11  showEffort: boolean
12  showCompact: boolean
13  showReset: boolean
14  showCost: boolean
15}
16
17export const DEFAULTS: Config = {
18  toastAt: 80,
19  notifyAt: 95,
20  notify: true,
21  warnAt: 70,
22  dangerAt: 90,
23  barWidth: 8,
24  showModel: true,
25  showEffort: true,
26  showCompact: true,
27  showReset: true,
28  showCost: true,
29}
30
31export const readConfig = (options: PluginOptions | undefined): Config => {
32  const config = { ...DEFAULTS }
33  for (const key of Object.keys(DEFAULTS) as (keyof Config)[]) {
34    const value = options?.[key]
35    if (typeof value === typeof DEFAULTS[key]) (config as Record<string, unknown>)[key] = value
36  }
37  config.barWidth = Math.max(0, Math.min(20, Math.round(config.barWidth)))
38  return config
39}
40
hooks/format.ts 86 lines
1import type { SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
2
3import type { Snapshot } from '../types'
4
5const LABELS: Record<string, string> = {
6  five_hour: '5h',
7  seven_day: '7d',
8  spend_limit: 'limit',
9}
10
11const NAMES: Record<string, string> = {
12  five_hour: '5 saatlik',
13  seven_day: 'Haftalık',
14  spend_limit: 'Harcama',
15}
16
17export const labelOf = (kind: string) => LABELS[kind] ?? kind
18export const nameOf = (kind: string) => NAMES[kind] ?? kind
19
20export const toSnapshot = (u: {
21  context: SessionContextUsage
22  rateLimits: SessionRateLimit[]
23  cost?: SessionCost
24}): Snapshot => ({
25  contextPercent: u.context.percent,
26  contextTokens: u.context.tokens,
27  contextWindow: u.context.window,
28  limits: u.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
29  costUsd: u.cost?.usd,
30})
31
32/** "claude-opus-5-5[1m]" → "Opus 5.5 1M", "sonnet" → "Sonnet". */
33export const prettyModel = (id: string) => {
34  const is1m = /\[1m\]/i.test(id)
35  const m = /(opus|sonnet|haiku|fable)(?:-(\d+)(?:-(\d{1,2}))?(?!\d))?/i.exec(id)
36  const family = m?.[1]
37  const base = family
38    ? [family.charAt(0).toUpperCase() + family.slice(1).toLowerCase(), m[2] && (m[3] ? `${m[2]}.${m[3]}` : m[2])]
39        .filter(Boolean)
40        .join(' ')
41    : id.replace(/^claude-/, '').replace(/\[1m\]/i, '')
42  return is1m ? `${base} 1M` : base
43}
44
45/** Kalan süre: "45d", "1s12d", "3g4s" (gün, saat, dakika). */
46export const countdown = (resetsAt: string | undefined, now: number) => {
47  if (!resetsAt) return undefined
48  const ms = Date.parse(resetsAt) - now
49  if (Number.isNaN(ms)) return undefined
50  const minutes = Math.max(0, Math.ceil(ms / 60_000))
51  if (minutes < 60) return `${minutes}d`
52  const hours = Math.floor(minutes / 60)
53  if (hours < 24) return `${hours}s${minutes % 60 ? `${minutes % 60}d` : ''}`
54  const days = Math.floor(hours / 24)
55  return `${days}g${hours % 24 ? `${hours % 24}s` : ''}`
56}
57
58export const colorOf = (percent: number, warnAt = 70, dangerAt = 90) =>
59  percent >= dangerAt ? 'error' : percent >= warnAt ? 'warning' : 'success'
60
61export const bar = (percent: number, width: number) => {
62  const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
63  return '▓'.repeat(filled) + '░'.repeat(width - filled)
64}
65
66export const formatPercent = (p: number) => `${Math.round(p)}%`
67
68export const formatTokens = (n: number) =>
69  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
70
71export const formatCost = (usd: number) => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
72
73/** The highest threshold `percent` has reached, 0 below all. */
74export const levelOf = (percent: number, thresholds: readonly number[]) =>
75  thresholds.reduce((level, t) => (percent >= t && t > level ? t : level), 0)
76
77/**
78 * Whether two readings name the same rate-limit window: the reported reset
79 * time can drift a little between responses, a new window moves it by hours.
80 */
81export const isSameWindow = (a: string | undefined, b: string | undefined) => {
82  if (a === b) return true
83  if (!a || !b) return false
84  return Math.abs(Date.parse(a) - Date.parse(b)) < 15 * 60_000
85}
86
types/index.d.ts 27 lines
1export type Limit = { kind: string; percent: number; resetsAt?: string }
2
3export type Snapshot = {
4  contextPercent?: number
5  contextTokens?: number
6  contextWindow: number
7  limits: Limit[]
8  costUsd?: number
9}
10
11export type ModelInfo = { model?: string; effort?: string }
12
13/** The person's view choices from /usage-band, kept across sessions too. */
14export type Prefs = { isHidden: boolean; isCompact: boolean }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'usage-band': {
19      snap: Snapshot | null
20      model: ModelInfo
21      /** Token count auto-compaction runs at; null when off or unknown. */
22      compactAt: number | null
23      prefs: Prefs
24    }
25  }
26}
27