SLOPSHOPPER

usage-bars

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

newbandnetworktimer
A shopper browsing a rack in a slop shop
README

Usage Bars for Claude Code

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.

入力欄の上に並んだ 3 本のバー

Anthropic の公式のものではありません。

出すもの

バー中身
context今のセッションの context window の使用率
5h5 時間の利用枠の使用率と、リセットまでの残り時間
7d7 日の利用枠の使用率と、リセットまでの残り時間
  • 色は 50% 未満が緑、50% 以上が黄、80% 以上が赤

バーを押したとき

押したバーの詳細を、バーの上に開く。使用率はバーと重なるので出さない。

バー出すもの
context使っている量と窓の大きさ、自動 compact が走る量、/context と同じ分け方の内訳(色分けした細いバーと、多い順に 4 つ。手元での見積もりで通信はしないので、/context の数字と少しずれることがある)
5h何時にリセットされるか(例: 今日 20:30 にリセット)
7d何月何日の何時にリセットされるか(例: 10月18日(日)12:00 にリセット)

context のバーを押したところ

  • 同じバーをもう一度押すか「閉じる」で閉じる。別のバーを押すと、そのバーの詳細に切り替わる
  • 会話の上に浮かぶ popup にはできない(mod が描く場所はどれも決まった枠の中で、会話の上に重ねる場所は無い)ので、帯が上に伸びて出る

必要なもの

  • Claude Code Desktop の Code タブ(手元で動くセッション)
  • 5h / 7d を出すには、Claude のサブスクリプション(Pro / Max)でログインしていること。API キーで使っているときは 5h / 7d が -- のままになる
  • Claude Code 2.1.295(Windows 版 Desktop に同梱のもの)で確かめた。mod の API(function hooks)は early access で、版が上がると動かなくなることがある

入れ方

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・文字 11px40px(Desktop 自身の使用量の通知と同じ高さ)
regular26px・文字 12px47px

regular の見た目

帯の上下の余白(約 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

知っておくこと

  • 色はダークテーマ向け。 バーは SVG の画像として描かれ、アプリのテーマに追従しない。ライトテーマでは確かめていない
  • 5h / 7d は 3 つの読み方のうち一番新しい値を出す。
  • このセッションの応答が運んできた値(ターンが終わるたび)
  • 使用量 API の値(/usage と同じ口)。手元の値が 5 分より古いときに読む(セッションを開いた直後と、Desktop がそのセッションの帯を描いている間)。5h / 7d のバーを押したときは、1 分より古ければ読み直す
  • 別のセッションが記録した値(1 分ごとに取り込む。通信はしない)。別のセッションで使った分も、このセッションのターンを待たずに反映される
  • 使用量 API は Claude Code のログイン情報で読む(https://api.anthropic.com/api/oauth/usage)。資格情報は Claude Code が持ったままで、mod には渡らない。マシンの全セッションで読んだ値と読みに行った時刻を共有するので、読みに行くのはマシン全体でおおよそ 5 分に 1 回、多くても 1 分に 1 回ほどになる。CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC を設定していると読まず、応答と別のセッションの値だけで出す
  • セッションを開いた直後は、最後に記録した値を出す(記録はマシンの中で全セッション共通)。記録から 5 分以内ならそれを今の値として出し、API は読まない。それより古ければ灰色で出し、API を読めたら置き換える
  • context は、最初の応答が来るまで見積もりを灰色で出す。 応答の前は使用量の数字がまだ無いので、/context と同じ分け方で手元で見積もった値(通信はしない)を出し、応答が来たら置き換える。compact の直後も同じ。システムプロンプト・ツール・メモリーの分が最初から入っているので、0% にはならない。応答が来るまでは 5 秒ごとに数え直す
  • 開いた時点の見積もりには、あとから繋がる MCP のツールと最初のメッセージの分が入らない。そこで、最初の応答が来たときの差を記録し(マシンの中で全セッション共通)、次のセッションからは開いた時点の見積もりに足す。差を記録するまでの最初の 1 回は少なめに出る
  • 送信前の「新しいセッション」画面には出ない。 Desktop は最初のメッセージを送ってから Claude Code を起動するので、その前は mod が動いていない
  • API キーや 3P プロバイダ・ゲートウェイで使っているときは 5h / 7d を出さず、記録した値も API も使わない。最初の応答が来ても 5h / 7d が空なら、灰色の値を消して -- にする
  • 残り時間の表示は 1 分ごとに描き直す
  • 同じ帯にほかの mod(archive-check など)が描くときは、その分をバーの左に並べる
  • ターミナルには出さない(ターミナルには 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 . で検査できる。

License

MIT

Source 3 files
hooks/register.tsx 607 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
607
hooks/hit.ts 18 lines
1import 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
18
types/index.d.ts 37 lines
1export 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