SLOPSHOPPER

sparkline-statusline

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

newbandtimer
v1.3.2no licenseupdated 2026-10-06kuu13580/dotfiles/plugins/sparkline-statusline
A shopper browsing a rack in a slop shop
README

sparkline-statusline

Claude Codeのステータスラインにスパークラインゲージでコンテキスト使用率・レートリミット・prompt cache を表示するプラグインです。Claude Code 専用です (Antigravity 非対応)。

表示内容

  • ctx: コンテキストウィンドウ使用率
  • 5h: 5時間レートリミット使用率(リセット時刻付き)
  • 7d: 7日間レートリミット使用率
  • cache: prompt cache の状態 (● warm / ○ cold) と cold になる時刻

出力例

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

Desktop app (mod)

同梱の mod (hooks/register.tsx) が Desktop app の Code tab でプロンプト上の band に同じ形式で表示します (モデル名は除く)。terminal では描画せず statusline に任せます。

mod は statusline の prompt_cache を受け取れないため、cache は model 切り替え時に TTL を取得できた場合のみ表示します (切り替え直後は次の応答まで ○)。

表示仕様 (statusline.py と register.tsx で揃える)

  • ゲージ: 8 文字、 ▁▂▃▄▅▆▇█ の 9 段階
  • 幅不足時: ○◔◑◕● の 1 文字 (12.5% 刻みで切り替え)。CLI は COLUMNS - 4、Desktop は band の bodyColumns と比較
  • 色: 50% 未満は rgb(pct*5.1, 200, 80)、50% 以上は rgb(255, 200-(pct-50)*4, 60)
  • 区切り │ 、ラベル・補足は dim、時刻は HH:MM (ローカル)

PR 番号を clickable にしたい場合

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/

Source 2 files
hooks/register.tsx 153 lines
1import { 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}
153
types/index.d.ts 16 lines
1export 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