A stacked bar above the prompt showing the context window by /context category.

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 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Segment } from '../types'
5import { fmt, toSnapshot } from './bar'
6import { BAR_INDENT, CELL, allocate, barWidth, levelColor } from './shared'
7
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
10
11// Free space and the compaction buffer share their colors with used rows
12// (promptBorder, inactive), so they are told apart by being dimmed.
13const tone = (s: Segment) => ({ color: s.color, ...(s.kind !== 'used' && { dimColor: true }) })
14
15const SETTLE_MS = 300
16let pending: Timer | undefined
17
18// 'summary' is the engine's local count; 'full' would send token-count requests.
19async function refresh($: EngineInterface) {
20 const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
21 if (breakdown) await update($, snapshot, () => toSnapshot(breakdown))
22}
23
24// The store outlives sessions; $.state is what the drawing subscribes to.
25async function sync($: EngineInterface) {
26 const hidden = (await $.store.get('isHidden')) === true
27 await update($, isHidden, () => hidden)
28 await refresh($)
29}
30
31export const register: Register = (on, options) => {
32 const cap = Number(options.barWidth)
33
34 on('session.start', async ($, e, next) => {
35 await $.command.register({
36 name: 'context-bar',
37 description: 'Show or hide the context bar above the prompt',
38 })
39 await sync($)
40 return next(e)
41 })
42
43 on('command.run', { command: 'context-bar' }, async $ => {
44 const hidden = await update($, isHidden, h => !h)
45 await $.store.set('isHidden', hidden)
46 return { text: hidden ? 'Context bar hidden.' : 'Context bar shown.' }
47 })
48
49 // Every row the main conversation keeps: responses and tool results mid-turn, and
50 // the rows a compaction rebuilds the conversation from. The compaction events
51 // themselves still see the old conversation, so the refresh waits for the rows to settle.
52 // Nothing runs after next, so the row is stored as the engine answers it.
53 on('session.append', ($, e, next) => {
54 if (e.agentId === undefined) {
55 pending?.cancel()
56 pending = $.clock.after(SETTLE_MS, () => void refresh($))
57 }
58 return next(e)
59 })
60
61 on('session.measure', async ($, e, next) => {
62 await refresh($)
63 return next(e)
64 })
65
66 // /clear raises no session.start, only this, with the conversation already cleared.
67 on('classic.SessionStart', async ($, e, next) => {
68 if (e.source === 'clear') await sync($)
69 return next(e)
70 })
71
72 // Draws above what the plugins beneath drew (usage-bar draws below), so the
73 // two stack the same way whichever of them loads first.
74 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75 const below = await next(e)
76 const snap = await read($, snapshot)
77 if (e.props.hasSurvey || snap === null || (await read($, isHidden))) return below
78
79 const { Box, Text } = $.ui.resolve(e)
80 const cells = allocate(
81 snap.segments.map(s => s.tokens),
82 barWidth(cap, e.props.bodyColumns),
83 )
84 const level = levelColor(snap.percent)
85 // A box of fixed width, not spaces: in a proportional font spaces are narrower
86 // than usage-bar's "5h " label, and the bars would not start in one column.
87 const indent = <Box width={BAR_INDENT} flexShrink={0} />
88
89 return (
90 <Box flexDirection="column">
91 <Text wrap="truncate-end">
92 <Text bold>Context </Text>
93 <Text {...(level && { color: level })}>
94 {fmt(snap.used)} / {fmt(snap.window)} ({snap.percent}%)
95 </Text>
96 {snap.autoCompactAt === null ? '' : ` · auto-compact at ${fmt(snap.autoCompactAt)}`}
97 </Text>
98 <Box flexDirection="row">
99 {indent}
100 <Text wrap="truncate-end">
101 {snap.segments.map((s, i) => {
102 const n = cells[i] ?? 0
103 return n > 0 ? <Text {...tone(s)}>{CELL.repeat(n)}</Text> : ''
104 })}
105 </Text>
106 </Box>
107 <Box flexDirection="row">
108 {indent}
109 <Text wrap="truncate-end">
110 {snap.segments
111 .filter(s => s.tokens > 0)
112 .map(s => (
113 <Text>
114 <Text {...tone(s)}>{CELL}</Text> {s.name} {fmt(s.tokens)}{' '}
115 </Text>
116 ))}
117 </Text>
118 </Box>
119 {below}
120 </Box>
121 )
122 })
123}
124hooks/bar.ts 24 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { Snapshot } from '../types'
4
5/** Keeps the rows that take room in the window, as /context's grid does. */
6export function toSnapshot(b: SessionContextBreakdown): Snapshot {
7 return {
8 segments: b.categories.flatMap(c =>
9 c.kind === 'deferred' ? [] : [{ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }],
10 ),
11 used: b.totalTokens,
12 window: b.rawMaxTokens,
13 percent: b.percentage,
14 autoCompactAt: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
15 }
16}
17
18/** 950 → "950", 45231 → "45.2k", 200000 → "200k", 1000000 → "1M" */
19export function fmt(n: number): string {
20 if (n < 1000) return String(Math.round(n))
21 const [v, unit] = n < 1e6 ? [n / 1e3, 'k'] : [n / 1e6, 'M']
22 return `${Number(v.toFixed(1))}${unit}`
23}
24hooks/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 25 lines1/** One row of /context's breakdown that takes room in the window. */
2export type Segment = {
3 name: string
4 tokens: number
5 /** Theme key, as /context draws the row. */
6 color: string
7 kind: 'used' | 'free' | 'buffer'
8}
9
10/** What the band draws, taken from `$.session.usage({ breakdown })`. */
11export type Snapshot = {
12 segments: Segment[]
13 used: number
14 window: number
15 percent: number
16 /** Token count where auto-compact runs; null when it is off. */
17 autoCompactAt: number | null
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'context-bar': { snapshot: Snapshot | null; isHidden: boolean }
23 }
24}
25