SLOPSHOPPER

session-panel

Side panel: live 5-hour and weekly limits, context breakdown, model and effort switchers, skills, connector status, location, version and mod toggles

newpanebandcommandtoastprocess
v0.2.0no licenseupdated 2026-10-16Ilja-Garbuz/claude-code-mods/mods/session-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-panel
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ 5-HOUR │ ┃ ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ ⏺ Read(src/auth.ts) │ ┃ resets in resetting · read just now ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ CONTEXT show ⎿ Added 2 lines, removed 1 line │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────── ⏺ Bash(bun test) │ ┃ 97.4k / 200.0k (49% used, $0.42) ⎿ 3 pass, 1 fail │ ┃ │ ┃ MODEL ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ — ch │ ┃ effort default cha ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ SKILLS show › /panel │ ┃ ⎿ session-panel: Session panel opened. │ ┃ CONNECTORS (…) show check connec │ ┃ │ ┃ LOCATION │ ┃ /work/app │ ┃ branch feat/auth-refresh │ ┃ │ ┃ CLAUDE CODE │ ┃ v2.1.289 │ ┃ │ ┃ MODS (0) hide │ ┃ Install mod-manager to toggle mods here ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
5-HOUR 31% used ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ resets in resetting · read just now CONTEXT show ━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────── 97.4k / 200.0k (49% used, $0.42) MODEL — change model effort default change effort SKILLS show CONNECTORS (…) show check connectors again LOCATION /work/app branch feat/auth-refresh CLAUDE CODE v2.1.289 MODS (0) hide Install mod-manager to toggle mods here collapse panel
README

Claude Code mods

Three mods for the Claude Code terminal (CLI). Claude Desktop, VS Code and mobile keep their normal look.

ModWhat it does
session-panelA side panel docked on the right: live 5-hour and weekly limits, context use with a per-category breakdown, the model and effort with switchers, skills with their context cost, connector status, the working directory, the Claude Code version and toggles for your mods. /panel opens it; the ✕ collapses it to a « button.
message-styleYour prompts as opencode-style panels: full width, text on the right, a thin blue stripe on the right edge.
mod-manager/mods lists your installed plugins and mods with on/off toggles that apply in the open session, no restart. The side panel's Mods section uses it.

Install

You need Claude Code 2.1.300 or newer.

In a Claude Code terminal session, run each of these, answering y to add the marketplace and choosing the user scope:

/plugin install session-panel --marketplace Ilja-Garbuz/claude-code-mods
/plugin install message-style --marketplace Ilja-Garbuz/claude-code-mods
/plugin install mod-manager --marketplace Ilja-Garbuz/claude-code-mods

Or from any shell (PowerShell, bash, fish):

claude plugin marketplace add Ilja-Garbuz/claude-code-mods
claude plugin install session-panel@ilja-mods --scope user
claude plugin install message-style@ilja-mods --scope user
claude plugin install mod-manager@ilja-mods --scope user

session-panel brings mod-manager along, so installing it alone also works.

Start a new session (or run /reload-plugins) and the panel appears once the terminal is wide enough (144 columns in the fullscreen layout). Anywhere else, type /panel.

To update later: claude plugin marketplace update ilja-mods, then /reload-plugins.

Settings

  • message-style: /plugin configure message-style@ilja-mods sets the panel colour and the stripe colour.
  • Turn a mod off: use the toggles in the side panel's Mods section, /mods toggle <name>, or claude plugin disable <name>@ilja-mods.

Notes

  • Model switching: changing the model in the panel works like /model, so it also becomes your default for new sessions. Mid-session the panel warns first, because a switch drops the prompt cache.
  • Background checks: to stay current, the panel runs claude -p /usage --safe-mode every 3 minutes and claude mcp list every 15 minutes. Neither calls the model or uses your limits.
  • Windows and Linux: the mods call claude and git by name, so both need to be on your PATH. Nothing depends on the shell.

Developing

See DEVELOPMENT.md. Each mod checks with:

claude plugin validate mods/session-panel
claude plugin test mods/session-panel
Source 2 files
hooks/register.tsx 729 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { PanelBrain, PanelChange, PanelConnector, PanelConnectors, PanelDetail, PanelLimit, PanelUsage } from '../types'
5
6const PANE = 'session-panel'
7const WIDTH = 44
8
9const usage = atom({ plugin: 'session-panel', key: 'usage' } as const, null as PanelUsage | null)
10const detail = atom({ plugin: 'session-panel', key: 'detail' } as const, null as PanelDetail | null)
11const info = atom({ plugin: 'session-panel', key: 'info' } as const, null as { cwd: string; version: string; branch?: string } | null)
12const expanded = atom({ plugin: 'session-panel', key: 'expanded' } as const, ['mods'] as string[])
13const now = atom({ plugin: 'session-panel', key: 'now' } as const, 0)
14const brain = atom({ plugin: 'session-panel', key: 'brain' } as const, { models: [] } as PanelBrain)
15const picker = atom({ plugin: 'session-panel', key: 'picker' } as const, null as 'model' | 'effort' | null)
16const collapsed = atom({ plugin: 'session-panel', key: 'collapsed' } as const, false)
17const connectors = atom({ plugin: 'session-panel', key: 'connectors' } as const, null as PanelConnectors | null)
18const checking = atom({ plugin: 'session-panel', key: 'checking' } as const, false)
19const pending = atom({ plugin: 'session-panel', key: 'pending' } as const, null as PanelChange | null)
20
21const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
22
23// mod-manager's values, read while drawing; that plugin owns and writes them.
24const mods = { plugin: 'mod-manager', key: 'mods' } as const
25const modsBusy = { plugin: 'mod-manager', key: 'busy' } as const
26const modsNote = { plugin: 'mod-manager', key: 'note' } as const
27
28// The engine has no limit figures until this session's first reply; the
29// figures /usage read meanwhile stand until it does.
30function keepLimits(prev: PanelUsage | null, next: PanelUsage, limitsAt: number | undefined): PanelUsage {
31  const limits = next.limits.length > 0 ? next.limits : (prev?.limits ?? [])
32  return { ...next, limits, limitsAt }
33}
34
35function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): PanelUsage {
36  return {
37    limits: u.rateLimits.map((l: SessionRateLimit) => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
38    tokens: u.context.tokens,
39    window: u.context.window,
40    percent: u.context.percent,
41    costUsd: u.cost?.usd,
42  }
43}
44
45const KIND_ORDER = (kind: string) => (kind === 'used' ? 0 : kind === 'buffer' ? 1 : 2)
46
47// /usage reads the account's live figures. The engine's own
48// figures only move when this session gets a reply, so another session or
49// Claude Desktop can push them past what the panel last saw.
50const LIVE = [
51  { kind: 'five_hour', pattern: /Current session:\s*([\d.]+)%[^\n]*?resets ([^(\n]+)/ },
52  { kind: 'seven_day', pattern: /Current week[^:]*:\s*([\d.]+)%[^\n]*?resets ([^(\n]+)/ },
53]
54const POLL_MS = 3 * 60_000
55
56async function takeLive($: EngineInterface, text: string) {
57  const found = LIVE.flatMap(({ kind, pattern }) => {
58    const m = pattern.exec(text)
59    return m ? [{ kind, percentUsed: Number(m[1]), resetsText: m[2]?.trim() }] : []
60  })
61  if (found.length === 0) return
62  const at = await $.clock.now()
63  await update($, usage, prev => {
64    const base = prev ?? { limits: [], window: 0 }
65    const limits: PanelLimit[] = found.map(f => ({ ...base.limits.find(l => l.kind === f.kind), ...f }))
66    for (const l of base.limits) if (!limits.some(x => x.kind === l.kind)) limits.push(l)
67    return { ...base, limits, limitsAt: at }
68  })
69}
70
71// Asks a headless Claude Code for /usage. In the open session /usage is a
72// dialog, so it runs apart: safe mode loads no plugins or MCP servers, and the
73// command answers itself with no model call, so it costs no usage.
74async function poll($: EngineInterface) {
75  try {
76    const ran = await $.process.run(['claude', '-p', '/usage', '--safe-mode', '--no-session-persistence'], {
77      timeoutMs: 30_000,
78    })
79    if (ran.exitCode === 0) await takeLive($, ran.stdout)
80  } catch {
81    // claude not on PATH, or it took too long: the engine's figures stand
82  }
83}
84
85// `claude mcp list` health-checks every configured server, the ones that
86// failed or wait for a sign-in included, which the session's own tool list
87// leaves out. It runs apart from the session (a few seconds) and costs no usage.
88function parseMcpList(stdout: string): PanelConnector[] {
89  const found: PanelConnector[] = []
90  for (const line of stdout.split(/\r?\n/)) {
91    const nameEnd = line.indexOf(': ')
92    const statusAt = line.lastIndexOf(' - ')
93    if (nameEnd <= 0 || statusAt <= nameEnd) continue
94    const name = line.slice(0, nameEnd)
95    const raw = line.slice(statusAt + 3).trim()
96    const status: PanelConnector['status'] = raw.includes('Connected')
97      ? 'connected'
98      : raw.includes('Needs authentication')
99        ? 'auth'
100        : raw.includes('Failed')
101          ? 'failed'
102          : raw.includes('Pending')
103            ? 'pending'
104            : 'off'
105    const detail = raw.split(' — ')[1]
106    found.push({ name, status, detail })
107  }
108  return found
109}
110
111async function checkConnectors($: EngineInterface) {
112  if (await read($, checking)) return
113  await update($, checking, () => true)
114  try {
115    const ran = await $.process.run(['claude', 'mcp', 'list'], { timeoutMs: 60_000 })
116    const list = parseMcpList(ran.stdout)
117    if (list.length > 0) {
118      const at = await $.clock.now()
119      await update($, connectors, () => ({ at, list }))
120    }
121  } catch {
122    // claude not on PATH or the check timed out: the session's own list stands
123  } finally {
124    await update($, checking, () => false)
125  }
126}
127
128// The breakdown is counted as /context counts it ('full': the token-count
129// API, which uses none of your limits). The local estimate ran a third high
130// on tool schemas. Counted at most every 10 minutes unless asked; between
131// counts the Messages row follows the live total.
132let countedAt = 0
133const RECOUNT_MS = 10 * 60_000
134
135async function measure($: EngineInterface, force = false) {
136  const t = await $.clock.now()
137  const isDue = force || t - countedAt > RECOUNT_MS
138  const u = await $.session.usage(isDue ? { breakdown: 'full' } : undefined)
139  await update($, usage, prev => keepLimits(prev, toUsage(u), prev?.limitsAt))
140  const b = u.context.breakdown
141  if (!b) return
142  countedAt = t
143  const servers = new Map<string, number>()
144  for (const t of b.mcpTools) servers.set(t.serverName, (servers.get(t.serverName) ?? 0) + 1)
145  await update($, detail, () => ({
146    model: b.model,
147    totalTokens: b.totalTokens,
148    maxTokens: b.rawMaxTokens,
149    categories: b.categories
150      .filter(c => c.tokens > 0 && c.kind !== 'deferred')
151      .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind }))
152      // Biggest first, the compaction buffer and free space always last.
153      .sort((x, y) => KIND_ORDER(x.kind) - KIND_ORDER(y.kind) || y.tokens - x.tokens),
154    skills: (b.skills?.skillFrontmatter ?? [])
155      .map(s => ({ name: s.name, source: s.source, pluginName: s.pluginName, tokens: s.tokens }))
156      .sort((x, y) => x.name.localeCompare(y.name)),
157    servers: [...servers].map(([name, tools]) => ({ name, tools })).sort((x, y) => x.name.localeCompare(y.name)),
158    memoryFiles: b.memoryFiles.map(f => ({ path: f.path, type: f.type, tokens: f.tokens })),
159  }))
160}
161
162async function locate($: EngineInterface, cwd: string) {
163  const { version } = await $.session.version()
164  let branch: string | undefined
165  try {
166    const git = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 5_000 })
167    if (git.exitCode === 0) branch = git.stdout.trim() || undefined
168  } catch {
169    // no git on PATH: the panel just leaves the branch out
170  }
171  await update($, info, () => ({ cwd, version, branch }))
172}
173
174function kTokens(n: number) {
175  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
176  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
177  return String(n)
178}
179
180const CATEGORY_COLOR: Record<string, string> = {
181  Messages: '#4a8ff0',
182  'MCP tools': '#e8703a',
183  'System tools': '#26a17b',
184  'System prompt': '#d9a21b',
185  Skills: '#a8a8a8',
186  'MCP server instructions': '#8a8a8a',
187  'Custom agents': '#b07ce8',
188  'Memory files': '#e86aa6',
189  'Slash commands': '#5fc4d6',
190  'Autocompact buffer': '#4d4d4d',
191}
192
193const TITLE = '#d4d4d4'
194// Between the terminal's black and the pane's own grey.
195const PANEL_BG = '#1a1a1a'
196
197const STATUS: Record<PanelConnector['status'], { mark: string; color: string; word: string; rank: number }> = {
198  connected: { mark: '●', color: '#4ade80', word: 'connected', rank: 0 },
199  auth: { mark: '●', color: '#facc15', word: 'needs sign-in', rank: 1 },
200  pending: { mark: '●', color: '#facc15', word: 'needs approval', rank: 1 },
201  failed: { mark: '●', color: '#ef4444', word: 'failed', rank: 2 },
202  off: { mark: '○', color: '#6b6b6b', word: 'not set up', rank: 3 },
203}
204
205const SOURCE_LABEL: Record<string, string> = {
206  userSettings: 'your skills',
207  projectSettings: 'this project',
208  'built-in': 'built in',
209  bundled: 'built in',
210  mcp: 'from connectors',
211  syncedSkills: 'synced',
212}
213
214// "claude.ai Gmail" reads Gmail; "plugin:engineering:slack" reads slack · engineering.
215function labelOf(name: string) {
216  if (name.startsWith('claude.ai ')) return name.slice('claude.ai '.length)
217  const parts = name.split(':')
218  if (parts[0] === 'plugin' && parts.length >= 3) {
219    return parts[1] === parts[2] ? parts[2]! : `${parts.slice(2).join(':')} · ${parts[1]}`
220  }
221  return name
222}
223
224const colorOf = (name: string) => CATEGORY_COLOR[name] ?? '#7a7a7a'
225
226function ago(at: number | undefined, now: number) {
227  if (!at) return ''
228  const mins = Math.floor((now - at) / 60_000)
229  return mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h ago`
230}
231
232// Green at 0%, yellow at 50%, red at 100%.
233const STOPS: [number, number, number][] = [[74, 222, 128], [250, 204, 21], [239, 68, 68]]
234
235function gradient(fraction: number) {
236  const t = Math.max(0, Math.min(1, fraction)) * (STOPS.length - 1)
237  const i = Math.min(STOPS.length - 2, Math.floor(t))
238  const a = STOPS[i]!
239  const b = STOPS[i + 1]!
240  const mix = (k: number) => Math.round(a[k]! + (b[k]! - a[k]!) * (t - i))
241  return `#${[0, 1, 2].map(k => mix(k).toString(16).padStart(2, '0')).join('')}`
242}
243
244// A thin bar: each filled cell takes the gradient's colour at its own place,
245// so a fuller bar runs further into yellow and red.
246function cellsOf(percent: number, cells: number) {
247  const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
248  return {
249    on: Array.from({ length: filled }, (_, i) => gradient(cells === 1 ? 0 : i / (cells - 1))),
250    off: cells - filled,
251  }
252}
253
254function untilReset(resetsAt: string | undefined, at: number) {
255  if (!resetsAt) return ''
256  const ms = Date.parse(resetsAt) - at
257  if (!(ms > 0)) return 'resetting'
258  const mins = Math.round(ms / 60_000)
259  const d = Math.floor(mins / 1440)
260  const h = Math.floor((mins % 1440) / 60)
261  const m = mins % 60
262  return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
263}
264
265const LIMIT_LABEL: Record<string, string> = { five_hour: '5-HOUR', seven_day: 'WEEKLY', spend_limit: 'SPEND' }
266
267function shorten(path: string, room: number) {
268  return path.length <= room ? path : `…${path.slice(path.length - room + 1)}`
269}
270
271async function readBrain($: EngineInterface) {
272  const row = (await $.config.list()).find(r => r.key === 'model')
273  const settings = (await $.settings.read()) as {
274    modelSettings?: Record<string, { effortLevel?: string }>
275    effortLevel?: string
276  }
277  const alias = typeof row?.value === 'string' ? row.value : undefined
278  await update($, brain, b => {
279    // Effort is kept per model id (claude-opus-5-5); before the first request
280    // names the id, take the entry whose id holds the alias (opus).
281    const perModel = settings.modelSettings ?? {}
282    const key = b.model ?? Object.keys(perModel).find(id => alias && id.includes(alias.replace(/\[.*\]$/, '')))
283    const fromSettings = (key && perModel[key]?.effortLevel) || settings.effortLevel
284    return {
285      ...b,
286      alias: alias ?? b.alias,
287      models: (row?.options ?? []).filter(m => m !== 'default'),
288      effort: b.effort ?? fromSettings,
289    }
290  })
291}
292
293// /model and /effort do the switch, as typing them would.
294async function apply($: EngineInterface, change: PanelChange) {
295  await update($, pending, () => null)
296  await update($, picker, () => null)
297  try {
298    await $.command.run({ command: change.kind, args: change.value })
299    await update($, brain, b =>
300      change.kind === 'model' ? { ...b, alias: change.value } : { ...b, effort: change.value },
301    )
302    $.ui.toast(`${change.kind === 'model' ? 'Model' : 'Effort'} set to ${change.value}`)
303  } catch (err) {
304    $.ui.toast(`Could not change ${change.kind}: ${String(err).slice(0, 120)}`)
305  }
306  void readBrain($).catch(() => undefined)
307}
308
309// Collapsed, the pane is closed and gives its room back to the transcript;
310// a single « button above the prompt brings it back. (A docked pane keeps a
311// minimum width, so a thin strip was not possible.)
312async function setCollapsed($: EngineInterface, isCollapsed: boolean) {
313  await update($, collapsed, () => isCollapsed)
314  if (isCollapsed) await $.ui.close({ id: PANE })
315  else await $.ui.open({ id: PANE, title: 'Session', columns: WIDTH })
316}
317
318export const register: Register = on => {
319  // Opened unasked at start, the panel stays only where it docks beside the
320  // transcript; /panel opens it anywhere, inline above the prompt included.
321  let isAsked = false
322
323  on('session.start', async ($, e, next) => {
324    await $.command.register({
325      name: 'panel',
326      description: 'Open the session side panel (limits, context, skills, connectors, mods)',
327    })
328    const started = await next(e)
329    void locate($, e.cwd)
330    void measure($, true)
331    void readBrain($).catch(() => undefined)
332    const startedAt = await $.clock.now()
333    await update($, now, () => startedAt)
334    // Redraw the reset countdowns once a minute.
335    // Once a minute: redraw the countdowns, and take the engine's latest
336    // limit figures (a free read; they move whenever any request answers).
337    $.clock.every(60_000, async () => {
338      const t = await $.clock.now()
339      await update($, now, () => t)
340    })
341    if (e.isInteractive) {
342      $.clock.after(2_000, () => poll($))
343      $.clock.every(POLL_MS, () => poll($))
344      $.clock.after(5_000, () => checkConnectors($))
345      $.clock.every(15 * 60_000, () => checkConnectors($))
346    }
347    if (e.isInteractive && e.surface === 'terminal') {
348      if (!(await read($, collapsed))) void $.ui.open({ id: PANE, title: 'Session', columns: WIDTH })
349    }
350    return started
351  })
352
353  // /clear wipes the session's values and starts no new session.start, so
354  // the panel fills itself again once the clear has gone through.
355  on('session.end', async ($, e, next) => {
356    const ended = await next(e)
357    if (e.reason === 'clear') {
358      $.clock.after(300, async () => {
359        void locate($, await $.session.cwd())
360        void measure($, true)
361        void readBrain($).catch(() => undefined)
362        void poll($)
363        void checkConnectors($)
364        const t = await $.clock.now()
365        await update($, now, () => t)
366      })
367    }
368    return ended
369  })
370
371  on('session.measure', async ($, e, next) => {
372    const at = await $.clock.now()
373    await update($, usage, prev => keepLimits(prev, toUsage(e), e.rateLimits.length > 0 ? at : prev?.limitsAt))
374    return next(e)
375  })
376
377  on('turn.complete', async ($, e, next) => {
378    const done = await next(e)
379    if (!e.agentId) void measure($)
380    return done
381  })
382
383  // The live model and effort, as each main-loop request is sent.
384  on('turn.step', async function* ($, e, next) {
385    if (!e.agentId) {
386      // A request without an effort keeps the one already known.
387      const effort = e.effort === undefined ? undefined : String(e.effort)
388      void update($, brain, b =>
389        b.model === e.model && (effort === undefined || b.effort === effort)
390          ? b
391          : { ...b, model: e.model, effort: effort ?? b.effort },
392      )
393    }
394    return yield* next(e)
395  })
396
397  on('command.run', { command: 'panel' }, async $ => {
398    isAsked = true
399    void measure($, true)
400    await setCollapsed($, false)
401    return { text: 'Session panel opened.' }
402  })
403
404  // The close mark collapses the panel: it goes, and the « button stays.
405  on('ui.close', { id: PANE }, async ($, e, next) => {
406    if (e.origin.kind === 'person') await update($, collapsed, () => true)
407    return next(e)
408  })
409
410  on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
411    if (e.props.hasSurvey || !(await read($, collapsed))) return next(e)
412    const { Box, Button, Text } = $.ui.resolve(e)
413    return (
414      <Box justifyContent="flex-end">
415        <Button key="expand" label="open session panel" plain onPress={() => void setCollapsed($, false)}>
416          <Text color={TITLE}>«</Text>
417        </Button>
418      </Box>
419    )
420  })
421
422  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
423    const { Box, Text, Button } = $.ui.resolve(e)
424    if (e.props.placement === 'inline' && !isAsked) {
425      $.clock.after(0, () => $.ui.close({ id: PANE }))
426      return <Text dimColor>Session panel: type /panel to keep it open here</Text>
427    }
428    // Two cells clear of the pane's right edge, so the percentages never touch it.
429    const cols = Math.max(22, e.props.bodyColumns - 2)
430    const u = await read($, usage)
431    const counted = await read($, detail)
432    // Between counts, the Messages row is the live total less everything else.
433    const d =
434      counted && u?.tokens !== undefined
435        ? {
436            ...counted,
437            categories: counted.categories.map(c => {
438              if (c.name !== 'Messages') return c
439              const rest = counted.categories
440                .filter(x => x.kind === 'used' && x.name !== 'Messages')
441                .reduce((n, x) => n + x.tokens, 0)
442              return { ...c, tokens: Math.max(c.tokens, (u.tokens ?? 0) - rest) }
443            }),
444          }
445        : counted
446    const where = await read($, info)
447    const open = await read($, expanded)
448    const at = (await read($, now)) || Date.now()
449    const mind = await read($, brain)
450    const choosing = await read($, picker)
451    const asked = await read($, pending)
452    const { value: modList = [] } = await $.state.get(mods)
453    const { value: busyId = null } = await $.state.get(modsBusy)
454    const { value: said = null } = await $.state.get(modsNote)
455
456    const isOpen = (k: string) => open.includes(k)
457    const flip = (k: string) => () =>
458      void update($, expanded, list => (list.includes(k) ? list.filter(x => x !== k) : [...list, k]))
459
460    const heading = (title: string, k?: string, count?: number | string) => (
461      <Box marginTop={1}>
462        <Text bold color={TITLE}>{title}</Text>
463        {count !== undefined && <Text dimColor> ({count})</Text>}
464        {k && <Text> </Text>}
465        {k && (
466          <Button key={`flip-${k}`} label={isOpen(k) ? 'hide' : 'show'} plain onPress={flip(k)}>
467            <Text dimColor>{isOpen(k) ? '[−]' : '[+]'}</Text>
468          </Button>
469        )}
470      </Box>
471    )
472
473    const limits = u?.limits ?? []
474    // Before the first reply there is no live figure: count what is loaded
475    // (the used rows), never the tools that load on demand.
476    const loaded = d ? d.categories.filter(c => c.kind === 'used').reduce((n, c) => n + c.tokens, 0) : undefined
477    const ctxTokens = u?.tokens ?? loaded
478    const ctxPercent = u?.percent ?? (d && loaded !== undefined ? Math.round((loaded / d.maxTokens) * 100) : 0)
479
480    const thinBar = (k: string, percent: number) => {
481      const c = cellsOf(percent, cols)
482      return (
483        <Box key={`bar-${k}`}>
484          {c.on.map((color, i) => (
485            <Text key={`${k}-${i}`} color={color}>━</Text>
486          ))}
487          <Text color="#3a3a3a">{'─'.repeat(c.off)}</Text>
488        </Box>
489      )
490    }
491
492    // Limits as Claude Desktop's usage pane draws them: a thick bar, blue,
493    // turning red from 80% used, on a dark track.
494    const limitBar = (k: string, percent: number) => {
495      const filled = Math.max(0, Math.min(cols, Math.round((percent / 100) * cols)))
496      return (
497        <Box key={`bar-${k}`}>
498          <Text color={percent >= 80 ? '#e5484d' : '#4a8ff0'}>{'▆'.repeat(filled)}</Text>
499          <Text color="#3a3a3a">{'▆'.repeat(cols - filled)}</Text>
500        </Box>
501      )
502    }
503
504    const mcp = await read($, connectors)
505    const isChecking = await read($, checking)
506    // The session names a server in tool spelling (claude_ai_Gmail), the
507    // check in display spelling (claude.ai Gmail): compare letters and digits.
508    const keyOf = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
509    const toolCount = new Map((d?.servers ?? []).map(x => [keyOf(x.name), x.tools]))
510    const connectorRows = (
511      mcp
512        ? mcp.list.map(c => ({ ...c, tools: toolCount.get(keyOf(c.name)) }))
513        : (d?.servers ?? []).map(x => ({ name: x.name, status: 'connected' as const, detail: undefined, tools: x.tools }))
514    )
515      .map(c => ({ ...c, label: labelOf(c.name) }))
516      .sort((x, y) => STATUS[x.status].rank - STATUS[y.status].rank || x.label.localeCompare(y.label))
517
518    const groups = new Map<string, NonNullable<typeof d>['skills']>()
519    for (const sk of d?.skills ?? []) {
520      const group = sk.pluginName ?? SOURCE_LABEL[sk.source] ?? sk.source
521      groups.set(group, [...(groups.get(group) ?? []), sk])
522    }
523    const skillGroups = [...groups].sort((x, y) => y[1].length - x[1].length || x[0].localeCompare(y[0]))
524
525    const segmentBar = () => {
526      if (!d) return null
527      const used = d.categories.filter(c => c.kind !== 'free')
528      let left = cols
529      const runs = used.map(c => {
530        const n = Math.min(left, Math.max(1, Math.round((c.tokens / d.maxTokens) * cols)))
531        left -= n
532        return { name: c.name, n }
533      })
534      return (
535        <Box key="bar-context">
536          {runs.filter(r => r.n > 0).map(r => (
537            <Text key={`seg-${r.name}`} color={colorOf(r.name)}>{'━'.repeat(r.n)}</Text>
538          ))}
539          <Text color="#3a3a3a">{'─'.repeat(Math.max(0, left))}</Text>
540        </Box>
541      )
542    }
543
544    // With context in the cache, a switch asks first: it drops the cache.
545    const choose = (change: PanelChange) => () =>
546      void ((ctxTokens ?? 0) > 0 ? update($, pending, () => change) : apply($, change))
547    const options = choosing === 'model' ? mind.models : choosing === 'effort' ? EFFORTS : []
548    const current = choosing === 'model' ? mind.alias : mind.effort
549    const togglePicker = (k: 'model' | 'effort') => () => void update($, picker, p => (p === k ? null : k))
550
551    return (
552      // The collapse button sits at the foot of the panel: pinned to the
553      // bottom while the sections are shorter than the pane, after them when
554      // they run longer.
555      <Box
556        flexDirection="column"
557        width={e.props.bodyColumns}
558        paddingRight={Math.max(0, e.props.bodyColumns - cols)}
559        minHeight={e.props.scroll.bodyRows}
560        justifyContent="space-between"
561        backgroundColor={PANEL_BG}
562      >
563        <Box flexDirection="column">
564        {limits.length === 0 && (
565          <Box flexDirection="column">
566            <Text bold color={TITLE}>USAGE LIMITS</Text>
567            <Text dimColor>No reading yet (shows after the first reply on a subscription)</Text>
568          </Box>
569        )}
570        {limits.map((l, i) => (
571          <Box key={`limit-${l.kind}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
572            <Box justifyContent="space-between">
573              <Text bold color={TITLE}>{LIMIT_LABEL[l.kind] ?? l.kind.toUpperCase()}</Text>
574              <Text dimColor>{Math.round(l.percentUsed)}% used</Text>
575            </Box>
576            {limitBar(l.kind, l.percentUsed)}
577            <Text dimColor>
578              {l.resetsAt ? `resets in ${untilReset(l.resetsAt, at)}` : l.resetsText ? `resets ${l.resetsText}` : ' '}
579              {u?.limitsAt ? ` · read ${ago(u.limitsAt, at)}` : ''}
580            </Text>
581          </Box>
582        ))}
583
584        {heading('CONTEXT', 'context')}
585        {d ? segmentBar() : thinBar('context', ctxPercent)}
586        <Text>
587          {ctxTokens !== undefined ? kTokens(ctxTokens) : '—'} / {kTokens(u?.window ?? d?.maxTokens ?? 0)}
588          <Text dimColor> ({ctxPercent}% used{u?.costUsd !== undefined ? `, $${u.costUsd.toFixed(2)}` : ''})</Text>
589        </Text>
590        {isOpen('context') && d && (
591          <Box flexDirection="column">
592            {d.categories.map(c => (
593              <Box key={`cat-${c.name}`} justifyContent="space-between">
594                <Text wrap="truncate-end">
595                  <Text color={c.kind === 'free' ? '#3a3a3a' : colorOf(c.name)}>■ </Text>
596                  <Text dimColor={c.kind !== 'used'}>{c.name}</Text>
597                </Text>
598                <Text dimColor>
599                  {kTokens(c.tokens)} {String(((c.tokens / d.maxTokens) * 100).toFixed(1)).padStart(5)}%
600                </Text>
601              </Box>
602            ))}
603            {d.memoryFiles.length > 0 && <Text dimColor>memory files: {d.memoryFiles.length}</Text>}
604          </Box>
605        )}
606
607        {heading('MODEL')}
608        <Box justifyContent="space-between">
609          <Text wrap="truncate-end">
610            {mind.model ?? d?.model ?? '—'}
611            {mind.alias && <Text dimColor> ({mind.alias})</Text>}
612          </Text>
613          <Button key="pick-model" label="change model" plain onPress={togglePicker('model')}>
614            <Text dimColor>[change]</Text>
615          </Button>
616        </Box>
617        <Box justifyContent="space-between">
618          <Text>
619            effort{' '}
620            {mind.effort ? <Text bold>{mind.effort}</Text> : <Text dimColor>default</Text>}
621          </Text>
622          <Button key="pick-effort" label="change effort" plain onPress={togglePicker('effort')}>
623            <Text dimColor>[change]</Text>
624          </Button>
625        </Box>
626        {choosing && !asked && (
627          <Box flexWrap="wrap" columnGap={1}>
628            {options.map(o => (
629              <Button key={`opt-${choosing}-${o}`} label={o} plain onPress={choose({ kind: choosing, value: o })}>
630                <Text color={o === current ? 'success' : undefined}>{o === current ? `[${o}]` : o}</Text>
631              </Button>
632            ))}
633          </Box>
634        )}
635        {asked && (
636          <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
637            <Text color="warning" wrap="wrap">
638              Switching {asked.kind} to {asked.value} now drops the prompt cache. Your next message re-sends all{' '}
639              {ctxTokens !== undefined ? kTokens(ctxTokens) : ''} tokens of context uncached, so it costs more and uses
640              more of your limits.
641            </Text>
642            <Box columnGap={2}>
643              <Button key="confirm-change" label="switch anyway" variant="primary" onPress={() => void apply($, asked)} />
644              <Button key="cancel-change" label="cancel" onPress={() => void update($, pending, () => null)} />
645            </Box>
646          </Box>
647        )}
648
649        {heading('SKILLS', 'skills', d ? `${d.skills.length} · ${kTokens(d.skills.reduce((n, x) => n + x.tokens, 0))}` : undefined)}
650        {isOpen('skills') &&
651          skillGroups.map(([group, list]) => (
652            <Box key={`sg-${group}`} flexDirection="column">
653              <Text dimColor>{group}</Text>
654              {list.map(sk => (
655                <Box key={`skill-${group}-${sk.name}`} justifyContent="space-between">
656                  <Text wrap="truncate-end">  {sk.name}</Text>
657                  <Text dimColor>{kTokens(sk.tokens)}</Text>
658                </Box>
659              ))}
660            </Box>
661          ))}
662
663        <Box marginTop={1} justifyContent="space-between">
664          <Box>
665            <Text bold color={TITLE}>CONNECTORS</Text>
666            <Text dimColor>
667              {' '}
668              ({mcp ? `${mcp.list.filter(c => c.status === 'connected').length}/${mcp.list.length}` : d?.servers.length ?? '…'})
669            </Text>
670            <Text> </Text>
671            <Button key="flip-mcp" label={isOpen('mcp') ? 'hide' : 'show'} plain onPress={flip('mcp')}>
672              <Text dimColor>{isOpen('mcp') ? '[−]' : '[+]'}</Text>
673            </Button>
674          </Box>
675          <Button key="check-mcp" label="check connectors again" plain onPress={() => void checkConnectors($)}>
676            <Text dimColor>{isChecking ? 'checking…' : '↻'}</Text>
677          </Button>
678        </Box>
679        {isOpen('mcp') &&
680          connectorRows.map(c => (
681            <Box key={`mcp-${c.name}`} justifyContent="space-between">
682              <Text wrap="truncate-end">
683                <Text color={STATUS[c.status].color}>{c.status === 'connected' && !c.tools ? '○' : STATUS[c.status].mark} </Text>
684                <Text dimColor={c.status === 'off'}>{c.label}</Text>
685              </Text>
686              <Text dimColor>{c.tools ? `${c.tools} tools` : c.status === 'connected' ? 'no tools here' : STATUS[c.status].word}</Text>
687            </Box>
688          ))}
689        {isOpen('mcp') && mcp && <Text dimColor>checked {ago(mcp.at, at)}</Text>}
690
691        {heading('LOCATION')}
692        <Text wrap="truncate-start">{where ? shorten(where.cwd, cols) : '—'}</Text>
693        {where?.branch && <Text dimColor>branch {where.branch}</Text>}
694
695        {heading('CLAUDE CODE')}
696        <Text>v{where?.version ?? '?'}</Text>
697
698        {heading('MODS', 'mods', modList.filter(m => m.isMod).length)}
699        {isOpen('mods') && modList.length === 0 && (
700          <Text dimColor>Install mod-manager to toggle mods here</Text>
701        )}
702        {isOpen('mods') &&
703          modList
704            .filter(m => m.isMod)
705            .map(m => (
706              <Button
707                key={`mod-${m.id}`}
708                label={m.id}
709                plain
710                onPress={() => void $.command.run({ command: 'mods', args: `toggle ${m.id}` })}
711              >
712                <Text color={m.enabled ? 'success' : 'inactive'}>
713                  {busyId === m.id ? '[ … ]' : m.enabled ? '[ on]' : '[off]'}
714                </Text>{' '}
715                {m.name}
716              </Button>
717            ))}
718        {isOpen('mods') && said && <Text dimColor wrap="wrap">{said}</Text>}
719        </Box>
720        <Box marginTop={1}>
721          <Button key="collapse" label="collapse panel" plain onPress={() => void setCollapsed($, true)}>
722            <Text dimColor>» collapse</Text>
723          </Button>
724        </Box>
725      </Box>
726    )
727  })
728}
729
types/index.d.ts 53 lines
1export type PanelLimit = { kind: string; percentUsed: number; resetsAt?: string; resetsText?: string }
2
3export type PanelUsage = {
4  limits: PanelLimit[]
5  tokens?: number
6  window: number
7  percent?: number
8  costUsd?: number
9  limitsAt?: number
10}
11
12export type PanelDetail = {
13  model: string
14  totalTokens: number
15  maxTokens: number
16  categories: { name: string; tokens: number; kind: string }[]
17  skills: { name: string; source: string; pluginName?: string; tokens: number }[]
18  servers: { name: string; tools: number }[]
19  memoryFiles: { path: string; type: string; tokens: number }[]
20}
21
22export type PanelInfo = { cwd: string; version: string; branch?: string }
23
24export type PanelBrain = { model?: string; alias?: string; effort?: string; models: string[] }
25
26export type PanelConnector = {
27  name: string
28  status: 'connected' | 'auth' | 'failed' | 'pending' | 'off'
29  detail?: string
30}
31
32export type PanelConnectors = { at: number; list: PanelConnector[] }
33
34export type PanelChange = { kind: 'model' | 'effort'; value: string }
35
36declare module 'claude-code' {
37  interface PluginState {
38    'session-panel': {
39      usage: PanelUsage | null
40      detail: PanelDetail | null
41      info: PanelInfo | null
42      expanded: string[]
43      now: number
44      brain: PanelBrain
45      picker: 'model' | 'effort' | null
46      pending: PanelChange | null
47      collapsed: boolean
48      connectors: PanelConnectors | null
49      checking: boolean
50    }
51  }
52}
53