SLOPSHOPPER

usage-bar

Bars above the prompt for each plan usage window, with time to reset and a pace warning.

newbandcommandtimer
v0.2.3no licenseupdated 2026-10-06kesuuyof/claude-code-bars/plugins/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
› 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-bar ⎿ usage-bar: Usage bars hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-bars

Claude Code の mod(function hooks のプラグイン)を集めた marketplace。

claude plugin marketplace add kesuuyof/claude-code-bars
claude plugin install context-bar@claude-code-bars
claude plugin install usage-bar@claude-code-bars

両方を有効にすると、プロンプト上部の帯に context-bar が上、usage-bar が下の順で並ぶ。この順はプラグインの読み込み順によらない。どのバーも3列目から始まり、同じ帯の上では同じ長さになる。

バーは全マスを █ 1文字で描き、色で区別する。使用中の部分はカテゴリの色(usage-bar は使用率に応じた色)、空きとバッファは同じ色を薄く描く。░ や ▒ を混ぜると、フォントによって文字幅が違い、バーの長さがずれるため。バーの前のラベル欄(5h や空欄)も、空白ではなく幅3マスの Box で作る。等幅でないフォントでは、空白と 5h の幅が違うため。

Context 45k / 200k (23%) · auto-compact at 167k
   ████████████████████████████████████████
   █ System prompt 3k  █ System tools 12k  █ Messages 30k  █ Free space 122k  █ Autocompact buffer 33k
5h ████████████████████████████████████████ 60% · resets in 3h30m
   ⚠ ahead of pace: 60% used, 30% of the window elapsed
7d ████████████████████████████████████████ 18% · resets in 6d0h

バーの長さ

バーの長さは既定で最大 40 セルで、帯が狭いときは、右側に usage-bar の「60% · resets in …」の分(24セル)を残して縮む。context-bar もこの余白を同じだけ取るので、両方のバーの長さはそろう。バーが2行に折り返す場合(フォントによっては、█ が1セルより広く描かれる)は、各プラグインの barWidth 設定を小さくする。2つのプラグインに同じ値を入れないと、バーの長さがそろわない。/config の一覧から変えるか、次のように設定する。

echo '{"barWidth":"30"}' | claude plugin configure context-bar@claude-code-bars --values-stdin

context-bar

コンテキストウィンドウの使用状況を、/context と同じカテゴリと色で1本の積み上げバーにする。

  • ヘッダーに「使用トークン / ウィンドウ(使用率)」と auto-compact が走るトークン数
  • 使用率が 50% 以上で黄、80% 以上で赤
  • /context-bar で表示・非表示を切り替える。選択はセッションをまたいで保持される

値は $.session.usage({ breakdown: 'summary' }) をそのまま使う。summary はエンジンがローカルで推定した値で、通信は発生しない。そのため、token-count API で数える /context の値とは一致しないことがある。

usage-bar

プランの使用制限の枠($.session.usage().rateLimits が返すもの。現状は 5 時間枠と週次枠)を、1枠1本のバーにする。

  • 各バーに使用率と、枠のリセットまでの残り時間(1分ごとに更新)
  • 使用率が 50% 以上で黄、80% 以上で赤
  • 使用率が枠の経過率を 20 ポイント以上上回ると、行の下に警告を出す
  • /usage-bar で表示・非表示を切り替える。選択はセッションをまたいで保持される
  • 経過率の求め方: 枠の長さは API にないため、名前(five_hour → 5時間、seven_day → 7日)から決める。長さが名前から分からない枠(spend_limit など)は、警告の対象外。
  • 表示されないとき: 枠の値は API の応答と一緒に届くため、セッションの最初の応答までは何も表示しない。サブスクリプション以外(API キー)では枠がないので、何も表示しない。

開発

各 mod は plugins/<name>/ に独立して置く。新しい mod は同じ構成で作り、.claude-plugin/marketplace.json の plugins に1行足す。

claude plugin validate plugins/usage-bar
claude plugin test plugins/usage-bar
claude --plugin-dir plugins/context-bar --plugin-dir plugins/usage-bar

型定義(.claude-plugin/types/)は、エンジンが mod を読み込むたびに生成する。一度 --plugin-dir で起動した後なら、tsc -p plugins/<name> で型チェックできる。

共通コード

hooks/shared.ts(色の閾値、バーの区画配分)とそのテスト tests/shared.test.ts は、各プラグインに同じ内容で置く。プラグインはそれぞれ単独でインストールされ、プラグインのフォルダだけがコピーされるので、ほかのプラグインのファイルを import できないため。片方を直したら、もう片方にもコピーする。

