SLOPSHOPPER

usage-band

輸入框上方顯示 5h/7d 額度、重置倒數、token 與花費

newbandtimer
v0.3.2no licenseupdated 2026-10-09mikekuo0725/usage-band/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 ⟨Claude Code's own drawing⟩ ☁ Cloudy 49% of context 97.4k / 200.0k last turns ██ steady 5h ██░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ☁ Cloudy 49% of context 97.4k / 200.0k last turns ██ steady 5h ██░░░░░░ 31%
README

usage-band

A collection of Claude Code plugins (mods) that add live status displays to the interface.

usage-band

Displays session usage above the prompt: 5-hour and 7-day rate-limit consumption with reset countdowns, token counts, and session cost.

usage-band

  • Context line: context window utilization, with a trend chart of recent turns
  • 5h / 7d: rate-limit utilization and time remaining until reset
  • ↑ / ↓: input and output tokens for the current session
  • ≈: cached tokens
  • $: accumulated cost for the current session

pixel-helpers

When you send out helpers (subagents), a pixel office opens in the side pane. Each helper sits at a desk and randomly types, talks on the phone, rushes to the copier, drinks coffee, or thinks. Finished helpers raise their arms. Interrupted helpers turn grey and fall asleep at their desk.

pixel-helpers

  • Pane: opens by itself when a helper starts, or with /helpers
  • Above the prompt: while helpers work, a line shows how many are running and their names
  • Status: running helpers show time worked and tools used; finished ones show the total time

Install

claude plugin marketplace add mikekuo0725/usage-band
claude plugin install usage-band@usage-band
claude plugin install pixel-helpers@usage-band

usage-band(中文)

本專案收錄 Claude Code 外掛(mod),為介面加入即時狀態顯示。

usage-band

於輸入框上方即時顯示本次工作階段的用量資訊,包括 5 小時與 7 天額度使用率、重置倒數、token 用量及花費。

usage-band

  • 上下文列:上下文視窗使用率,並以趨勢圖呈現最近幾輪的變化
  • 5h / 7d:額度使用率及距離重置的剩餘時間
  • ↑ / ↓:本次工作階段的輸入與輸出 token 數
  • ≈:快取 token 數
  • $:本次工作階段的累計花費

pixel-helpers

派小幫手時,右邊面板會顯示一間像素辦公室。每位小幫手坐在自己的辦公桌前,隨機打字、講電話、衝去影印、喝咖啡或想事情。做完的小幫手會舉起雙手;被打斷的會變灰、趴在桌上睡著。

pixel-helpers

  • 面板:小幫手出發時自動打開,或用 /helpers 打開
  • 輸入框上方:有小幫手在工作時,會顯示幾位在工作中和他們的名字
  • 狀態:工作中的顯示已工作時間和工具數;做完的顯示總共花多久;被中斷的顯示做了多久

安裝

