Claude Code Desktop の入力欄の上に context・5h・7d の使用率をバーで常時出す

Claude Code Desktop(Code タブ)の入力欄の上に、context・5 時間・7 日の使用率を 3 本のバーで常に出す mod(Claude Code のプラグイン)。バーを押すと、context の内訳やリセットの日時を出す。
An unofficial Claude Code mod that keeps the context window, 5-hour and 7-day usage visible as three bars above the prompt in Claude Code Desktop. Press a bar for the context breakdown or the reset time.

Anthropic の公式のものではありません。
| バー | 中身 |
|---|---|
context | 今のセッションの context window の使用率 |
5h | 5 時間の利用枠の使用率と、リセットまでの残り時間 |
7d | 7 日の利用枠の使用率と、リセットまでの残り時間 |
押したバーの詳細を、バーの上に開く。使用率はバーと重なるので出さない。
| バー | 出すもの |
|---|---|
context | 使っている量と窓の大きさ、自動 compact が走る量、/context と同じ分け方の内訳(色分けした細いバーと、多い順に 4 つ。手元での見積もりで通信はしないので、/context の数字と少しずれることがある) |
5h | 何時にリセットされるか(例: 今日 20:30 にリセット) |
7d | 何月何日の何時にリセットされるか(例: 10月18日(日)12:00 にリセット) |

-- のままになるDesktop の Code タブでは /plugin が使えないので、ターミナルから入れる。どちらか一方でよい。
シェルから(スコープは既定の user で入る):
claude plugin install usage-bars --marketplace tanmen/claude-tanmen-plugins
ターミナルで起動した Claude Code のプロンプトから:
/plugin install usage-bars --marketplace tanmen/claude-tanmen-plugins
こちらは Add marketplace? に y、スコープは user を選ぶ。
user スコープで入れると、Desktop の Code タブで手元に作るセッションにも読み込まれる。帯が出るのは入れたあとに開いたセッションからで、入れたときにもう開いていたセッションには出ない。
外すとき:
claude plugin uninstall usage-bars@tanmen
設定項目 size で 2 通りから選べる。上のスクリーンショットが既定の compact、下が regular。
size | バー | 帯の高さ |
|---|---|---|
compact(既定) | 19px・文字 11px | 40px(Desktop 自身の使用量の通知と同じ高さ) |
regular | 26px・文字 12px | 47px |

