SLOPSHOPPER

status-meter

Shows context, 5-hour and 7-day limit usage as SVG meters above the prompt in the Desktop app

newbandcommandtimer
v0.1.2no licenseupdated 2026-10-02s-hiraoku/claude-mods/plugins/status-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-meter
› 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 › /meter ⎿ status-meter: ctx 200k ████▉░░░░░ 49% │ 5h ███▏░░░░░░ 31% ↻NaN/NaN │ 7d ░░░░░░░░░░ — ⎿ status-meter: Patterns: ring, dots, sparkline, bar, braille (now: bar) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

status-meter

Claude Code Desktop アプリの入力欄の上に、コンテキストと 5 時間制限・7 日制限の使用率を SVG のメーターで表示する mod です。 ターミナルの statusLine(~/.claude/statusline.sh)の 2 行目を Desktop に移したもので、ターミナルには何も描きません。

ctx 200k [ゲージ] 20% │ 5h [ゲージ] 43% ↻09:05 │ 7d [ゲージ] 5% ↻10/6

[ゲージ] の部分が SVG で、形は下の 5 パターンから選べます。

表示

  • ctx:コンテキストの使用率。ラベルにコンテキストウィンドウの大きさ(千トークン単位)を付けます
  • 5h / 7d:5 時間制限と 7 日制限の使用率。リセットまで 24 時間を切っていればリセット時刻(↻HH:MM)、それより先なら日付(↻M/D)を添えます。どちらもマシンのローカル時刻です
  • 色:statusline と同じ式で、使用率 0% の緑から 50% の黄、100% の赤へ変わります。ゲージと数値の両方に使います
  • リセット済み:リセット時刻を過ぎた制限は 0% として表示します。表示は 1 分ごとに更新します
  • 値がないとき:まだ値を受け取っていない制限は — と表示し、ゲージは灰色の空の状態で描きます

メーターのパターン

Claude Code の statusline でレート制限を表示する記事の 5 パターンを、文字ではなく SVG の図形で描きます。

パターン形
ring円の上に、使用率の分だけ弧を描く(1% 刻み)
dots使用率の色で塗った丸 1 つ
sparkline右に行くほど高い 8 本の棒を、使用率の分だけ左から点灯する
bar(既定)10 マスのバー。端数は最後のマスを途中まで塗る
braille点字のような 2×4 の点を 4 マス並べ、使用率の分だけ左下から点灯する

使い方

インストール

Claude Code のプロンプトで実行します。マーケットプレイスを登録済みなら 1 行目は不要です。

/plugin marketplace add sym-product-sandbox/hiraoku-claude-mods
/plugin install status-meter@hiraoku-mods

リポジトリは sym-product-sandbox の internal なので、組織のメンバーで、手元の git が GitHub に認証できている必要があります。

インストールしたら Desktop アプリを再起動します。

表示する

  1. Desktop アプリの Code タブで、Local 環境の新しいセッションを開きます
  2. 何か 1 つメッセージを送ります
  3. 入力欄の上にメーターの行が出ます

最初のメッセージを送るまでは、帯が出ないことがあります。

パターンを切り替える

Desktop アプリでは、プロンプトで /meter にパターン名を付けて実行します。

/meter bar

開いているセッションのメーターがすぐに切り替わり、設定に保存されるので、次のセッションでも同じパターンになります。 /meter だけを実行すると、今のメーターを文字の 1 行で表示し、選べるパターンと今のパターンを続けて表示します。

ctx 200k ██░░░░░░░░ 20% │ 5h ████▎░░░░░ 43% ↻09:05 │ 7d ▌░░░░░░░░░ 5% ↻10/6
Patterns: ring, dots, sparkline, bar, braille (now: bar)

帯が出ないクラウドのセッションでも、この表示は出ます。

Desktop アプリで /config を開くと、アプリ自体の設定画面が開き、この mod の設定はありません。

更新する

/plugin marketplace update hiraoku-mods
/plugin update status-meter@hiraoku-mods

