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

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 パターンから選べます。
↻HH:MM)、それより先なら日付(↻M/D)を添えます。どちらもマシンのローカル時刻です— と表示し、ゲージは灰色の空の状態で描きます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 アプリを再起動します。
最初のメッセージを送るまでは、帯が出ないことがあります。
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 アプリの再起動後に反映されます。
ui_render を送らない)ため、mod 側では出せません。クラウドのセッションでは /meter で同じ値を文字で確認できます— です。制限の値は API の応答と一緒に届くためです。セッションの間で値を共有する仕組み($.store)は入れていません動作を確認したバージョン:
cd plugins/status-meter
claude plugin test # テスト
claude plugin validate . # manifest と hooks の検証
npx -y -p typescript tsc -p . # 型チェック(.claude-plugin/types/ は Claude Code が生成し、git には入れない)hooks/register.js 227 lines1// 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