SLOPSHOPPER

token-usage-panel

Shows a token usage panel (session cost, 5-hour and weekly limits, Claude status) and a color bar of the /context breakdown above the prompt

newbandnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage-panel
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─ ⋆ ✦ Token usage ✦ ⋆ ─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─· This session All sessions Base per turn : $0.05 5-hour limit : 31% (first turn after reset : $0.97) Weekly limit : — Session total : $0.42 Cache resets in : — Claude status: — ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─ ⋆ ✦ Token usage ✦ ⋆ ─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─·─· This session All sessions Base per turn : $0.05 5-hour limit : 31% (first turn after reset : $0.97) Weekly limit : — Session total : $0.42 Cache resets in : — Claude status: —
README

claude-session-panels

Small mods for Claude Code, built on function hooks.

ModWhat it does
token-usage-panelAbove the prompt: a token usage panel (session cost, 5-hour and weekly limits, Claude status) and a color bar of the /context breakdown. Refreshes after every reply.
session-switcherLists your open Claude Code sessions in a side column. Click a row to switch to that tab. /session-switcher

Features

token-usage-panel

  • This session: base cost per turn, the first turn after a cache reset, session total, and minutes left until the prompt cache expires
  • All sessions: 5-hour and weekly limit usage with reset times, and Claude's service status
  • A color bar of the /context breakdown (system prompt, tools, skills, messages, and so on) with used / max tokens
  • Fits the terminal width: two columns when wide, stacked when narrow

session-switcher

  • One row per open Claude Code session, named by its /rename title or Claude's auto title
  • Marks your own session, sessions that are working, and tabs waiting for you (🔔)
  • Click a row to jump to that tab (Ghostty on macOS)

Install

  1. Function hooks are opt-in. Add this to ~/.claude/settings.json and restart Claude Code:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
  1. In Claude Code:
   /plugin marketplace add BrainRev/claude-session-panels
   /plugin install token-usage-panel@claude-session-panels
   /plugin install session-switcher@claude-session-panels

Language

The mods show English or Japanese. They pick the first that applies:

  1. CLAUDE_PANELS_LANG=en or CLAUDE_PANELS_LANG=ja (any other value means English)
  2. Japanese if Claude Code's language setting (/config, or "language" in ~/.claude/settings.json) is Japanese
  3. Japanese if LC_ALL or LANG starts with ja
  4. English

To fix the language, add it to ~/.claude/settings.json and restart Claude Code:

{ "env": { "CLAUDE_PANELS_LANG": "ja" } }

Turn off

claude plugin disable token-usage-panel@claude-session-panels
claude plugin disable session-switcher@claude-session-panels

Use uninstall instead of disable to remove a mod.

Notes

  • token-usage-panel reads https://status.claude.com/api/v2/status.json every 5 minutes for the Claude status row. Nothing else leaves your machine.
  • Costs are estimates from public list prices.
  • session-switcher switches tabs only in Ghostty on macOS. Elsewhere it shows the list only. If you run Claude inside tmux, add set -g set-titles on to ~/.tmux.conf so Ghostty sees the tab titles.

License

MIT


claude-session-panels(日本語)

Claude Code の function hooks で作った小さな mod です。

modすること
token-usage-panel入力欄の上に、トークン消費の欄(このセッションの消費・5時間枠と週枠・Claude の稼働状況)と、/context の内訳を色分けした横棒を出します。返答のたびに数え直します。
session-switcher開いている Claude Code のセッションを右の列に並べます。行を押すとそのタブへ切り替わります。/session-switcher

できること

token-usage-panel

  • このセッション:1回の返答のベース消費量、キャッシュが切れた後の1回目、累計、キャッシュが切れるまでの残り分数
  • 全セッション合計:5時間枠と週枠の使用率と戻る時刻、Claude の稼働状況
  • /context の内訳(システムプロンプト・道具・スキル・会話など)を色分けした横棒と、使用量/上限のトークン数
  • 端末の幅に合わせる:広いと2列、狭いと縦に並べる