帯の上下の余白(約 21px)は Desktop の枠のもので、mod からは変えられない。大きさで変わるのはバーの高さと文字の大きさだけ。
入れるときに選ぶ:
claude plugin install usage-bars --marketplace tanmen/claude-tanmen-plugins --config size=regular
入れたあとに変える(名前は usage-bars@tanmen の形で渡す。変えたあとは Claude Code を起動し直すと効く):
echo '{"size":"regular"}' | claude plugin configure usage-bars@tanmen --values-stdin
/usage と同じ口)。手元の値が 5 分より古いときに読む(セッションを開いた直後と、Desktop がそのセッションの帯を描いている間)。5h / 7d のバーを押したときは、1 分より古ければ読み直すhttps://api.anthropic.com/api/oauth/usage)。資格情報は Claude Code が持ったままで、mod には渡らない。マシンの全セッションで読んだ値と読みに行った時刻を共有するので、読みに行くのはマシン全体でおおよそ 5 分に 1 回、多くても 1 分に 1 回ほどになる。CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC を設定していると読まず、応答と別のセッションの値だけで出す/context と同じ分け方で手元で見積もった値(通信はしない)を出し、応答が来たら置き換える。compact の直後も同じ。システムプロンプト・ツール・メモリーの分が最初から入っているので、0% にはならない。応答が来るまでは 5 秒ごとに数え直す-- にするstatusLine がある)。アンケートが出ている間は、帯をアンケートに譲るclaude plugin validate .
claude plugin test .
作業中の mod は claude --plugin-dir . で読み込める。
型チェック: claude --plugin-dir . で一度起動すると、Claude Code が .claude-plugin/types/ に型定義と tsconfig.json を置く(インストールしたものや claude -p では置かれない)。ルートの tsconfig.json はそれを継いでいるので、そのあとは tsc -p . で検査できる。
MIT
hooks/register.tsx 607 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Detail, DetailKind, UsageSnapshot, UsageWindow } from '../types'
5
6const snapshot = atom({ plugin: 'usage-bars', key: 'snapshot' } as const, null)
7// 押して開いたバーの詳細。閉じていれば null
8const detail = atom({ plugin: 'usage-bars', key: 'detail' } as const, null)
9
10// 5h / 7d の最後の値。マシンの中の全セッションで共有し、開いた直後の表示と、別のセッションへの受け渡しに使う
11const STORED_WINDOWS = 'windows'
12// 使用量 API を読みに行った時刻。全セッションが同じ時に読みに行かないよう、先に書いておく
13const FETCH_CLAIM = 'fetchClaim'
14// 開いた時点の context の見積もりと、最初の応答の値の差(トークン数)。MCP のツールは開いたあとに繋がり、
15// 最初のメッセージも見積もりに入らないので、そのぶん少なく出る(Desktop の実測で 58.5K → 85.9K)。
16// 全セッションで共有し、次のセッションの開いた時点の見積もりに足す
17const CONTEXT_GAP = 'contextGap'
18
19// /usage が読むのと同じ口。応答を待たずに 5h / 7d が読める
20const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
21// 手元の値がこれより古ければ使用量 API を読み直す。詳細を開いたときはもっと短く
22const REFRESH_MS = 5 * 60_000
23const DETAIL_REFRESH_MS = 60_000
24const CLAIM_MS = 60_000
25// 帯がこの間描かれていなければ、見られていないとみて API は読みに行かない(別のセッションの値は取り込む)
26const VISIBLE_MS = 2 * 60_000
27// 応答の値が届くまで、context の見積もりをこの間隔で数え直す(最初のターンで増えていく分を追う)
28const ESTIMATE_MS = 5_000
29
30const WINDOWS = [
31 { kind: 'five_hour', label: '5h', title: '5 時間の利用枠' },
32 { kind: 'seven_day', label: '7d', title: '7 日の利用枠' },
33] as const
34
35const DETAIL_KINDS: readonly DetailKind[] = ['context', 'five_hour', 'seven_day']
36
37const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土']
38
39// context の詳細の色。Desktop 自身の使用量の popup(ダークテーマ)に合わせた
40const DETAIL_COLORS = { text: '#E8E6DC', muted: '#9C9A92', track: '#3A3A37' } as const
41// context の内訳の区切り。多い順に割り当てる
42const SEGMENT_COLORS = ['#4C8BF5', '#E8833A', '#3FB27F', '#B07CF0', '#E05A9B', '#8A8A85']
43
44// SVG は画像として描かれ、アプリのテーマに追従しない。Desktop のダークテーマに合わせた色
45const TRACK = '#2C2C2A'
46const LABEL = '#B4B2A9'
47const MUTED = '#888780'
48const TONES = {
49 low: { fill: '#1D9E75', text: '#5DCAA5' },
50 mid: { fill: '#BA7517', text: '#EF9F27' },
51 high: { fill: '#E24B4A', text: '#F09595' },
52 none: { fill: MUTED, text: MUTED },
53} as const
54const FONT = "'Segoe UI', 'Yu Gothic UI', 'Hiragino Sans', system-ui, sans-serif"
55// 帯の上下の余白(11px / 10px)は Desktop の枠のもので、mod からは変えられない(負の margin は
56// 検査を通るが Desktop では無視された)。縦幅はバーの高さで決まる。
57// compact(既定)はバー 19px で帯が 40px になり、Desktop 自身の「週間使用量の上限」の枠と同じ高さになる
58type Size = { height: number; fontSize: number; detailFontSize: number; baseline: number; detailDx: number }
59
60const SIZES: Record<'compact' | 'regular', Size> = {
61 compact: { height: 19, fontSize: 11, detailFontSize: 11, baseline: 13, detailDx: 44 },
62 regular: { height: 26, fontSize: 12, detailFontSize: 11, baseline: 17, detailDx: 48 },
63}
64
65type Meter = {
66 kind: DetailKind
67 label: string
68 percent: number | null
69 // バーの右寄りに小さく出す補足(残り時間)。無ければ空
70 detail: string
71 // 読み上げ用の全文。削った情報(トークン数・リセット時刻)はここに残す
72 alt: string
73 // 前のセッションから持ち越した値や、応答の前の見積もり。今の値と見分けられるよう色を落とす
74 isTentative: boolean
75}
76
77type StoredWindows = { windows: UsageWindow[]; readAt: number }
78
79function toWindows(limits: SessionRateLimit[]): UsageWindow[] {
80 return limits.map(({ kind, percentUsed, resetsAt }) =>
81 resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
82 )
83}
84
85function isStoredWindows(value: unknown): value is StoredWindows {
86 if (typeof value !== 'object' || value === null) return false
87 const v = value as Partial<StoredWindows>
88 return Array.isArray(v.windows) && typeof v.readAt === 'number'
89}
90
91// hit.ts が知らせてきた、押されたバー
92function detailKindOf(data: unknown): DetailKind | null {
93 if (typeof data !== 'object' || data === null) return null
94 const kind = (data as { kind?: unknown }).kind
95 return DETAIL_KINDS.find(k => k === kind) ?? null
96}
97
98// 使用量 API の応答から 5h / 7d を取り出す。utilization は 0〜100 の使用率、resets_at は ISO 8601
99// (小数秒が 6 桁で来るので 3 桁に詰めてから読む)。形が違えば undefined
100function parseUsage(text: string): UsageWindow[] | undefined {
101 let body: unknown
102 try {
103 body = JSON.parse(text)
104 } catch {
105 return undefined
106 }
107 if (typeof body !== 'object' || body === null) return undefined
108
109 const windows: UsageWindow[] = []
110 for (const { kind } of WINDOWS) {
111 const value = (body as Record<string, unknown>)[kind]
112 if (typeof value !== 'object' || value === null) continue
113 const { utilization, resets_at: resetsAt } = value as { utilization?: unknown; resets_at?: unknown }
114 if (typeof utilization !== 'number') continue
115 const percentUsed = Math.round(utilization * 10) / 10
116 const at = typeof resetsAt === 'string' ? Date.parse(resetsAt.replace(/(\.\d{3})\d+/, '$1')) : NaN
117 windows.push(Number.isNaN(at) ? { kind, percentUsed } : { kind, percentUsed, resetsAt: new Date(at).toISOString() })
118 }
119 return windows.length > 0 ? windows : undefined
120}
121
122// 読めなければ undefined(今の値を残す)。資格情報は host が持ったままで、mod には渡らない
123async function fetchWindows($: EngineInterface): Promise<UsageWindow[] | undefined> {
124 try {
125 const auth = await $.session.authorize()
126 if (auth === null || auth.kind !== 'bearer') return undefined
127 const response = await $.http.fetch(USAGE_URL, {
128 auth: auth.handle,
129 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
130 })
131 return response.ok ? parseUsage(response.text) : undefined
132 } catch {
133 return undefined
134 }
135}
136
137// 今の値より新しい読みだけを取り込む
138async function adopt($: EngineInterface, reading: StoredWindows): Promise<void> {
139 await update($, snapshot, prev => {
140 if (prev === null || (prev.windowsReadAt !== null && prev.windowsReadAt >= reading.readAt)) return prev
141 return { ...prev, windows: reading.windows, windowsReadAt: reading.readAt, isCarriedOver: false }
142 })
143}
144
145function roundToMinute(ms: number): number {
146 return Math.round(ms / 60_000) * 60_000
147}
148
149function formatTokens(n: number): string {
150 if (n >= 1_000_000) return `${Math.round(n / 100_000) / 10}M`
151 if (n >= 1_000) return `${Math.round(n / 1_000)}K`
152 return `${n}`
153}
154
155function formatRemaining(ms: number): string {
156 const minutes = Math.max(0, Math.floor(ms / 60_000))
157 const d = Math.floor(minutes / 1440)
158 const h = Math.floor((minutes % 1440) / 60)
159 const m = minutes % 60
160 if (d > 0) return `${d}d${h}h`
161 if (h > 0) return `${h}h${m}m`
162 return `${m}m`
163}
164
165function hhmm(at: Date): string {
166 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
167}
168
169function formatClock(at: Date, now: Date): string {
170 if (at.toDateString() === now.toDateString()) return hhmm(at)
171 return `${at.getMonth() + 1}/${at.getDate()} ${hhmm(at)}`
172}
173
174// 詳細に出すリセットの日時。今日なら「今日 20:30」、それ以外は「10月18日(日)12:00」
175function formatResetAt(at: Date, now: Date): string {
176 if (at.toDateString() === now.toDateString()) return `今日 ${hhmm(at)}`
177 return `${at.getMonth() + 1}月${at.getDate()}日(${WEEKDAYS[at.getDay()]})${hhmm(at)}`
178}
179
180function percentText(percent: number | null): string {
181 return percent === null ? '--' : `${Math.round(percent)}%`
182}
183
184function contextMeter(snap: UsageSnapshot): Meter {
185 const used = snap.contextTokens === null ? '--' : formatTokens(snap.contextTokens)
186 const suffix = snap.isContextEstimated ? '・応答の前の見積もり' : ''
187 return {
188 kind: 'context',
189 label: 'context',
190 percent: snap.contextPercent,
191 detail: '',
192 alt: `context ${percentText(snap.contextPercent)}(${used} / ${formatTokens(snap.contextWindow)}${suffix})`,
193 isTentative: snap.isContextEstimated,
194 }
195}
196
197function windowMeter(snap: UsageSnapshot, kind: (typeof WINDOWS)[number]['kind'], label: string, now: number): Meter {
198 const win = snap.windows.find(w => w.kind === kind)
199 if (win === undefined) {
200 return { kind, label, percent: null, detail: '', alt: `${label} まだ値がない`, isTentative: false }
201 }
202
203 const isCarriedOver = snap.isCarriedOver
204 const suffix = isCarriedOver ? '・前回のセッションの値' : ''
205 if (win.resetsAt === undefined) {
206 return { kind, label, percent: win.percentUsed, detail: '', alt: `${label} ${percentText(win.percentUsed)}${suffix}`, isTentative: isCarriedOver }
207 }
208
209 // 使用量 API は 04:59:59.9 のような時刻で返すので、分に丸める
210 const resetsAt = roundToMinute(Date.parse(win.resetsAt))
211 // 持ち越した値の窓がもう切り替わっていれば、使用率は 0 から数え直しになっている
212 if (resetsAt <= now) {
213 return { kind, label, percent: 0, detail: '', alt: `${label} 0%(リセット済み)`, isTentative: isCarriedOver }
214 }
215
216 const remaining = formatRemaining(resetsAt - now)
217 const clock = formatClock(new Date(resetsAt), new Date(now))
218 return {
219 kind,
220 label,
221 percent: win.percentUsed,
222 detail: remaining,
223 alt: `${label} ${percentText(win.percentUsed)}(あと ${remaining}・${clock} にリセット${suffix})`,
224 isTentative: isCarriedOver,
225 }
226}
227
228// 詳細の 5h / 7d の 1 行(使用率の後ろに続ける部分)。リセットの日時と、前回のセッションの値ならその旨
229function windowLine(snap: UsageSnapshot, kind: (typeof WINDOWS)[number]['kind'], now: number): string {
230 const win = snap.windows.find(w => w.kind === kind)
231 if (win === undefined) return 'まだ値がない'
232 const suffix = snap.isCarriedOver ? '(前回のセッションの値)' : ''
233 if (win.resetsAt === undefined) return suffix
234 const at = roundToMinute(Date.parse(win.resetsAt))
235 if (at <= now) return `リセット済み${suffix}`
236 return `${formatResetAt(new Date(at), new Date(now))} にリセット${suffix}`
237}
238
239// 詳細の context。見出しの行に自動 compact の閾値と使っている量、/context と同じ分け方の内訳(手元での見積もり。通信しない)を色分けした細いバー、
240// 多い順に 4 つの凡例。使用率は下のバーと重なるので出さない。幅は % で書き、帯の幅に合わせて伸び縮みさせる
241function contextSvg(open: Detail, snap: UsageSnapshot): { source: string; alt: string } {
242 const parts: string[] = []
243 const alt: string[] = []
244 const used = snap.contextTokens === null ? '--' : formatTokens(snap.contextTokens)
245 const summary = `${snap.isContextEstimated ? '約 ' : ''}${used} / ${formatTokens(snap.contextWindow)}`
246 // 右寄せの 1 かたまりで、自動 compact を使っている量の左に置く
247 const compact = open.autoCompactAt === null ? null : `${formatTokens(open.autoCompactAt)} で自動 compact`
248 const compactSpan = compact === null ? '' : `<tspan font-size="11">${escapeXml(compact)}</tspan><tspan dx="14">`
249 parts.push(`<text x="0" y="14" font-size="13" font-weight="600" fill="${DETAIL_COLORS.text}">コンテキスト</text>`)
250 parts.push(
251 `<text x="100%" y="14" font-size="12" fill="${DETAIL_COLORS.muted}" text-anchor="end">${compactSpan}${escapeXml(summary)}${compact === null ? '' : '</tspan>'}</text>`,
252 )
253 alt.push(`コンテキスト ${summary}`)
254 if (compact !== null) alt.push(compact)
255
256 parts.push(`<rect y="22" width="100%" height="4" rx="2" fill="${DETAIL_COLORS.track}"/>`)
257 const categories = open.categories ?? []
258 if (categories.length > 0 && snap.contextWindow > 0) {
259 let x = 0
260 categories.forEach((c, i) => {
261 const width = Math.min(100 - x, (c.tokens / snap.contextWindow) * 100)
262 if (width <= 0) return
263 const color = SEGMENT_COLORS[i % SEGMENT_COLORS.length]
264 parts.push(`<rect x="${x.toFixed(3)}%" y="22" width="${width.toFixed(3)}%" height="4" fill="${color}"/>`)
265 x += width
266 })
267 } else if (snap.contextPercent !== null) {
268 const fill = toneOf(contextMeter(snap)).fill
269 parts.push(`<rect y="22" width="${Math.min(100, snap.contextPercent)}%" height="4" rx="2" fill="${fill}"/>`)
270 }
271
272 const lines: string[] = []
273 if (open.categories === null) {
274 lines.push(`<tspan fill="${DETAIL_COLORS.muted}">内訳を数えている…</tspan>`)
275 } else if (categories.length > 0) {
276 // 色はバーの区切りと同じ
277 lines.push(
278 categories
279 .slice(0, 4)
280 .map((c, i) => `<tspan fill="${SEGMENT_COLORS[i]}">●</tspan><tspan fill="${DETAIL_COLORS.muted}"> ${escapeXml(`${c.name} ${formatTokens(c.tokens)}`)} </tspan>`)
281 .join(''),
282 )
283 alt.push(categories.map(c => `${c.name} ${formatTokens(c.tokens)}`).join('・'))
284 }
285 lines.forEach((line, i) => parts.push(`<text x="0" y="${42 + i * 16}" font-size="11">${line}</text>`))
286
287 const height = lines.length === 0 ? 30 : 42 + (lines.length - 1) * 16 + 6
288 const source = [
289 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${height}" font-family="${FONT}">`,
290 ...parts,
291 `</svg>`,
292 ].join('')
293 return { source, alt: alt.join('\n') }
294}
295
296function toneOf(meter: Meter) {
297 // 前のセッションから持ち越した値・応答の前の見積もりは、今の値と見分けられるよう色を落とす
298 if (meter.percent === null || meter.isTentative) return TONES.none
299 // 表示する数字(四捨五入)で判定する。79.6 を「80%」と出しながら黄にしない
300 const shown = Math.round(meter.percent)
301 if (shown >= 80) return TONES.high
302 if (shown >= 50) return TONES.mid
303 return TONES.low
304}
305
306function escapeXml(text: string): string {
307 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
308}
309
310type TextColors = { label: string; detail: string; percent: string }
311
312function textLayer(meter: Meter, size: Size, colors: TextColors, attributes = ''): string {
313 const detail = meter.detail
314 ? `<text x="100%" dx="-${size.detailDx}" y="${size.baseline}" font-size="${size.detailFontSize}" fill="${colors.detail}" text-anchor="end">${escapeXml(meter.detail)}</text>`
315 : ''
316 return [
317 `<g font-family="${FONT}" font-size="${size.fontSize}"${attributes}>`,
318 `<text x="10" y="${size.baseline}" fill="${colors.label}">${escapeXml(meter.label)}</text>`,
319 detail,
320 `<text x="100%" dx="-10" y="${size.baseline}" font-weight="600" fill="${colors.percent}" text-anchor="end">${percentText(meter.percent)}</text>`,
321 `</g>`,
322 ].join('')
323}
324
325// 薄い色の塗り+到達点に 2px の縦線。幅はすべて % で書き、枠の幅に合わせて伸び縮みさせる。
326// 詳細を開いているバーは縁取りで示す
327function meterSvg(meter: Meter, size: Size, isSelected: boolean): string {
328 const tone = toneOf(meter)
329 const width = meter.percent === null ? 0 : Math.min(100, Math.max(0, Math.round(meter.percent)))
330 const h = size.height
331 // 縦線が文字を横切らないよう、文字の形(太らせたもの)だけ線をくり抜く
332 const knockout = textLayer(
333 meter,
334 size,
335 { label: 'black', detail: 'black', percent: 'black' },
336 ' stroke="black" stroke-width="5" stroke-linejoin="round"',
337 )
338 const edge =
339 width > 0
340 ? `<g mask="url(#k)"><rect x="${width}%" width="2" height="${h}" transform="translate(-2 0)" fill="${tone.text}"/></g>`
341 : ''
342 const outline = isSelected
343 ? `<rect width="100%" height="${h}" rx="6" fill="none" stroke="${LABEL}" stroke-width="2" clip-path="url(#r)"/>`
344 : ''
345
346 return [
347 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${h}">`,
348 `<defs><clipPath id="r"><rect width="100%" height="${h}" rx="6"/></clipPath>`,
349 `<mask id="k" maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="${h}">`,
350 `<rect width="100%" height="${h}" fill="white"/>${knockout}</mask></defs>`,
351 `<rect width="100%" height="${h}" rx="6" fill="${TRACK}"/>`,
352 `<g clip-path="url(#r)"><rect width="${width}%" height="${h}" fill="${tone.fill}" fill-opacity="0.25"/>${edge}</g>`,
353 outline,
354 textLayer(meter, size, { label: LABEL, detail: MUTED, percent: tone.text }),
355 `</svg>`,
356 ].join('')
357}
358
359// サブスクリプションの資格情報が無いセッション(API キー・3P プロバイダ・ゲートウェイ)。5h / 7d は無いので、
360// 共有の値も出さず、使用量 API も読まない
361let isPlanless = false
362let lastDrawnAt = 0
363// このセッションを開いた時点の見積もりと、それに足す差。最初の応答の値が届くまでだけ使う
364// (compact の後は MCP もメッセージも見積もりに入っているので、差は足さない)
365let isBeforeFirstResponse = true
366let startEstimate: number | null = null
367let startGap = 0
368
369// 別のセッションが記録した新しい値を取り込み、それでも古ければ使用量 API を読む(maxAgeMs が null なら読まない)
370async function refreshWindows($: EngineInterface, maxAgeMs: number | null): Promise<void> {
371 if (isPlanless) return
372 const stored = await $.store.get(STORED_WINDOWS)
373 if (isStoredWindows(stored)) await adopt($, stored)
374 if (maxAgeMs === null) return
375
376 const snap = await read($, snapshot)
377 const now = await $.clock.now()
378 if (snap === null) return
379 if (!snap.isCarriedOver && snap.windowsReadAt !== null && now - snap.windowsReadAt < maxAgeMs) return
380 const claim = await $.store.get(FETCH_CLAIM)
381 if (typeof claim === 'number' && now - claim < CLAIM_MS) return
382 await $.store.set(FETCH_CLAIM, now)
383
384 const windows = await fetchWindows($)
385 if (windows === undefined) return
386 const readAt = await $.clock.now()
387 const value: StoredWindows = { windows, readAt }
388 await $.store.set(STORED_WINDOWS, value)
389 await adopt($, value)
390}
391
392// 1 分ごと: 別のセッションの値の取り込み・帯が見られていて古ければ読み直し・「あと何分」の描き直し
393async function tick($: EngineInterface): Promise<void> {
394 const now = await $.clock.now()
395 await refreshWindows($, now - lastDrawnAt < VISIBLE_MS ? REFRESH_MS : null).catch(() => undefined)
396 $.ui.invalidate('ui.render')
397}
398
399// 応答の値が無い間(最初の応答の前・compact の直後)は、/context と同じ分け方の手元の見積もり(通信しない)で埋める。
400// 応答の値がもう入っていれば数えないし、数えている間に届いても上書きしない
401async function estimateContext($: EngineInterface): Promise<void> {
402 const current = await read($, snapshot)
403 if (current === null || (current.contextPercent !== null && !current.isContextEstimated)) return
404 const { context } = await $.session.usage({ breakdown: 'summary' })
405 const counted = context.breakdown?.totalTokens
406 if (counted === undefined || context.window <= 0) return
407 let tokens = counted
408 if (isBeforeFirstResponse) {
409 if (startEstimate === null) {
410 startEstimate = counted
411 const gap = await $.store.get(CONTEXT_GAP)
412 startGap = typeof gap === 'number' && gap > 0 && gap < context.window ? gap : 0
413 }
414 // 数え直して MCP の分が入ってきても差を二重に足さない。最初のターンで差より増えたら、数えた値を出す
415 tokens = Math.max(startEstimate + startGap, counted)
416 }
417 await update($, snapshot, prev => {
418 if (prev === null || (prev.contextPercent !== null && !prev.isContextEstimated)) return prev
419 // 応答の値と同じく、モデルの窓に対する割合(内訳の percentage は compact の窓に対する割合で、窓が小さいとずれる)
420 const contextPercent = (tokens / context.window) * 100
421 return { ...prev, contextPercent, contextTokens: tokens, contextWindow: context.window, isContextEstimated: true }
422 })
423}
424
425async function countContext($: EngineInterface): Promise<void> {
426 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
427 const categories = (breakdown?.categories ?? [])
428 .filter(c => c.kind === 'used' && c.tokens > 0)
429 .sort((a, b) => b.tokens - a.tokens)
430 .map(({ name, tokens }) => ({ name, tokens }))
431 await update($, detail, prev =>
432 prev?.kind === 'context' ? { ...prev, categories, autoCompactAt: breakdown?.autoCompactThreshold ?? null } : prev,
433 )
434}
435
436export const register: Register = (on, options) => {
437 const size = options.size === 'regular' ? SIZES.regular : SIZES.compact
438
439 on('session.start', async ($, e, next) => {
440 const usage = await $.session.usage()
441 const now = await $.clock.now()
442 isPlanless = (await $.session.authorize().catch(() => null))?.kind !== 'bearer'
443 const live = toWindows(usage.rateLimits)
444 const stored = live.length > 0 || isPlanless ? undefined : await $.store.get(STORED_WINDOWS)
445 const carried = isStoredWindows(stored) ? stored : undefined
446
447 await update($, snapshot, () => ({
448 contextPercent: usage.context.percent ?? null,
449 contextTokens: usage.context.tokens ?? null,
450 contextWindow: usage.context.window,
451 isContextEstimated: false,
452 windows: carried?.windows ?? live,
453 windowsReadAt: carried?.readAt ?? (live.length > 0 ? now : null),
454 // 別のセッションが少し前に読んだ値なら、今の値として出す
455 isCarriedOver: carried !== undefined && now - carried.readAt >= REFRESH_MS,
456 }))
457
458 // 最初の応答を待たずに、使用量 API で 5h / 7d を読む
459 void refreshWindows($, REFRESH_MS).catch(() => undefined)
460 // 読み直し(hot reload)や再開で、もう応答の値があれば見積もらない
461 isBeforeFirstResponse = usage.context.percent === undefined
462 startEstimate = null
463 startGap = 0
464 void estimateContext($).catch(() => undefined)
465 $.clock.every(ESTIMATE_MS, () => void estimateContext($).catch(() => undefined))
466
467 $.clock.every(60_000, () => void tick($))
468
469 return next(e)
470 })
471
472 on('session.measure', async ($, e, next) => {
473 const now = await $.clock.now()
474 const live = toWindows(e.rateLimits)
475 // 応答が来たのに 5h / 7d が空なら、この認証には利用枠が無い(API キーなど)。持ち越した値はもう出さない
476 const hasNoWindows = live.length === 0 && e.context.tokens !== undefined
477
478 // 応答の値が無い(compact の直後・応答が失敗した)。見積もりを出していればそのまま残し、数え直す
479 const hasNoContext = e.context.percent === undefined
480
481 // 最初の応答の値で、開いた時点の見積もりとの差を記録する(次のセッションの見積もりに足す)
482 if (isBeforeFirstResponse && e.context.tokens !== undefined) {
483 isBeforeFirstResponse = false
484 if (startEstimate !== null) await $.store.set(CONTEXT_GAP, Math.max(0, e.context.tokens - startEstimate))
485 }
486
487 await update($, snapshot, prev => {
488 const kept = prev !== null && !(hasNoWindows && prev.isCarriedOver) ? prev : null
489 const estimated = hasNoContext && prev?.isContextEstimated === true ? prev : null
490 return {
491 contextPercent: estimated?.contextPercent ?? e.context.percent ?? null,
492 contextTokens: estimated?.contextTokens ?? e.context.tokens ?? null,
493 contextWindow: e.context.window,
494 isContextEstimated: estimated !== null,
495 windows: live.length > 0 ? live : (kept?.windows ?? []),
496 windowsReadAt: live.length > 0 ? now : (kept?.windowsReadAt ?? null),
497 isCarriedOver: live.length > 0 ? false : (kept?.isCarriedOver ?? false),
498 }
499 })
500
501 if (live.length > 0) {
502 const value: StoredWindows = { windows: live, readAt: now }
503 await $.store.set(STORED_WINDOWS, value)
504 }
505
506 if (hasNoContext) void estimateContext($).catch(() => undefined)
507 // 開いている context の内訳を数え直す
508 if ((await read($, detail))?.kind === 'context') void countContext($).catch(() => undefined)
509
510 return next(e)
511 })
512
513 // バーに重ねた hit.ts が、押されたバーを知らせてくる。開いているバーをもう一度押すと閉じ、別のバーなら切り替える
514 on('ui.message', async ($, e, next) => {
515 const kind = detailKindOf(e.data)
516 if (kind === null) return next(e)
517
518 const isOpen = (await read($, detail))?.kind === kind
519 await update($, detail, () => (isOpen ? null : { kind, categories: null, autoCompactAt: null }))
520 if (!isOpen && kind === 'context') void countContext($).catch(() => undefined)
521 if (!isOpen && kind !== 'context') void refreshWindows($, DETAIL_REFRESH_MS).catch(() => undefined)
522
523 return next(e)
524 })
525
526 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
527 // ターミナルには statusLine があるので、帯は Desktop だけに出す
528 if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
529
530 // 下に別の mod(archive-check など)の木があれば、バーの左に並べる
531 const below = await next(e)
532 const snap = await read($, snapshot)
533 if (snap === null) return below
534
535 const open = await read($, detail)
536 const now = await $.clock.now()
537 lastDrawnAt = now
538 const { Box, Button, Client, Svg, Text } = $.ui.resolve(e)
539 const meters = [
540 contextMeter(snap),
541 ...WINDOWS.map(w => windowMeter(snap, w.kind, w.label, now)),
542 ]
543
544 const meterRow = (
545 <Box flexDirection="row" gap={2}>
546 {meters.map(meter => (
547 // 押せる面(absolute)の基準。無いと、ほかの木と並べたときに面が帯全体に広がる
548 <Box position="relative" flexDirection="column" flexGrow={1} flexShrink={1} width="30%">
549 <Svg source={meterSvg(meter, size, open?.kind === meter.kind)} alt={meter.alt} />
550 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
551 <Client key={`hit-${meter.kind}`} module="./hit.ts" props={{ kind: meter.kind }} width="100%" height="100%" />
552 </Box>
553 </Box>
554 ))}
555 </Box>
556 )
557 const hasBelow = (below as { type?: unknown }).type !== 'engine'
558 const bars = hasBelow ? (
559 <Box flexDirection="row" gap={1}>
560 <Box flexDirection="column" flexShrink={0}>
561 {below}
562 </Box>
563 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
564 {meterRow}
565 </Box>
566 </Box>
567 ) : (
568 meterRow
569 )
570 if (open === null) return bars
571
572 const close = <Button key="close" label="閉じる" role="dismiss" onPress={() => update($, detail, () => null)} />
573 // 押したバーの分だけを出す。context は見出し・内訳のバー・凡例、5h / 7d は名前とリセットの日時の 1 行。
574 // 使用率は下のバーと重なるので出さない
575 const window = WINDOWS.find(w => w.kind === open.kind)
576 let panel
577 if (window !== undefined) {
578 panel = (
579 <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
580 <Box flexDirection="row" gap={1}>
581 <Text bold>{window.title}</Text>
582 <Text dimColor>{windowLine(snap, window.kind, now)}</Text>
583 </Box>
584 {close}
585 </Box>
586 )
587 } else {
588 const section = contextSvg(open, snap)
589 panel = (
590 <Box flexDirection="row" gap={2} alignItems="flex-start" paddingX={1}>
591 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
592 <Svg source={section.source} alt={section.alt} />
593 </Box>
594 {close}
595 </Box>
596 )
597 }
598
599 return (
600 <Box flexDirection="column" gap={1}>
601 {panel}
602 {bars}
603 </Box>
604 )
605 })
606}
607hooks/hit.ts 18 lines1import type { ClientModule } from 'claude-code'
2
3type HitProps = { kind: string }
4
5// バーの上に透明に重ねる領域。何も描かず、押されたことだけを hooks に知らせる。
6// Svg は押せる要素で包めない(Button の中身は文字だけ)ので、押せる面を別に重ねている
7const Hit: ClientModule<HitProps, true> = (props, surface) => {
8 if (surface.state === undefined) {
9 surface.onPointer(event => {
10 if (event.type === 'up' && event.button === 'left') surface.post({ kind: props.kind })
11 })
12 surface.setState(true)
13 }
14 return surface.elements.Box({})
15}
16
17export default Hit
18types/index.d.ts 37 lines1export type UsageWindow = {
2 kind: string
3 percentUsed: number
4 resetsAt?: string
5}
6
7export type UsageSnapshot = {
8 contextPercent: number | null
9 contextTokens: number | null
10 contextWindow: number
11 // 応答の値がまだ無い間(最初の応答の前・compact の直後)に、手元の見積もりで埋めた context なら true
12 isContextEstimated: boolean
13 windows: UsageWindow[]
14 // windows を読んだ時刻(ms)。このセッションの応答・使用量 API・別のセッションの記録のうち一番新しいもの。
15 // 前のセッションから持ち越して、まだ読み直せていない値なら isCarriedOver
16 windowsReadAt: number | null
17 isCarriedOver: boolean
18}
19
20export type DetailKind = 'context' | 'five_hour' | 'seven_day'
21
22export type ContextCategoryRow = { name: string; tokens: number }
23
24// 押して開いたバーの詳細
25export type Detail = {
26 kind: DetailKind
27 // context の内訳(/context と同じ分け方の、手元での見積もり)。数え終わるまでは null
28 categories: ContextCategoryRow[] | null
29 autoCompactAt: number | null
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'usage-bars': { snapshot: UsageSnapshot | null; detail: Detail | null }
35 }
36}
37