更新は Desktop アプリの再起動後に反映されます。

制約

  • Desktop アプリだけで表示します。ターミナルでは何も描かず、これまでどおり statusLine が表示されます
  • 帯は Desktop アプリの Local 環境のセッションでだけ出ます。クラウドのセッションに Desktop アプリで接続しても、帯は出ません。アプリが帯の描画を CLI に依頼しない(ui_render を送らない)ため、mod 側では出せません。クラウドのセッションでは /meter で同じ値を文字で確認できます
  • 新しいセッションでは、最初の応答まで 5h と 7d は — です。制限の値は API の応答と一緒に届くためです。セッションの間で値を共有する仕組み($.store)は入れていません

動作を確認したバージョン:

  • Claude Code CLI 2.1.287
  • Desktop アプリ 2.19675.0(同梱の Claude Code 2.1.286)

テスト

cd plugins/status-meter
claude plugin test               # テスト
claude plugin validate .         # manifest と hooks の検証
npx -y -p typescript tsc -p .    # 型チェック(.claude-plugin/types/ は Claude Code が生成し、git には入れない)
Source 1 files
hooks/register.js 227 lines
1// The meters line of the terminal statusline (~/.claude/statusline.sh), for the band above the prompt
2// in the Desktop app: context, 5-hour and 7-day usage, each drawn as an SVG gauge beside its value.
3// An app attached to a cloud session never asks for the band, so /meter also prints the line as text.
4
5// The meter pattern in use: options.pattern at load, then each /config change
6let selected = 'bar';
7let context = null;
8let rateLimits = [];
9// The redraw timer, kept so a re-fired session.start can stop it
10let ticker = null;
11
12// How often to redraw, so the reset markers move on and a passed reset shows 0%
13const TICK_MS = 60_000;
14// The pattern last chosen with /meter or /config, which wins over options.pattern
15const PATTERN_KEY = 'pattern';
16const DAY_MS = 24 * 3_600_000;
17const LIMITS = [
18  { kind: 'five_hour', name: '5h' },
19  { kind: 'seven_day', name: '7d' },
20];
21// Neutral gray with alpha, so the unfilled part reads on light and dark themes alike
22const TRACK = 'rgba(128,128,128,0.3)';
23
24// Each pattern draws a gauge for `used` (0 to 100) in `color`, sized to sit on one text line
25const PATTERNS = {
26  ring: gauge(12, 12, (used, color) =>
27    `<circle cx="6" cy="6" r="4.5" fill="none" stroke="${TRACK}" stroke-width="2"/>` +
28    `<circle cx="6" cy="6" r="4.5" fill="none" stroke="${color}" stroke-width="2" pathLength="100" stroke-dasharray="${Math.round(used)} 100" transform="rotate(-90 6 6)"/>`),
29  dots: gauge(10, 10, (used, color) => `<circle cx="5" cy="5" r="4" fill="${color}"/>`),
30  // Eight bars of rising height, lit from the left
31  sparkline: gauge(31, 12, (used, color) =>
32    times(8, (i) => {
33      const h = Math.round(1.5 * (i + 1));
34      return `<rect x="${i * 4}" y="${12 - h}" width="3" height="${h}" rx="0.5" fill="${i < used / 12.5 ? color : TRACK}"/>`;
35    })),
36  // Ten cells, the last one lit filled only in part
37  bar: gauge(59, 8, (used, color) =>
38    times(10, (i) => {
39      const fill = Math.min(Math.max(used / 10 - i, 0), 1) * 5;
40      const lit = fill > 0 ? `<rect x="${i * 6}" width="${fill.toFixed(1)}" height="8" rx="1" fill="${color}"/>` : '';
41      return `<rect x="${i * 6}" width="5" height="8" rx="1" fill="${TRACK}"/>` + lit;
42    })),
43  // Four braille cells of 2x4 dots, lit column by column from the bottom up, as ⡀⡄⡆⡇⣇⣧⣷⣿ fill
44  braille: gauge(30, 12, (used, color) =>
45    times(32, (k) => {
46      const col = Math.floor(k / 4);
47      const x = 1.5 + col * 3 + Math.floor(col / 2) * 2;
48      const y = 1.5 + (3 - (k % 4)) * 3;
49      return `<circle cx="${x}" cy="${y}" r="1" fill="${k < used * 0.32 ? color : TRACK}"/>`;
50    })),
51};
52
53export function register(on, options) {
54  // An unset or unknown option falls back to the default pattern
55  if (options.pattern in PATTERNS) selected = options.pattern;
56
57  // Fires again on an enable or a worker respawn, which may keep this module's variables
58  on('session.start', async ($, e, next) => {
59    ticker?.cancel();
60    await $.command.register({
61      name: 'meter',
62      description: 'Switch the meter pattern (ring, dots, sparkline, bar or braille)',
63      argumentHint: '<pattern>',
64    });
65    const saved = await $.store.get(PATTERN_KEY);
66    if (saved in PATTERNS) selected = saved;
67    const usage = await $.session.usage();
68    context = usage.context;
69    rateLimits = usage.rateLimits;
70    // A session can start before any response reported the limits, and a later response need
71    // not raise session.measure for them, so each tick reads them again
72    ticker = $.clock.every(TICK_MS, async () => {
73      const latest = (await $.session.usage()).rateLimits;
74      if (latest.length > 0) rateLimits = latest;
75      $.ui.invalidate('ui.render');
76    });
77    $.ui.invalidate('ui.render');
78    return next(e);
79  });
80
81  // /clear, /resume, /branch (fork) and compaction change the context, which session.measure
82  // reports only after the next turn
83  on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
84    context = (await $.session.usage()).context;
85    $.ui.invalidate('ui.render');
86    return next(e);
87  });
88
89  // Fires after each turn, and when a rate-limit window moves a whole point
90  on('session.measure', async ($, e, next) => {
91    context = e.context;
92    // e.rateLimits is always the latest reading; changed alone marks a window that went away
93    if (e.rateLimits.length > 0 || e.changed.includes('rateLimits')) rateLimits = e.rateLimits;
94    $.ui.invalidate('ui.render');
95    return next(e);
96  });
97
98  // Switches the pattern live; a change another hook denied leaves the drawing as it was
99  on('config.set', { key: 'status-meter.pattern' }, async ($, e, next) => {
100    const result = await next(e);
101    if (result.deny === undefined) await choose($, result.value);
102    return result;
103  });
104
105  // The Desktop app's /config opens the app's own settings, not this row, and a mod has no
106  // /config row of its own to set ($.config.set refuses the key), so /meter is how a Desktop
107  // user switches
108  on('command.run', { command: 'meter' }, async ($, e) => {
109    const name = e.args.trim();
110    if (!(name in PATTERNS)) {
111      const line = textLine(await $.clock.now());
112      return { text: line + '\nPatterns: ' + Object.keys(PATTERNS).join(', ') + ' (now: ' + selected + ')' };
113    }
114    await choose($, name);
115    return { text: 'Meter pattern: ' + name };
116  });
117
118  // The terminal keeps its own statusLine, so only the app surfaces get this band. Only an app that
119  // runs the session itself (Desktop's Local sessions) asks for it; one attached to a cloud session
120  // sends no ui_render, so this hook never runs there and /meter is the way to see the figures
121  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
122    if (e.surface === 'terminal') return next(e);
123    const elements = $.ui.resolve(e);
124    const { Box, Text } = elements;
125    const now = await $.clock.now();
126    const meters = allMeters(now);
127    // The statusline's dim separator between meters
128    const children = meters.flatMap((m, i) => [
129      ...(i === 0 ? [] : [Text({ dimColor: true, children: ['│'] })]),
130      meter(elements, m),
131    ]);
132    const line = Box({ flexDirection: 'row', columnGap: 1, alignItems: 'center', children });
133    // Keep what the mods after this one draw in the band
134    const rest = await next(e);
135    return rest ? Box({ flexDirection: 'column', children: [line, rest] }) : line;
136  });
137}
138
139// Saved in $.store, which every session on the machine shares, so the next session starts with it
140async function choose($, name) {
141  selected = name;
142  await $.store.set(PATTERN_KEY, name);
143  $.ui.invalidate('ui.render');
144}
145
146function allMeters(now) {
147  return [contextMeter(), ...LIMITS.map((limit) => limitMeter(limit, now))];
148}
149
150// The band as one line of text, for an app that does not draw the band: a ten-cell block bar per meter
151function textLine(now) {
152  return allMeters(now)
153    .map(({ label, used, marker }) => {
154      const known = typeof used === 'number';
155      const parts = [label, textBar(known ? used : 0), known ? Math.round(used) + '%' : '—'];
156      if (marker) parts.push(marker);
157      return parts.join(' ');
158    })
159    .join(' │ ');
160}
161
162// Eighths of a cell, so the last lit cell fills in part as the SVG bar's does
163const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
164
165function textBar(used) {
166  const eighths = Math.round(Math.min(Math.max(used, 0), 100) * 0.8);
167  const full = Math.floor(eighths / 8);
168  const part = EIGHTHS[eighths % 8];
169  return '█'.repeat(full) + part + '░'.repeat(10 - full - (part ? 1 : 0));
170}
171
172function contextMeter() {
173  const label = context?.window > 0 ? 'ctx ' + Math.floor(context.window / 1000) + 'k' : 'ctx';
174  return { name: 'ctx', label, used: context?.percent, marker: null };
175}
176
177function limitMeter({ kind, name }, now) {
178  const limit = rateLimits.find((l) => l.kind === kind);
179  const resetsAt = limit?.resetsAt == null ? null : Date.parse(limit.resetsAt);
180  // A window that has reset since the reading starts again from zero
181  if (resetsAt != null && resetsAt <= now) return { name, label: name, used: 0, marker: null };
182  return { name, label: name, used: limit?.percentUsed, marker: resetsAt == null ? null : resetMarker(resetsAt, now) };
183}
184
185// The reset's local time of day when it is within a day, else its date
186function resetMarker(ms, now) {
187  const d = new Date(ms);
188  if (ms - now < DAY_MS) {
189    return '↻' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
190  }
191  return '↻' + (d.getMonth() + 1) + '/' + d.getDate();
192}
193
194// An unknown figure (no reading yet) draws an empty gauge in the track color beside a dash
195function meter({ Box, Text, Svg }, { name, label, used, marker }) {
196  const known = typeof used === 'number';
197  const value = known ? Math.round(used) + '%' : '—';
198  const color = known ? gradient(used) : TRACK;
199  const shape = PATTERNS[selected] ?? PATTERNS.ring;
200  const children = [
201    Text({ dimColor: true, children: [label] }),
202    Svg({ source: shape.draw(known ? used : 0, color), alt: label + ' ' + value, width: shape.width, height: shape.height }),
203    Text({ ...(known ? { color } : { dimColor: true }), children: [value] }),
204  ];
205  if (marker) children.push(Text({ dimColor: true, children: [marker] }));
206  return Box({ key: 'meter-' + name, flexDirection: 'row', columnGap: 1, alignItems: 'center', children });
207}
208
209// The statusline's green to yellow to red ramp, ported as is (Python's int() truncates)
210function gradient(pct) {
211  const rgb = pct < 50 ? [Math.trunc(pct * 5.1), 200, 80] : [255, Math.max(Math.trunc(200 - (pct - 50) * 4), 0), 60];
212  return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
213}
214
215function gauge(width, height, body) {
216  return {
217    width,
218    height,
219    draw: (used, color) =>
220      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body(used, color)}</svg>`,
221  };
222}
223
224function times(n, fn) {
225  return Array.from({ length: n }, (_, i) => fn(i)).join('');
226}
227