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

Three mods for the Claude Code terminal (CLI). Claude Desktop, VS Code and mobile keep their normal look.
| Mod | What it does |
|---|---|
| session-panel | A 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-style | Your 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. |
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.
/plugin configure message-style@ilja-mods sets the panel colour and the stripe colour./mods toggle <name>, or claude plugin disable <name>@ilja-mods./model, so it also becomes your default for new sessions. Mid-session the panel warns first, because a switch drops the prompt cache.claude -p /usage --safe-mode every 3 minutes and claude mcp list every 15 minutes. Neither calls the model or uses your limits.claude and git by name, so both need to be on your PATH. Nothing depends on the shell.See DEVELOPMENT.md. Each mod checks with:
claude plugin validate mods/session-panel
claude plugin test mods/session-panelhooks/register.tsx 729 lines1import { 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}
729types/index.d.ts 53 lines1export 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