スパークラインゲージでコンテキスト使用率・レートリミット・prompt cache を表示するステータスライン

Claude Codeのステータスラインにスパークラインゲージでコンテキスト使用率・レートリミット・prompt cache を表示するプラグインです。Claude Code 専用です (Antigravity 非対応)。
Claude Opus 4.6 │ ctx ████▇ 62% │ 5h █▁ 15% (reset 18:30) │ 7d ▁ 3% │ cache ● ~14:32
1 行が幅に収まらない場合は、ゲージを円 1 文字に切り替えます。
Claude Opus 4.6 │ ctx ◑ 62% │ 5h ◔ 15% (reset 18:30) │ 7d ○ 3% │ cache ● ~14:32
同梱の mod (hooks/register.tsx) が Desktop app の Code tab でプロンプト上の band に同じ形式で表示します (モデル名は除く)。terminal では描画せず statusline に任せます。
mod は statusline の prompt_cache を受け取れないため、cache は model 切り替え時に TTL を取得できた場合のみ表示します (切り替え直後は次の応答まで ○)。
▁▂▃▄▅▆▇█ の 9 段階○◔◑◕● の 1 文字 (12.5% 刻みで切り替え)。CLI は COLUMNS - 4、Desktop は band の bodyColumns と比較rgb(pct*5.1, 200, 80)、50% 以上は rgb(255, 200-(pct-50)*4, 60) │ 、ラベル・補足は dim、時刻は HH:MM (ローカル)PR 番号のリンク表示は本プラグインではなく Claude Code 標準の footer PR バッジが担当します (statusline の1つ下に自動表示)。対応端末では OSC 8 で clickable になります。
Windows Terminal などで下線が出ず clickable にならない場合は、Claude Code の terminal auto-detection をバイパスする FORCE_HYPERLINK を設定してから起動します:
FORCE_HYPERLINK=1 claude
PowerShell の場合:
$env:FORCE_HYPERLINK = "1"; claude
詳細: Claude Code Docs — Customize your status line (Troubleshooting)
プラグインを有効にすると、セッション開始時に ~/.claude/settings.json の statusLine が自動設定されます (refreshInterval: 60 付き)。手動設定は不要です。
https://nyosegawa.com/posts/claude-code-statusline-rate-limits/
hooks/register.tsx 153 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { CacheState, RateLimit, Usage } from '../types'
5
6// Desktop-only band. The terminal uses scripts/statusline.py, which has richer prompt_cache data.
7const usage = atom({ plugin: 'sparkline-statusline', key: 'usage' } as const, { rateLimits: [] } as Usage)
8const EMPTY_CACHE: CacheState = { lastResponseAt: null, lastHadCache: false, ttl: null }
9const cache = atom({ plugin: 'sparkline-statusline', key: 'cache' } as const, EMPTY_CACHE)
10
11const SPARKS = ' ▁▂▃▄▅▆▇█'
12const CIRCLES = '○◔◑◕●'
13const GREEN = '#00c850'
14const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
15
16export const gradient = (pct: number): string => {
17 const hex = (n: number) => Math.max(0, Math.min(255, Math.trunc(n))).toString(16).padStart(2, '0')
18 return pct < 50 ? `#${hex(pct * 5.1)}c850` : `#ff${hex(200 - (pct - 50) * 4)}3c`
19}
20
21export const sparkGauge = (pct: number, width = 8): string => {
22 const level = Math.min(Math.max(pct, 0), 100) / 100
23 let gauge = ''
24 for (let i = 0; i < width; i++) {
25 const start = i / width
26 const end = (i + 1) / width
27 if (level >= end) gauge += SPARKS[8]
28 else if (level <= start) gauge += SPARKS[0]
29 else gauge += SPARKS[Math.trunc(((level - start) / (end - start)) * 8)]
30 }
31 return gauge
32}
33
34export const circle = (pct: number): string =>
35 CIRCLES[Math.min(Math.trunc(Math.min(Math.max(pct, 0), 100) / 25 + 0.5), 4)] ?? CIRCLES[0]!
36
37const hhmm = (ms: number): string => {
38 const d = new Date(ms)
39 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
40}
41
42const toUsage = (context: SessionContextUsage, rateLimits: SessionRateLimit[]): Usage => ({
43 ctxPercent: context.percent,
44 rateLimits: rateLimits.map(({ kind, percentUsed, resetsAt }): RateLimit => ({ kind, percentUsed, resetsAt })),
45})
46
47type Piece = { text: string; color?: string; dim?: boolean }
48
49export const register: Register = on => {
50 let expiryTimer: { cancel: () => void } | undefined
51
52 on('session.start', async ($, e, next) => {
53 const { context, rateLimits } = await $.session.usage()
54 await update($, usage, () => toUsage(context, rateLimits))
55 return next(e)
56 })
57
58 on('session.measure', async ($, e, next) => {
59 await update($, usage, () => toUsage(e.context, e.rateLimits))
60 return next(e)
61 })
62
63 on('turn.complete', async ($, e, next) => {
64 const result = await next(e)
65 if (e.agentId === undefined && e.usage) {
66 const u = e.usage
67 const now = await $.clock.now()
68 const state = await update($, cache, c => ({
69 ...c,
70 lastResponseAt: now,
71 lastHadCache: u.cache_read_input_tokens + u.cache_creation_input_tokens > 0,
72 }))
73 expiryTimer?.cancel()
74 if (state.ttl) expiryTimer = $.clock.after(TTL_MS[state.ttl], () => $.ui.invalidate('ui.render'))
75 }
76 return result
77 })
78
79 // A model switch forfeits the cached prefix, so stay cold until the next response.
80 on('classic.PostModelSwitch', async ($, e, next) => {
81 expiryTimer?.cancel()
82 await update($, cache, c => ({ ...c, ttl: e.cache_ttl, lastHadCache: false }))
83 return next(e)
84 })
85
86 // /clear and resume keep the process but start another conversation, with no session.start.
87 on('session.end', async ($, e, next) => {
88 if (e.reason === 'clear' || e.reason === 'resume') {
89 expiryTimer?.cancel()
90 await update($, cache, c => ({ ...EMPTY_CACHE, ttl: c.ttl }))
91 }
92 return next(e)
93 })
94
95 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
96 if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
97
98 const u = await read($, usage)
99 const c = await read($, cache)
100 const now = await $.clock.now()
101 const five = u.rateLimits.find(r => r.kind === 'five_hour')
102 const week = u.rateLimits.find(r => r.kind === 'seven_day')
103
104 const gauge = (label: string, pct: number, compact: boolean): Piece[] => [
105 { text: label, dim: true },
106 { text: ' ' },
107 { text: compact ? circle(pct) : sparkGauge(pct), color: gradient(pct) },
108 { text: ` ${Math.round(pct)}%` },
109 ]
110
111 const segments = (compact: boolean): Piece[][] => {
112 const list: Piece[][] = []
113 if (u.ctxPercent !== undefined) list.push(gauge('ctx', u.ctxPercent, compact))
114 if (five) {
115 const reset: Piece[] = five.resetsAt ? [{ text: ` (reset ${hhmm(Date.parse(five.resetsAt))})`, dim: true }] : []
116 list.push([...gauge('5h', five.percentUsed, compact), ...reset])
117 }
118 if (week) list.push(gauge('7d', week.percentUsed, compact))
119 if (c.ttl && c.lastResponseAt !== null) {
120 const expiresAt = c.lastResponseAt + TTL_MS[c.ttl]
121 list.push(
122 c.lastHadCache && now < expiresAt
123 ? [{ text: 'cache', dim: true }, { text: ' ' }, { text: '●', color: GREEN }, { text: ` ~${hhmm(expiresAt)}`, dim: true }]
124 : [{ text: 'cache ○', dim: true }],
125 )
126 }
127 return list
128 }
129
130 const width = (list: Piece[][]) =>
131 list.reduce((sum, pieces) => sum + pieces.reduce((n, p) => n + p.text.length, 0), 0) + (list.length - 1) * 3
132 const full = segments(false)
133 if (full.length === 0) return next(e)
134 const row = width(full) > e.props.bodyColumns ? segments(true) : full
135
136 const { Box, Text } = $.ui.resolve(e)
137 return (
138 <Box flexDirection="row" flexWrap="wrap">
139 {row.map((pieces, i) => (
140 <Text>
141 {i > 0 && <Text dimColor> │ </Text>}
142 {pieces.map(p => (
143 <Text color={p.color} dimColor={p.dim}>
144 {p.text}
145 </Text>
146 ))}
147 </Text>
148 ))}
149 </Box>
150 )
151 })
152}
153types/index.d.ts 16 lines1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = { ctxPercent?: number; rateLimits: RateLimit[] }
4
5export type CacheState = {
6 lastResponseAt: number | null
7 lastHadCache: boolean
8 ttl: '5m' | '1h' | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'sparkline-statusline': { usage: Usage; cache: CacheState }
14 }
15}
16