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

Small mods for Claude Code, built on function hooks.
| Mod | What it does |
|---|---|
| token-usage-panel | Above 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-switcher | Lists your open Claude Code sessions in a side column. Click a row to switch to that tab. /session-switcher |
token-usage-panel
/context breakdown (system prompt, tools, skills, messages, and so on) with used / max tokenssession-switcher
/rename title or Claude's auto title~/.claude/settings.json and restart Claude Code: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
/plugin marketplace add BrainRev/claude-session-panels
/plugin install token-usage-panel@claude-session-panels
/plugin install session-switcher@claude-session-panels
The mods show English or Japanese. They pick the first that applies:
CLAUDE_PANELS_LANG=en or CLAUDE_PANELS_LANG=ja (any other value means English)language setting (/config, or "language" in ~/.claude/settings.json) is JapaneseLC_ALL or LANG starts with jaTo fix the language, add it to ~/.claude/settings.json and restart Claude Code:
{ "env": { "CLAUDE_PANELS_LANG": "ja" } }
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.
https://status.claude.com/api/v2/status.json every 5 minutes for the Claude status row. Nothing else leaves your machine.set -g set-titles on to ~/.tmux.conf so Ghostty sees the tab titles.MIT
Claude Code の function hooks で作った小さな mod です。
| mod | すること |
|---|---|
| token-usage-panel | 入力欄の上に、トークン消費の欄(このセッションの消費・5時間枠と週枠・Claude の稼働状況)と、/context の内訳を色分けした横棒を出します。返答のたびに数え直します。 |
| session-switcher | 開いている Claude Code のセッションを右の列に並べます。行を押すとそのタブへ切り替わります。/session-switcher |
token-usage-panel
/context の内訳(システムプロンプト・道具・スキル・会話など)を色分けした横棒と、使用量/上限のトークン数session-switcher
/rename で付けた題名か、Claude が付けた題名~/.claude/settings.json に次を足し、Claude Code を起動し直します。 { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
/plugin marketplace add BrainRev/claude-session-panels
/plugin install token-usage-panel@claude-session-panels
/plugin install session-switcher@claude-session-panels
画面の言葉は英語と日本語の2つです。上から順に、最初に当てはまったもので決まります。
CLAUDE_PANELS_LANG=en または CLAUDE_PANELS_LANG=ja(ほかの値は英語)language(/config、または ~/.claude/settings.json の "language")が日本語なら日本語LC_ALL か LANG が ja で始まれば日本語言語を固定するときは、~/.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 を使います。
https://status.claude.com/api/v2/status.json を5分ごとに読みます。ほかに外へ送るものはありません。~/.tmux.conf に set -g set-titles on を足すと、Ghostty がタブの題名を読めるようになります。MIT
hooks/register.tsx 136 lines1import { 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}
136hooks/lang.ts 28 lines1// 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}
28hooks/text.ts 43 lines1// 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}
43hooks/tokens.ts 156 lines1// 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}
156types/index.d.ts 21 lines1export 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