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

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 と同じカテゴリと色で1本の積み上げバーにする。
/context-bar で表示・非表示を切り替える。選択はセッションをまたいで保持される値は $.session.usage({ breakdown: 'summary' }) をそのまま使う。summary はエンジンがローカルで推定した値で、通信は発生しない。そのため、token-count API で数える /context の値とは一致しないことがある。
プランの使用制限の枠($.session.usage().rateLimits が返すもの。現状は 5 時間枠と週次枠)を、1枠1本のバーにする。
/usage-bar で表示・非表示を切り替える。選択はセッションをまたいで保持されるfive_hour → 5時間、seven_day → 7日)から決める。長さが名前から分からない枠(spend_limit など)は、警告の対象外。各 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 を足すときは、どちらに並べたいかで置く側を決める。
hooks/register.tsx 111 lines1import { 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}
111hooks/limits.ts 56 lines1import 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}
56hooks/shared.ts 41 lines1// 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}
41types/index.d.ts 16 lines1/** 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