SLOPSHOPPER

session-switcher

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

newpanecommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview could not run: TypeError: Invalid URL: null/Users/raymondxu/slopshopper-mods/.cache/src/brainrev-claude-session-panels-session-switcher/hooks/../scripts/tabmatch.mjs at new URLShim (inner.js:204:26) at anony
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 147 lines
1import { 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}
147
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 37 lines
1// 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}
37
hooks/tree.ts 52 lines
1import 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}
52
types/index.d.ts 13 lines
1export 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