session-switcher

  • 開いている Claude Code のセッションを1行ずつ。名前は /rename で付けた題名か、Claude が付けた題名
  • 自分のセッション、作業中のセッション、返事を待っているタブ(🔔)に印を付ける
  • 行を押すとそのタブへ移る(macOS の Ghostty)

入れ方

  1. function hooks は既定で切れています。~/.claude/settings.json に次を足し、Claude Code を起動し直します。
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
  1. Claude Code で次を打ちます。
   /plugin marketplace add BrainRev/claude-session-panels
   /plugin install token-usage-panel@claude-session-panels
   /plugin install session-switcher@claude-session-panels

表示の言語

画面の言葉は英語と日本語の2つです。上から順に、最初に当てはまったもので決まります。

  1. 環境変数 CLAUDE_PANELS_LANG=en または CLAUDE_PANELS_LANG=ja(ほかの値は英語)
  2. Claude Code の設定 language(/config、または ~/.claude/settings.json の "language")が日本語なら日本語
  3. LC_ALL か LANG が ja で始まれば日本語
  4. それ以外は英語

言語を固定するときは、~/.claude/settings.json に次を足して Claude Code を起動し直します。

{ "env": { "CLAUDE_PANELS_LANG": "ja" } }

止め方

claude plugin disable token-usage-panel@claude-session-panels
claude plugin disable session-switcher@claude-session-panels

消すときは disable の代わりに uninstall を使います。

補足

  • token-usage-panel は Claude の稼働状況の行のために https://status.claude.com/api/v2/status.json を5分ごとに読みます。ほかに外へ送るものはありません。
  • 金額は公開されている定価からの見積もりです。
  • session-switcher がタブを切り替えられるのは macOS の Ghostty だけです。ほかの端末では一覧だけを出します。tmux の中で Claude を動かす場合は ~/.tmux.conf に set -g set-titles on を足すと、Ghostty がタブの題名を読めるようになります。

ライセンス

MIT