claude plugin marketplace add mikekuo0725/usage-band
claude plugin install usage-band@usage-band
claude plugin install pixel-helpers@usage-band
Source 5 files
hooks/register.tsx 168 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Usage } from '../types'
5import { barCells, formatCount, formatCountdown, formatUsd, hasReset } from './format'
6import { addTurn } from './tokens'
7import { addReading, chart, forecastFor, trendWord } from './weather'
8
9const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
10const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
11const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
12const readings = atom({ plugin: 'usage-band', key: 'readings' } as const, [])
13
14const BAR_WIDTH = 8
15
16const COLORS = {
17  fiveHour: { fg: '#3f7d58', bg: '#dcebe0' },
18  sevenDay: { fg: '#6b4fa8', bg: '#e6def5' },
19  input: { fg: '#b4483c', bg: '#f5dcd8' },
20  output: { fg: '#3f7d58', bg: '#dcebe0' },
21  cache: { fg: '#4a5fb0', bg: '#dde2f5' },
22  cost: { fg: '#8a6a1f', bg: '#f3e7c8' },
23  stale: { fg: '#6b6b6b', bg: '#e6e6e6' },
24} as const
25
26type Tone = (typeof COLORS)[keyof typeof COLORS]
27
28export const register: Register = on => {
29  on('session.start', async ($, e, next) => {
30    const u = await $.session.usage()
31    await update($, usage, () => toUsage(u.rateLimits, u.cost?.usd))
32    const ms = await $.clock.now()
33    await update($, now, () => ms)
34    await takeReading($)
35    $.clock.every(60_000, () => {
36      void $.clock.now().then(ms => update($, now, () => ms))
37    })
38    return next(e)
39  })
40
41  on('session.measure', async ($, e, next) => {
42    await update($, usage, () => toUsage(e.rateLimits, e.cost?.usd))
43    const ms = await $.clock.now()
44    await update($, now, () => ms)
45    return next(e)
46  })
47
48  on('session.end', async ($, e, next) => {
49    if (e.reason === 'clear') {
50      await update($, tokens, () => null)
51      await update($, readings, () => [])
52    }
53    return next(e)
54  })
55
56  on('turn.complete', async ($, e, next) => {
57    const result = await next(e)
58    const u = result.usage
59    if (u) {
60      // /clear 不會觸發 session.start,所以每輪都比對 startedAt 決定要不要歸零
61      const { startedAt } = await $.session.usage()
62      await update($, tokens, t => addTurn(t, startedAt, u))
63    }
64    // 記憶空間只看主對話,不看子代理
65    if (!e.agentId) await takeReading($)
66    return result
67  })
68
69  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
70    if (e.props.hasSurvey) return next(e)
71
72    const u = await read($, usage)
73    const t = await read($, tokens)
74    const nowMs = await read($, now)
75    const list = await read($, readings)
76    const { Box, Text } = $.ui.resolve(e)
77
78    const pill = (key: string, tone: Tone, children: JSX.Element) => (
79      <Box key={key} backgroundColor={tone.bg} paddingX={1} marginRight={1}>
80        {children}
81      </Box>
82    )
83
84    const limitPill = (limit: SessionRateLimit, label: string, tone: Tone) => {
85      if (hasReset(limit.resetsAt, nowMs)) {
86        const grey = COLORS.stale
87        return pill(
88          label,
89          grey,
90          <Text color={grey.fg}>
91            {label} <Text dimColor>{'░'.repeat(BAR_WIDTH)}</Text> 已重置
92          </Text>,
93        )
94      }
95      const filled = barCells(limit.percentUsed, BAR_WIDTH)
96      const countdown = formatCountdown(limit.resetsAt, nowMs)
97      return pill(
98        label,
99        tone,
100        <Text color={tone.fg}>
101          {label} {'█'.repeat(filled)}
102          <Text dimColor>{'░'.repeat(BAR_WIDTH - filled)}</Text>{' '}
103          <Text bold>{Math.round(limit.percentUsed)}%</Text>
104          {countdown ? ` │ ↻ ${countdown}` : ''}
105        </Text>,
106      )
107    }
108
109    const pills: JSX.Element[] = []
110    const five = u?.rateLimits.find(r => r.kind === 'five_hour')
111    const seven = u?.rateLimits.find(r => r.kind === 'seven_day')
112    if (five) pills.push(limitPill(five, '5h', COLORS.fiveHour))
113    if (seven) pills.push(limitPill(seven, '7d', COLORS.sevenDay))
114    if (t && t.input + t.output + t.cache > 0) {
115      pills.push(pill('in', COLORS.input, <Text color={COLORS.input.fg}>↑ {formatCount(t.input)}</Text>))
116      pills.push(pill('out', COLORS.output, <Text color={COLORS.output.fg}>↓ {formatCount(t.output)}</Text>))
117      pills.push(pill('cache', COLORS.cache, <Text color={COLORS.cache.fg}>≋ {formatCount(t.cache)}</Text>))
118    }
119    if (u?.costUsd != null) {
120      pills.push(pill('cost', COLORS.cost, <Text color={COLORS.cost.fg}>{formatUsd(u.costUsd)}</Text>))
121    }
122
123    const rows: JSX.Element[] = []
124    const latest = list[list.length - 1]
125    if (latest) {
126      const f = forecastFor(latest.percent)
127      const trend = trendWord(list)
128      const wide = (e.props.bodyColumns ?? 80) >= 60
129      rows.push(
130        <Box key="weather" flexDirection="row" paddingX={1}>
131          <Text color={f.color} bold>{`${f.icon}  ${f.word}`}</Text>
132          <Text>{`  ${latest.percent}% of context`}</Text>
133          <Text dimColor>{`  ${formatCount(latest.tokens)} / ${formatCount(latest.window)}`}</Text>
134          {wide ? <Text dimColor>{'   last turns '}</Text> : ''}
135          {wide ? <Text color={f.color}>{chart(list)}</Text> : ''}
136          {wide && trend ? <Text dimColor>{`  ${trend}`}</Text> : ''}
137        </Box>,
138      )
139    }
140    if (pills.length > 0) {
141      rows.push(<Box key="usage" flexDirection="row" flexWrap="wrap">{pills}</Box>)
142    }
143
144    // 其他 mod 畫的東西(如果有)放最上面
145    const above = await next(e)
146    if (rows.length === 0) return above
147    return (
148      <Box flexDirection="column">
149        {above ?? ''}
150        {rows}
151      </Box>
152    )
153  })
154}
155
156// 讀一次記憶空間用量,加進天氣歷史
157async function takeReading($: EngineInterface) {
158  const { context } = await $.session.usage()
159  if (!context.window) return
160  const used = context.tokens ?? 0
161  const percent = Math.round(context.percent ?? (used / context.window) * 100)
162  await update($, readings, list => addReading(list, { tokens: used, window: context.window, percent }))
163}
164
165function toUsage(rateLimits: SessionRateLimit[], costUsd: number | undefined): Usage {
166  return { rateLimits, costUsd: costUsd ?? null }
167}
168
hooks/format.ts 41 lines
1const MIN = 60_000
2const HOUR = 60 * MIN
3const DAY = 24 * HOUR
4
5// 15600 → "15.6k";先四捨五入到一位小數再決定單位,避免出現 "1000.0k"
6export function formatCount(n: number): string {
7  if (n < 1000) return String(Math.round(n))
8  const k = Math.round(n / 100) / 10
9  if (k < 1000) return `${k.toFixed(1)}k`
10  return `${(Math.round(n / 100_000) / 10).toFixed(1)}M`
11}
12
13// 離 resetsAt 還有多久;沒時間或時間壞掉回空字串
14export function formatCountdown(resetsAt: string | undefined, nowMs: number): string {
15  if (!resetsAt) return ''
16  const at = Date.parse(resetsAt)
17  if (Number.isNaN(at)) return ''
18  const left = at - nowMs
19  if (left < MIN) return '<1m'
20  if (left >= DAY) return `${Math.floor(left / DAY)}d ${Math.floor((left % DAY) / HOUR)}h`
21  if (left >= HOUR) return `${Math.floor(left / HOUR)}h ${Math.floor((left % HOUR) / MIN)}m`
22  return `${Math.floor(left / MIN)}m`
23}
24
25export function formatUsd(usd: number): string {
26  return `$${usd.toFixed(2)}`
27}
28
29// 進度條要填幾格,夾在 0..width
30export function barCells(percent: number, width: number): number {
31  const cells = Math.round((percent / 100) * width)
32  return Math.max(0, Math.min(width, cells))
33}
34
35// 重置時間已經過了:手上的 % 是舊的,要等下一次回話才有新數字
36export function hasReset(resetsAt: string | undefined, nowMs: number): boolean {
37  if (!resetsAt) return false
38  const at = Date.parse(resetsAt)
39  return !Number.isNaN(at) && at <= nowMs
40}
41
hooks/tokens.ts 15 lines
1import type { ModelUsage } from 'claude-code'
2
3import type { Tokens } from '../types'
4
5// 把一輪的用量加進總數;startedAt 不同代表 /clear 過,從 0 重新算
6export function addTurn(t: Tokens | null, startedAt: number, u: ModelUsage): Tokens {
7  const base = t && t.startedAt === startedAt ? t : { startedAt, input: 0, output: 0, cache: 0 }
8  return {
9    startedAt,
10    input: base.input + u.input_tokens,
11    output: base.output + u.output_tokens,
12    cache: base.cache + u.cache_read_input_tokens + u.cache_creation_input_tokens,
13  }
14}
15
hooks/weather.ts 44 lines
1// 「記憶空間天氣」:改寫自 Anthropic 的 token-weather 範例 mod
2// (Copyright 2026 Anthropic PBC, Apache-2.0)
3
4import type { Reading } from '../types'
5import { formatCount } from './format'
6
7const HISTORY = 12
8const BARS = '▁▂▃▄▅▆▇█'
9
10// 依記憶空間用掉幾 % 決定天氣
11const FORECAST = [
12  { upTo: 25, icon: '☀', word: 'Clear', color: 'yellow' },
13  { upTo: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
14  { upTo: 75, icon: '☂', word: 'Showers', color: 'blue' },
15  { upTo: 90, icon: '☇', word: 'Storm', color: 'magenta' },
16  { upTo: Infinity, icon: '↯', word: 'Compact soon', color: 'red' },
17] as const
18
19export type Forecast = (typeof FORECAST)[number]
20
21export function forecastFor(percent: number): Forecast {
22  return FORECAST.find(f => percent < f.upTo) ?? FORECAST[FORECAST.length - 1]!
23}
24
25// 加一筆讀數;有真的讀數後丟掉一開始的 0,只留最後 12 筆
26export function addReading(list: Reading[], next: Reading): Reading[] {
27  const kept = list.filter(r => r.tokens > 0)
28  return [...kept, next].slice(-HISTORY)
29}
30
31// 長條依最大那筆縮放,用多少都看得出變化
32export function chart(list: Reading[]): string {
33  const top = Math.max(...list.map(r => r.tokens), 1)
34  return list.map(r => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]).join('')
35}
36
37export function trendWord(list: Reading[]): string {
38  if (list.length < 2) return ''
39  const delta = list[list.length - 1]!.tokens - list[list.length - 2]!.tokens
40  if (delta > 0) return `▲ +${formatCount(delta)} last turn`
41  if (delta < 0) return `▼ ${formatCount(-delta)} last turn`
42  return 'steady'
43}
44
types/index.d.ts 11 lines
1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Tokens = { startedAt: number; input: number; output: number; cache: number }
3export type Usage = { rateLimits: RateLimit[]; costUsd: number | null }
4export type Reading = { tokens: number; window: number; percent: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'usage-band': { tokens: Tokens | null; usage: Usage | null; now: number; readings: Reading[] }
9  }
10}
11