diff plugins/context-bar/hooks/shared.ts plugins/usage-bar/hooks/shared.ts

帯の積み重ね

AbovePrompt の帯に描けるツリーは1つだけ。各 mod は next(e) で下の mod が描いたものを受け取り、自分の行と一緒に返す。context-bar は自分を上に、usage-bar は自分を下に置く。新しい mod を足すときは、どちらに並べたいかで置く側を決める。

Source 4 files
hooks/register.tsx 111 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import { toRow } from './limits'
5import { BAR_INDENT, CELL, allocate, barWidth, levelColor } from './shared'
6
7const limits = atom({ plugin: 'usage-bar', key: 'limits' } as const, [])
8const clock = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
9const isHidden = atom({ plugin: 'usage-bar', key: 'isHidden' } as const, false)
10
11/** The countdowns move a minute at a time. */
12const TICK_MS = 60_000
13
14async function save($: EngineInterface, rateLimits: readonly SessionRateLimit[]) {
15  const now = await $.clock.now()
16  await update($, limits, () => [...rateLimits])
17  await update($, clock, () => now)
18}
19
20// The store outlives sessions; $.state is what the drawing subscribes to.
21async function sync($: EngineInterface) {
22  const hidden = (await $.store.get('isHidden')) === true
23  await update($, isHidden, () => hidden)
24  await save($, (await $.session.usage()).rateLimits)
25}
26
27export const register: Register = (on, options) => {
28  const cap = Number(options.barWidth)
29
30  on('session.start', async ($, e, next) => {
31    await $.command.register({
32      name: 'usage-bar',
33      description: 'Show or hide the plan usage bars above the prompt',
34    })
35    await sync($)
36    $.clock.every(TICK_MS, async () => {
37      const now = await $.clock.now()
38      await update($, clock, () => now)
39    })
40    return next(e)
41  })
42
43  on('command.run', { command: 'usage-bar' }, async $ => {
44    const hidden = await update($, isHidden, h => !h)
45    await $.store.set('isHidden', hidden)
46    return { text: hidden ? 'Usage bars hidden.' : 'Usage bars shown.' }
47  })
48
49  // The windows arrive with each response's headers: after a turn, and when one moves a point.
50  on('session.measure', async ($, e, next) => {
51    await save($, e.rateLimits)
52    return next(e)
53  })
54
55  // /clear raises no session.start, only this.
56  on('classic.SessionStart', async ($, e, next) => {
57    if (e.source === 'clear') await sync($)
58    return next(e)
59  })
60
61  // Draws below what the plugins beneath drew (context-bar draws above), so the
62  // two stack the same way whichever of them loads first.
63  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64    const above = await next(e)
65    const list = await read($, limits)
66    if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return above
67
68    const { Box, Text } = $.ui.resolve(e)
69    const now = await read($, clock)
70    const rows = list.map(l => toRow(l, now))
71    // The label sits in a box of fixed width, so the bar starts in the same column
72    // as context-bar's whatever font draws the label. A label longer than the
73    // grid's indent ("7d opus") widens this band's box and pushes its bars right.
74    const indent = Math.max(BAR_INDENT, ...rows.map(r => r.label.length + 1))
75    const width = barWidth(cap, e.props.bodyColumns)
76
77    return (
78      <Box flexDirection="column">
79        {above}
80        {rows.flatMap(r => {
81          const [filled = 0, empty = 0] = allocate([Math.min(r.used, 100), Math.max(0, 100 - r.used)], width)
82          const level = levelColor(r.used)
83          const tone = level ? { color: level } : {}
84          const row = (
85            <Box flexDirection="row">
86              <Box width={indent} flexShrink={0}>
87                <Text>{r.label}</Text>
88              </Box>
89              <Text wrap="truncate-end">
90                <Text {...tone}>{CELL.repeat(filled)}</Text>
91                <Text dimColor>{CELL.repeat(empty)}</Text>
92                <Text {...tone}>{r.tail}</Text>
93              </Text>
94            </Box>
95          )
96          if (r.warning === undefined) return [row]
97          return [
98            row,
99            <Box flexDirection="row">
100              <Box width={indent} flexShrink={0} />
101              <Text color="warning" wrap="truncate-end">
102                {r.warning}
103              </Text>
104            </Box>,
105          ]
106        })}
107      </Box>
108    )
109  })
110}
111
hooks/limits.ts 56 lines
1import type { Limit } from '../types'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6
7/** How many percentage points use may run ahead of the time elapsed before a row warns. */
8export const PACE_MARGIN = 20
9
10/** `five_hour` → "5h", `seven_day_opus` → "7d opus", `spend_limit` → "spend limit" */
11export function label(kind: string): string {
12  return kind.replace(/^five_hour/, '5h').replace(/^seven_day/, '7d').replace(/_/g, ' ')
13}
14
15/** The window's length as its name gives it; undefined where the name does not (a spend limit). */
16export function windowMs(kind: string): number | undefined {
17  return kind.startsWith('five_hour') ? 5 * HOUR : kind.startsWith('seven_day') ? 7 * DAY : undefined
18}
19
20/**
21 * How much of the window has passed, 0–100 to one decimal as `percentUsed` is;
22 * undefined when its length or reset time is unknown.
23 */
24export function elapsedPercent(limit: Limit, now: number): number | undefined {
25  const span = windowMs(limit.kind)
26  if (span === undefined || limit.resetsAt === undefined) return undefined
27  const passed = span - (Date.parse(limit.resetsAt) - now)
28  return Math.min(100, Math.max(0, Math.round((1000 * passed) / span) / 10))
29}
30
31/** 45s → "<1m", "13m", "2h05m", "4d6h" */
32export function fmtDuration(ms: number): string {
33  if (ms < MINUTE) return '<1m'
34  const m = Math.floor(ms / MINUTE)
35  if (m < 60) return `${m}m`
36  const h = Math.floor(m / 60)
37  if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
38  return `${Math.floor(h / 24)}d${h % 24}h`
39}
40
41export type Row = { label: string; used: number; tail: string; warning?: string }
42
43export function toRow(limit: Limit, now: number): Row {
44  const elapsed = elapsedPercent(limit, now)
45  const resets = limit.resetsAt === undefined ? '' : ` · resets in ${fmtDuration(Date.parse(limit.resetsAt) - now)}`
46  const isAhead = elapsed !== undefined && limit.percentUsed - elapsed >= PACE_MARGIN
47  return {
48    label: label(limit.kind),
49    used: limit.percentUsed,
50    tail: ` ${limit.percentUsed}%${resets}`,
51    ...(isAhead && {
52      warning: `⚠ ahead of pace: ${limit.percentUsed}% used, ${Math.round(elapsed)}% of the window elapsed`,
53    }),
54  }
55}
56
hooks/shared.ts 41 lines
1// Kept identical in every plugin of claude-code-bars: a plugin installs on its
2// own, so none can import another's files. Edit one, copy it to the others.
3
4/** Splits `width` cells by share of tokens (largest remainder), summing to `width` exactly. */
5export function allocate(tokens: readonly number[], width: number): number[] {
6  const total = tokens.reduce((a, b) => a + b, 0)
7  if (total <= 0 || width <= 0) return tokens.map(() => 0)
8  const parts = tokens.map(t => {
9    const exact = (t / total) * width
10    return { cells: Math.floor(exact), rest: exact - Math.floor(exact) }
11  })
12  const left = width - parts.reduce((a, p) => a + p.cells, 0)
13  for (const p of [...parts].sort((a, b) => b.rest - a.rest).slice(0, left)) p.cells += 1
14  return parts.map(p => p.cells)
15}
16
17export function levelColor(percent: number): string | undefined {
18  return percent >= 80 ? 'error' : percent >= 50 ? 'warning' : undefined
19}
20
21/**
22 * The one glyph every cell of every bar is drawn with; full and empty cells differ
23 * by color alone. A font that draws ░ or ▒ at another width than █ would otherwise
24 * make bars of the same cell count differ in length.
25 */
26export const CELL = '█'
27
28/**
29 * Every bar in the band sits on one grid, so bars stacked from different plugins
30 * line up: it starts BAR_INDENT cells in (after a label such as usage-bar's "5h "),
31 * and TAIL cells are kept right of it (for a tail such as " 60% · resets in 3h30m")
32 * plus one, so a row never reaches the band's last column.
33 */
34export const BAR_INDENT = 3
35const TAIL = 24
36
37/** Cells for a bar: at most `cap` (the plugin's barWidth option), the same for every plugin on a band. */
38export function barWidth(cap: number, bodyColumns: number): number {
39  return Math.max(0, Math.min(cap, bodyColumns - BAR_INDENT - TAIL - 1))
40}
41
types/index.d.ts 16 lines
1/** One plan usage window, as `$.session.usage().rateLimits` reports it. */
2export type Limit = {
3  /** `five_hour`, `seven_day`, a gateway's `spend_limit`, ... */
4  kind: string
5  /** 0 to 100, past 100 on an exceeded spend limit. */
6  percentUsed: number
7  /** ISO 8601. */
8  resetsAt?: string
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'usage-bar': { limits: Limit[]; now: number; isHidden: boolean }
14  }
15}
16