SLOPSHOPPER

context-bar

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

newbandcommandtimer
v0.2.3no licenseupdated 2026-10-06kesuuyof/claude-code-bars/plugins/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 › /context-bar ⎿ context-bar: Context bar 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 124 lines
1import { 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}
124
hooks/bar.ts 24 lines
1import 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}
24
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 25 lines
1/** 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