Lists your open Claude Code sessions in a side pane; click one to jump to its Ghostty tab (macOS)

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 147 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import { pickLang, settingFrom, type Lang } from './lang'
5import { TEXT } from './text'
6import { buildRows, lineOf } from './tree'
7import type { SessionSwitcherListing, SessionSwitcherTargets } from '../types'
8
9const PANE = 'session-switcher'
10const HERE = (import.meta as ImportMeta & { url: string }).url
11// decode so a plugin path with non-ASCII characters reaches node intact
12const MATCH = decodeURIComponent(new URL('../scripts/tabmatch.mjs', HERE).pathname)
13const GHOSTTY = decodeURIComponent(new URL('../scripts/ghostty.applescript', HERE).pathname)
14// presses per session, so repeated clicks cycle through tabs that share a title
15const presses = atom({ plugin: 'session-switcher', key: 'presses' } as const, {} as Record<string, number>)
16const listing = atom({ plugin: 'session-switcher', key: 'listing' } as const, { tabs: [], error: '' } as SessionSwitcherListing)
17// tabs matched on each refresh, so a click needs a single select
18const targets = atom({ plugin: 'session-switcher', key: 'targets' } as const, {} as SessionSwitcherTargets)
19const frame = atom({ plugin: 'session-switcher', key: 'frame' } as const, 0)
20const lang = atom({ plugin: 'session-switcher', key: 'lang' } as const, null as Lang | null)
21const SPIN_MS = 400
22const messageOf = (err: unknown) => (err as Error | undefined)?.message ?? String(err)
23
24// Inputs for pickLang: CLAUDE_PANELS_LANG, Claude Code's `language` setting, LC_ALL, LANG.
25async function langOf($: any): Promise<Lang> {
26 const panels = await $.env.get('CLAUDE_PANELS_LANG')
27 if (panels?.trim()) return pickLang({ panels })
28 let setting: string | undefined
29 try {
30 const row = (await $.config.list()).find((r: any) => r.key === 'language')
31 if (typeof row?.value === 'string' && row.value) setting = row.value
32 } catch {
33 setting = undefined // no config API here: fall back to the file
34 }
35 if (!setting) {
36 try {
37 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
38 setting = settingFrom(await $.fs.read(`${dir}/settings.json`))
39 } catch {
40 setting = undefined // no settings file
41 }
42 }
43 return pickLang({ setting, lcAll: await $.env.get('LC_ALL'), lang: await $.env.get('LANG') })
44}
45
46export const register: Register = on => {
47 on('session.start', async ($, e, next) => {
48 const lg = await langOf($)
49 await update($, lang, () => lg)
50 const x = TEXT[lg]
51 await $.command.register({ name: 'session-switcher', description: x.command })
52 const self = await $.session.id()
53 let spin: Timer | undefined
54 const spinWhileBusy = (busy: boolean) => {
55 if (busy && spin === undefined) spin = $.clock.every(SPIN_MS, () => void update($, frame, k => (k + 1) % 4))
56 if (!busy && spin !== undefined) {
57 spin.cancel()
58 spin = undefined
59 }
60 }
61 const refresh = async () => {
62 try {
63 const { exitCode, stdout, stderr } = await $.process.run(['node', MATCH, 'map', '--self', self], { timeoutMs: 15000 })
64 if (exitCode !== 0) throw new Error(stderr.trim().split('\n').pop() || `exit ${exitCode}`)
65 const { targets: got, ...rest } = JSON.parse(stdout) as SessionSwitcherListing & { targets: SessionSwitcherTargets }
66 await update($, targets, () => got)
67 await update($, listing, () => ({ ...rest, error: '' }))
68 spinWhileBusy(rest.tabs.some(t => t.status === 'busy'))
69 } catch (err) {
70 // keep the last good list and show the failure on one line
71 const msg = messageOf(err)
72 await update($, listing, cur => ({ ...cur, error: msg }))
73 }
74 }
75 void refresh()
76 $.clock.every(5000, refresh)
77 void $.ui.open({ id: PANE, title: x.paneTitle })
78 return next(e)
79 })
80
81 // The automatic open at startup is skipped on narrow terminals; reopen once on the first prompt (not after the user closes it).
82 let askedOnce = false
83 on('prompt.submit', async ($, e, next) => {
84 if (!askedOnce) {
85 askedOnce = true
86 const panes = await $.ui.panes()
87 const x = TEXT[(await read($, lang)) ?? 'en']
88 if (!panes.some(p => p.id === PANE && p.isPlaced)) void $.ui.open({ id: PANE, title: x.paneTitle })
89 }
90 return next(e)
91 })
92
93 on('command.run', { command: 'session-switcher' }, async $ => {
94 const x = TEXT[(await read($, lang)) ?? 'en']
95 await $.ui.open({ id: PANE, title: x.paneTitle })
96 return { text: x.opened }
97 })
98
99 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
100 const { Box, Button, Text } = $.ui.resolve(e)
101 const cur = await read($, listing)
102 const n = await read($, frame)
103 // decided at session.start; a render before that asks directly (drawing cannot write state)
104 const x = TEXT[(await read($, lang)) ?? (await langOf($))]
105 const cols = Math.max(8, e.props.bodyColumns ?? 30)
106 const rows = buildRows(cur)
107 const viaCache = async (sid: string, n: number) => {
108 const cands = (await read($, targets))[sid] ?? []
109 if (cands.length === 0) return undefined
110 const index = n % cands.length
111 const c = cands[index]
112 if (!c) return undefined
113 const { exitCode } = await $.process.run(['osascript', GHOSTTY, 'select', c.windowId, c.tabId], { timeoutMs: 5000 })
114 return exitCode === 0 ? { count: cands.length, index } : undefined
115 }
116 const switchTo = async (sid: string) => {
117 const n = (await read($, presses))[sid] ?? 0
118 try {
119 const got = (await viaCache(sid, n)) ?? await (async () => {
120 const { exitCode, stdout, stderr } = await $.process.run(['node', MATCH, 'switch', sid, String(n)], { timeoutMs: 20000 })
121 if (exitCode !== 0) throw new Error(stderr.trim().split('\n').pop() || `exit ${exitCode}`)
122 return JSON.parse(stdout) as { count: number; index?: number }
123 })()
124 if (got.count === 0) return $.ui.toast(x.notFound)
125 await update($, presses, cur => ({ ...cur, [sid]: n + 1 }))
126 if (got.count > 1) $.ui.toast(x.match((got.index ?? 0) + 1, got.count))
127 } catch (err) {
128 $.ui.toast(x.switchFailed(messageOf(err)))
129 }
130 }
131 // Without Ghostty on macOS the rows are plain text: clicking does nothing.
132 return (
133 <Box flexDirection="column">
134 {cur.error !== '' && <Text color="red">{lineOf({ label: x.refreshFailed(cur.error), isSelf: false, isBusy: false }, cols)}</Text>}
135 {rows.length === 0 && <Text dimColor>{x.loading}</Text>}
136 {rows.map(r =>
137 r.isSelf || !cur.canSwitch ? (
138 <Text key={r.sessionId} bold={r.isSelf}>{lineOf(r, cols, n)}</Text>
139 ) : (
140 <Button key={r.sessionId} plain label={lineOf(r, cols, n)} onPress={() => void switchTo(r.sessionId)} />
141 ),
142 )}
143 </Box>
144 )
145 })
146}
147hooks/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 37 lines1// Every word the pane shows, per language. Code does not hold display strings.
2import type { Lang } from './lang'
3
4export type Text = {
5 command: string
6 paneTitle: string
7 opened: string
8 notFound: string
9 match: (i: number, n: number) => string
10 switchFailed: (msg: string) => string
11 refreshFailed: (msg: string) => string
12 loading: string
13}
14
15export const TEXT: Record<Lang, Text> = {
16 en: {
17 command: 'Show your open Claude Code sessions in the side pane',
18 paneTitle: 'tabs',
19 opened: 'Opened the session list.',
20 notFound: 'Tab not found',
21 match: (i, n) => `Match ${i}/${n}`,
22 switchFailed: m => `Switch failed: ${m}`,
23 refreshFailed: m => `Refresh failed: ${m}`,
24 loading: 'Loading sessions…',
25 },
26 ja: {
27 command: '開いている Claude Code のセッションを右の列に出す',
28 paneTitle: 'タブ',
29 opened: 'セッションの一覧を開きました。',
30 notFound: 'タブが見つかりません',
31 match: (i, n) => `候補 ${i}/${n}`,
32 switchFailed: m => `切り替え失敗: ${m}`,
33 refreshFailed: m => `更新失敗: ${m}`,
34 loading: 'タブを読み込み中…',
35 },
36}
37hooks/tree.ts 52 lines1import type { SessionSwitcherFlags, SessionSwitcherTab } from '../types'
2
3type Listing = { tabs: SessionSwitcherTab[]; flags?: SessionSwitcherFlags }
4/** isBell = the Ghostty tab name starts with 🔔 (finished or waiting for input). */
5export type Row = { label: string; isSelf: boolean; isBusy: boolean; isBell?: boolean; sessionId: string }
6
7/** One row per open session, in the order given. A name the user gave the Ghostty tab wins over the session title. */
8export function buildRows({ tabs, flags = {} }: Listing): Row[] {
9 return tabs.map(t => ({
10 label: flags[t.sessionId]?.rename || t.title || t.name || t.sessionId.slice(0, 8),
11 isSelf: t.isSelf,
12 isBusy: t.status === 'busy',
13 isBell: flags[t.sessionId]?.bell === true,
14 sessionId: t.sessionId,
15 }))
16}
17
18const wide = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1faff}]/u
19const widthOf = (c: string) => (wide.test(c) ? 2 : 1)
20
21/** Cut to a display width (wide chars count 2), ending with … when it does not fit. */
22export function fit(text: string, columns: number): string {
23 const chars = [...text]
24 if (chars.reduce((n, c) => n + widthOf(c), 0) <= columns) return text
25 let out = ''
26 let used = 0
27 for (const c of chars) {
28 if (used + widthOf(c) > columns - 1) break
29 out += c
30 used += widthOf(c)
31 }
32 return out + '…'
33}
34
35/** Spinner frames for a busy session. They are 1 column wide, so a space pads them to match 🔔. */
36export const SPIN = ['◐', '◓', '◑', '◒']
37
38/** The 2-column mark: 🔔, else spinner, else blank. */
39export function markOf(row: Pick<Row, 'isBusy' | 'isBell'>, frame: number): string {
40 if (row.isBell) return '🔔'
41 if (row.isBusy) return SPIN[((frame % SPIN.length) + SPIN.length) % SPIN.length] + ' '
42 return ' '
43}
44
45const widthOfText = (t: string) => [...t].reduce((n, c) => n + widthOf(c), 0)
46
47/** One line: ▶ for this session, then the mark, then the label. */
48export function lineOf(row: Pick<Row, 'label' | 'isSelf' | 'isBusy' | 'isBell'>, columns: number, frame = 0): string {
49 const head = (row.isSelf ? '▶' : ' ') + markOf(row, frame)
50 return head + fit(row.label, Math.max(1, columns - widthOfText(head)))
51}
52types/index.d.ts 13 lines1export type SessionSwitcherTab = { name: string; title: string; status: string; sessionId: string; isSelf: boolean; cwd?: string }
2/** sessionId -> marks read from the Ghostty tab name (bell = leading 🔔, rename = name the user gave the tab, or empty) */
3export type SessionSwitcherFlags = Record<string, { bell: boolean; rename: string }>
4/** canSwitch = running on macOS with Ghostty open, so a click can select the tab */
5export type SessionSwitcherListing = { tabs: SessionSwitcherTab[]; flags?: SessionSwitcherFlags; canSwitch?: boolean; error: string }
6export type SessionSwitcherTargets = Record<string, { windowId: string; tabId: string }[]>
7
8declare module 'claude-code' {
9 interface PluginState {
10 'session-switcher': { listing: SessionSwitcherListing; presses: Record<string, number>; targets: SessionSwitcherTargets; frame: number; lang: 'en' | 'ja' | null }
11 }
12}
13