Source 5 files
hooks/register.tsx 136 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snap, Tok } from '../types'
5import { pickLang, settingFrom, type Lang } from './lang'
6import { TEXT } from './text'
7import { divider, pack, rows, stack } from './tokens'
8
9const snap = atom({ plugin: 'token-usage-panel', key: 'snap' } as const, null as Snap | null)
10const tok = atom({ plugin: 'token-usage-panel', key: 'tok' } as const, { ctx: null, model: '', cost: null, limits: [], lastAt: null, per: null, status: null, now: 0 } as Tok)
11const lang = atom({ plugin: 'token-usage-panel', key: 'lang' } as const, null as Lang | null)
12const W = 60
13const k = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${+(n / 1e3).toFixed(1)}k` : `${n}`)
14const STATUS_URL = 'https://status.claude.com/api/v2/status.json'
15
16// Inputs for pickLang: CLAUDE_PANELS_LANG, Claude Code's `language` setting, LC_ALL, LANG.
17async function langOf($: any): Promise<Lang> {
18  const panels = await $.env.get('CLAUDE_PANELS_LANG')
19  if (panels?.trim()) return pickLang({ panels })
20  let setting: string | undefined
21  try {
22    const row = (await $.config.list()).find((r: any) => r.key === 'language')
23    if (typeof row?.value === 'string' && row.value) setting = row.value
24  } catch {
25    setting = undefined // no config API here: fall back to the file
26  }
27  if (!setting) {
28    try {
29      const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
30      setting = settingFrom(await $.fs.read(`${dir}/settings.json`))
31    } catch {
32      setting = undefined // no settings file
33    }
34  }
35  return pickLang({ setting, lcAll: await $.env.get('LC_ALL'), lang: await $.env.get('LANG') })
36}
37
38async function refresh($: any) {
39  const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
40  if (!b) return
41  const segs = b.categories.filter((c: any) => c.kind !== 'deferred').map((c: any) => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
42  await update($, snap, () => ({ total: b.totalTokens, max: b.rawMaxTokens, percent: b.percentage, segs }))
43}
44
45// Context, cost, limits, model. With stamp, a cost increase marks the last reply time
46// (context can move before any reply, so only a cost increase counts).
47async function measure($: any, stamp: boolean) {
48  const u = await $.session.usage()
49  const model = await $.session.model()
50  const now = await $.clock.now()
51  await update($, tok, t => ({
52    ...t,
53    ctx: u.context.tokens ?? null,
54    model,
55    cost: u.cost?.usd ?? null,
56    limits: u.rateLimits.map((l: any) => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
57    lastAt: stamp && (u.cost?.usd ?? 0) > (t.cost ?? 0) ? now : t.lastAt,
58    now,
59  }))
60}
61
62async function readStatus($: any) {
63  let s: string | null = null
64  try {
65    const r = await $.http.fetch(STATUS_URL)
66    if (r.ok) s = JSON.parse(r.text)?.status?.indicator ?? null
67  } catch {
68    s = null
69  }
70  await update($, tok, t => ({ ...t, status: s }))
71}
72
73const tick = async ($: any) => {
74  const now = await $.clock.now()
75  await update($, tok, t => ({ ...t, now }))
76}
77
78export const register: Register = on => {
79  on('session.start', async ($, e, next) => {
80    const lg = await langOf($)
81    await update($, lang, () => lg)
82    void refresh($)
83    void measure($, false)
84    void readStatus($)
85    $.clock.every(60_000, () => void tick($))
86    $.clock.every(5 * 60_000, () => void readStatus($))
87    return next(e)
88  })
89  on('session.measure', async ($, e, next) => {
90    const r = await next(e)
91    if (e.changed.includes('context')) await refresh($)
92    if (e.changed.some(c => c === 'cost' || c === 'context' || c === 'rateLimits')) await measure($, e.changed.includes('cost') || e.changed.includes('context'))
93    return r
94  })
95  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
96    const s = await read($, snap)
97    const t = await read($, tok)
98    // decided at session.start; a render before that asks directly (drawing cannot write state)
99    const x = TEXT[(await read($, lang)) ?? (await langOf($))]
100    const { Box, Text } = $.ui.resolve(e)
101    const rs = rows(t, t.now || Date.now(), x)
102    const cols = e.props.bodyColumns || 80
103    const bw = Math.max(10, Math.min(W, cols - 22))
104    const tokens = (
105      <Box flexDirection="column">
106        <Text dimColor>{divider(cols, x)}</Text>
107        {stack(rs, cols, x).map((r, i) => (
108          <Text wrap="truncate" dimColor={i === 0 || r.left === x.all}>
109            {r.left}
110            {r.right ? <Text color={r.right.color}>{r.right.text}</Text> : ''}
111          </Text>
112        ))}
113      </Box>
114    )
115    if (!s) return <Box flexDirection="column">{tokens}</Box>
116    const used = s.segs.filter(x => x.kind !== 'free')
117    const cells = used.map(x => ({ ...x, n: x.tokens > 0 ? Math.max(1, Math.round((x.tokens / s.max) * bw)) : 0 }))
118    const legend = used.filter(c => c.tokens > 0)
119    const rest = Math.max(0, bw - cells.reduce((a, c) => a + c.n, 0))
120    return (
121      <Box flexDirection="column">
122        {tokens}
123        <Text dimColor>{x.breakdown}</Text>
124        <Text>
125          {cells.map(c => <Text color={c.color}>{'█'.repeat(c.n)}</Text>)}
126          <Text dimColor>{'░'.repeat(rest)}</Text>
127          <Text> {k(s.total)}/{k(s.max)} ({s.percent}%)</Text>
128        </Text>
129        {pack(legend.map(c => `■ ${c.name} ${k(c.tokens)}`), cols).map(line => (
130          <Text>{line.map((i, j) => <Text>{j ? '  ' : ''}<Text color={legend[i]!.color}>■</Text>{` ${legend[i]!.name} ${k(legend[i]!.tokens)}`}</Text>)}</Text>
131        ))}
132      </Box>
133    )
134  })
135}
136
hooks/lang.ts 28 lines
1// Picks the display language: en or ja. Pure; register.tsx gathers the inputs.
2
3export type Lang = 'en' | 'ja'
4
5export type LangInputs = { panels?: string; setting?: string; lcAll?: string; lang?: string }
6
7const JA_SETTING = /^(ja\b|ja[-_]|japanese|日本語|nihongo)/i
8
9/** 1. CLAUDE_PANELS_LANG, 2. Claude Code's `language` setting, 3. LC_ALL / LANG, 4. en. */
10export function pickLang({ panels, setting, lcAll, lang }: LangInputs): Lang {
11  const p = panels?.trim().toLowerCase()
12  if (p) return p === 'ja' ? 'ja' : 'en'
13  if (setting && JA_SETTING.test(setting.trim())) return 'ja'
14  const loc = lcAll || lang
15  if (loc && /^ja/i.test(loc)) return 'ja'
16  return 'en'
17}
18
19/** The `language` value from a settings.json text; undefined when absent or not JSON. */
20export function settingFrom(json: string): string | undefined {
21  try {
22    const v = JSON.parse(json)?.language
23    return typeof v === 'string' ? v : undefined
24  } catch {
25    return undefined // not JSON
26  }
27}
28
hooks/text.ts 43 lines
1// Every word the panel shows, per language. Code does not hold display strings.
2import type { Lang } from './lang'
3
4export type Text = {
5  title: string
6  session: string
7  all: string
8  left: [string, string, string, string]
9  right: [string, string, string]
10  ok: string
11  degraded: string
12  expired: string
13  minLeft: (m: number) => string
14  breakdown: string
15}
16
17export const TEXT: Record<Lang, Text> = {
18  en: {
19    title: 'Token usage',
20    session: 'This session',
21    all: 'All sessions',
22    left: ['Base per turn', '(first turn after reset', 'Session total', 'Cache resets in'],
23    right: ['5-hour limit', 'Weekly limit', 'Claude status'],
24    ok: 'OK',
25    degraded: 'Degraded',
26    expired: 'expired',
27    minLeft: m => `${m} min left`,
28    breakdown: 'Context breakdown',
29  },
30  ja: {
31    title: 'トークン消費',
32    session: 'セッション内',
33    all: '全セッション合計',
34    left: ['ベース消費量', '(キャッシュリセット後1度', '累計消費量', 'キャッシュリセットまで'],
35    right: ['5時間枠', '週枠', 'Claude状態'],
36    ok: '正常',
37    degraded: '障害あり',
38    expired: '切れています',
39    minLeft: m => `あと ${m}分`,
40    breakdown: 'コンテキストの内訳',
41  },
42}
43
hooks/tokens.ts 156 lines
1// Cost math and formatting for the token usage panel. Pure functions.
2import type { Limit, Tok } from '../types'
3import { TEXT, type Text } from './text'
4
5// Input price in $ per 1M tokens.
6export function inputRate(model: string): number {
7  if (model.includes('fable') || model.includes('mythos')) return 10
8  if (model.includes('opus')) {
9    if (model.includes('opus-4-1') || model.includes('opus-4-0')) return 15
10    if (/opus-4(-|$)/.test(model)) return 15
11    return 5
12  }
13  if (model.includes('sonnet')) return model.includes('sonnet-5') ? 2 : 3
14  if (model.includes('haiku')) return model.includes('haiku-3') ? 0.8 : 1
15  return 5
16}
17
18// Cache-read multiplier.
19export function readFactor(model: string): number {
20  return model.includes('fable-5-1') || model.includes('mythos-5-1') ? 0.025 : 0.1
21}
22
23export const TTL_MS = 60 * 60 * 1000
24
25export const money = (d: number) => (d >= 10 ? `$${d.toFixed(1)}` : `$${d.toFixed(2)}`)
26
27export function amount(d: number, digits: number, per: number | null): string {
28  return per ? `${(d / per).toFixed(digits)}% (${money(d)})` : money(d)
29}
30
31export const pct = (d: number, per: number | null) => (per ? `${(d / per).toFixed(3)}%` : money(d))
32
33export function cacheText(lastAt: number | null, now: number, s: Text = TEXT.en): string {
34  if (lastAt == null) return '—'
35  const left = TTL_MS - (now - lastAt)
36  if (left <= 0) return s.expired
37  return s.minLeft(Math.ceil(left / 60000))
38}
39
40const p2 = (n: number) => String(n).padStart(2, '0')
41
42// 5-hour limit: reset time, HH:mm today or M/d HH:mm otherwise
43export function resetClock(at: number, now: number): string {
44  const a = new Date(at)
45  const n = new Date(now)
46  const hm = `${p2(a.getHours())}:${p2(a.getMinutes())}`
47  const same = a.getFullYear() === n.getFullYear() && a.getMonth() === n.getMonth() && a.getDate() === n.getDate()
48  return same ? hm : `${a.getMonth() + 1}/${a.getDate()} ${hm}`
49}
50
51// Weekly limit: time left as XdYh / Yh / Zm
52export function remain(ms: number): string {
53  const m = Math.max(0, Math.floor(ms / 60000))
54  const d = Math.floor(m / 1440)
55  const h = Math.floor((m % 1440) / 60)
56  if (d > 0) return `${d}d${h}h`
57  if (h > 0) return `${h}h`
58  return `${m}m`
59}
60
61const resetAt = (l: Limit) => (l.resetsAt ? Date.parse(l.resetsAt) : NaN)
62
63export function limitText(l: Limit | undefined, now: number, kind: 'five_hour' | 'seven_day'): string {
64  if (!l) return '—'
65  const at = resetAt(l)
66  if (Number.isFinite(at) && at <= now) return '—'
67  const p = `${Math.round(l.percentUsed)}%`
68  if (!Number.isFinite(at)) return p
69  return `${p} (${kind === 'five_hour' ? resetClock(at, now) : remain(at - now)})`
70}
71
72export function level(l: Limit | undefined, now: number): 'red' | 'yellow' | undefined {
73  if (!l) return undefined
74  const at = resetAt(l)
75  if (Number.isFinite(at) && at <= now) return undefined
76  return l.percentUsed >= 90 ? 'red' : l.percentUsed >= 75 ? 'yellow' : undefined
77}
78
79// Display width: East Asian wide chars take 2 columns, symbols like — and ■ take 1
80const wide = (c: number) =>
81  (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) ||
82  (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || c >= 0x20000
83export function width(s: string): number {
84  let w = 0
85  for (const ch of s) w += wide(ch.codePointAt(0) ?? 0) ? 2 : 1
86  return w
87}
88export const pad = (s: string, w: number) => s + ' '.repeat(Math.max(0, w - width(s)))
89
90export type Cell = { text: string; color?: 'red' | 'yellow' }
91export type Row = { left: string; right: Cell | null }
92
93export const COL = 46
94const lw = (s: Text) => Math.max(...s.left.map(width)) + 1
95const rw = (s: Text) => Math.max(...s.right.map(width))
96
97export const header = (s: Text = TEXT.en) => `${pad(s.session, COL)}${s.all}`
98
99// Four rows: left = this session, right = account-wide limits
100export function rows(t: Tok, now: number, s: Text = TEXT.en): Row[] {
101  const ctx = t.ctx
102  const rate = inputRate(t.model)
103  const base = ctx == null ? '—' : amount((ctx * rate * readFactor(t.model)) / 1e6, 4, t.per)
104  const cold = ctx == null ? '—' : `${pct((ctx * rate * 2.0) / 1e6, t.per)})`
105  const total = t.cost == null ? '—' : amount(t.cost, 3, t.per)
106  const five = t.limits.find(l => l.kind === 'five_hour')
107  const week = t.limits.find(l => l.kind === 'seven_day')
108  const status: Cell = t.status == null ? { text: '—' } : t.status === 'none' ? { text: s.ok } : { text: s.degraded, color: 'yellow' }
109  const RW = rw(s), LW = lw(s)
110  const right = (label: string, c: Cell): Cell => ({ text: `${pad(label, RW)}: ${c.text}`, color: c.color })
111  const left = (i: number, v: string) => pad(`${pad(s.left[i]!, LW)}: ${v}`, COL)
112  return [
113    { left: left(0, base), right: right(s.right[0], { text: limitText(five, now, 'five_hour'), color: level(five, now) }) },
114    { left: left(1, cold), right: right(s.right[1], { text: limitText(week, now, 'seven_day'), color: level(week, now) }) },
115    { left: left(2, total), right: null },
116    { left: left(3, cacheText(t.lastAt, now, s)), right: right(s.right[2], status) },
117  ]
118}
119
120// Title rule centered to the exact width; drops ornaments when narrow
121export function divider(cols: number, s: Text = TEXT.en): string {
122  const TITLE = s.title
123  for (const mid of [` ⋆ ✦ ${TITLE} ✦ ⋆ `, ` ✦ ${TITLE} ✦ `, TITLE]) {
124    const rest = cols - width(mid)
125    if (rest < 0) continue
126    const fill = (n: number, from: number) => Array.from({ length: n }, (_, i) => ((i + from) % 2 ? '·' : '─')).join('')
127    const l = Math.floor(rest / 2)
128    return fill(l, 0) + mid + fill(rest - l, l + width(mid))
129  }
130  return TITLE
131}
132
133// Below this width the right column moves under the left one
134export const sideMin = (s: Text = TEXT.en) => COL + rw(s) + 2 + 14
135export function stack(rs: Row[], cols: number, s: Text = TEXT.en): { left: string; right: Cell | null }[] {
136  if (cols >= sideMin(s)) return [{ left: header(s), right: null }, ...rs]
137  return [
138    { left: s.session, right: null }, ...rs.map(r => ({ left: r.left.trimEnd(), right: null })),
139    { left: s.all, right: null }, ...rs.filter(r => r.right).map(r => ({ left: '', right: r.right })),
140  ]
141}
142
143// Pack legend items into lines that fit; never split an item
144export function pack(items: string[], cols: number, gap = 2): number[][] {
145  const lines: number[][] = []
146  let cur: number[] = [], used = 0
147  items.forEach((s, i) => {
148    const w = width(s)
149    if (cur.length && used + gap + w > cols) { lines.push(cur); cur = []; used = 0 }
150    used += (cur.length ? gap : 0) + w
151    cur.push(i)
152  })
153  if (cur.length) lines.push(cur)
154  return lines
155}
156
types/index.d.ts 21 lines
1export type Seg = { name: string; tokens: number; color: string; kind: string }
2export type Snap = { total: number; max: number; percent: number; segs: Seg[] }
3export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
4// Panel values; null when unknown
5export type Tok = {
6  ctx: number | null
7  model: string
8  cost: number | null
9  limits: Limit[]
10  lastAt: number | null
11  per: number | null
12  status: string | null
13  now: number
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'token-usage-panel': { snap: Snap | null; tok: Tok; lang: 'en' | 'ja' | null }
19  }
20}
21