Model, effort, context and 5-hour limit under the prompt; outlined prompts and tool rows with icons

A Claude Code mod that keeps your session's vitals in view and makes the transcript easier to scan.
Under the prompt: one row beside the hint line, with the model, the effort level, the context window and the 5-hour plan limit, and a button that opens the usage panel:
⏸ manual mode on · ? for shortcuts Opus 5.5 · 1M effort ●●●○○ high context ██░░ 17% · 171k/1M 5h ████░░ 42% · ↻ 2h 13m [ Usage ▸ ]
Bars turn amber from 50% and red from 80%. On a narrow terminal the chips wrap below the hint.
Usage panel (/usage-panel or the Usage ▸ button): cards for the 5-hour and weekly limits with reset times and countdowns, the context window and the session cost, plus one-press switches for the model and the effort level.
Your prompts: drawn in a rounded outline with a ❯ You · 14:32 header. Attached images stay below it.
Tool calls: one row per call, tied together by a rail, with a status dot, an icon and a label, the command or path, and lines changed and time taken at the right edge:
┃
●─ $ Run ls -la ~/.claude 20ms
┃
●─ ≡ Read hooks/hooks.json 2ms
┃
●─ ✎ Edit src/app.ts +4 −2 120ms
| Icon | Label | Tools |
|---|---|---|
$ | Run | Bash, PowerShell |
≡ | Read | Read |
✎ | Edit | Edit, MultiEdit, NotebookEdit |
✚ | Write | Write |
⌕ | Search | Grep, Glob |
⊕ | Web | WebFetch, WebSearch |
◇ | MCP | any mcp__* tool |
The status dot is green when a call succeeded, red when it failed, yellow when it was interrupted and grey while it runs. Folded groups of reads and searches list each call (up to six, then … +N more) instead of a count. Agents, todos and plan mode keep Claude Code's own rows.
Long Bash output: the transcript shows the first 8 and last 4 lines with … N lines hidden between them. Claude still reads the full output.
Everything here changes only what is drawn: the stored conversation, and what the model reads, are untouched. The mod makes no network calls, runs no processes and makes no model calls.
Needs Claude Code 2.1.287 or later. At the Claude Code prompt:
/plugin install session-band --marketplace AlessandroAriazzi/session-band
Answer y to add the marketplace, then press Enter for the user scope.
Clone the repository and load it from its folder:
claude --plugin-dir ./session-band
or set CLAUDE_CODE_PLUGIN_DIRS to the folder in the env block of ~/.claude/settings.json. An interactive session reloads the mod when a file is saved. Claude Code writes the API types to .claude-plugin/types/ the first time it loads the mod; after that:
claude plugin validate .
claude plugin test .
npx tsc -p .
MIT
hooks/register.tsx 780 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, SessionRateLimit, ThemeKey } from 'claude-code'
3
4import type { Effort } from '../types'
5
6// Effort is only visible on a model request (turn.step), so it is kept here;
7// model, context and rate limits are read live on each draw. `tick` forces a
8// redraw: after each turn, and on a timer so reset countdowns stay current.
9const effort = atom({ plugin: 'session-band', key: 'effort' } as const, null as Effort | null)
10const tick = atom({ plugin: 'session-band', key: 'tick' } as const, 0)
11const notice = atom({ plugin: 'session-band', key: 'notice' } as const, null as string | null)
12// Per transcript row: how long a tool call took (-1 until it ends), and
13// whether a prompt carried images, so its row keeps the engine's drawing of them.
14const duration = atom({ plugin: 'session-band', key: 'duration' } as const, -1)
15const images = atom({ plugin: 'session-band', key: 'images' } as const, false)
16// When each prompt was sent (0 when unknown: one stored before this mod loaded).
17const sentAt = atom({ plugin: 'session-band', key: 'sentAt' } as const, 0)
18
19const PANE = 'usage-detail'
20const PANE_TITLE = 'Usage and model'
21const COMMAND = 'usage-panel'
22const REFRESH_MS = 30_000
23const BAR_CELLS = 10
24const CARD_GAP = 2
25const MIN_CARD_COLUMNS = 30
26
27// Theme keys, so cards and pills follow the person's light or dark theme.
28const CARD_BG = 'userMessageBackground'
29const ACCENT = 'claude'
30
31export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
32const EFFORT_INFO: Record<(typeof EFFORT_LEVELS)[number], { name: string; hint: string }> = {
33 low: { name: 'low', hint: 'quick answers, little reasoning' },
34 medium: { name: 'medium', hint: 'a balance of speed and care' },
35 high: { name: 'high', hint: 'thinks it through, slower' },
36 xhigh: { name: 'extra high', hint: 'for hard tasks' },
37 max: { name: 'max', hint: 'all the reasoning it can' },
38}
39
40// What `/model <alias>` takes, as the picker in the panel offers it.
41export const MODEL_CHOICES = [
42 { alias: 'opus[1m]', label: 'Opus 1M', family: 'opus', isLong: true },
43 { alias: 'opus', label: 'Opus', family: 'opus', isLong: false },
44 { alias: 'sonnet', label: 'Sonnet', family: 'sonnet', isLong: false },
45 { alias: 'haiku', label: 'Haiku', family: 'haiku', isLong: false },
46 { alias: 'fable', label: 'Fable', family: 'fable', isLong: false },
47] as const
48
49const WINDOWS: Record<string, { title: string; subtitle: string }> = {
50 five_hour: { title: 'Session', subtitle: '5-hour window' },
51 seven_day: { title: 'Week', subtitle: '7-day window' },
52 spend_limit: { title: 'Spend', subtitle: 'spend limit' },
53}
54
55type Tone = 'success' | 'warning' | 'error'
56
57export function formatTokens(n: number): string {
58 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
59 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
60 return String(n)
61}
62
63export function prettyModel(model: string): string {
64 const match = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(\[1m\])?$/i.exec(model)
65 if (!match) return model
66 const [, family = '', major, minor, isLong] = match
67 const name = `${family[0]?.toUpperCase() ?? ''}${family.slice(1)} ${major}.${minor}`
68 return isLong ? `${name} · 1M` : name
69}
70
71export function isActiveChoice(model: string, choice: (typeof MODEL_CHOICES)[number]): boolean {
72 const lower = model.toLowerCase()
73 return lower.includes(choice.family) && lower.endsWith('[1m]') === choice.isLong
74}
75
76export function fill(percent: number, cells = BAR_CELLS): [string, string] {
77 const filled = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
78 return ['█'.repeat(filled), '░'.repeat(cells - filled)]
79}
80
81export function bar(percent: number, cells = BAR_CELLS): string {
82 return fill(percent, cells).join('')
83}
84
85function tone(percent: number): Tone {
86 if (percent >= 80) return 'error'
87 if (percent >= 50) return 'warning'
88 return 'success'
89}
90
91export function formatCountdown(ms: number): string {
92 const minutes = Math.max(0, Math.ceil(ms / 60_000))
93 const days = Math.floor(minutes / 1_440)
94 const hours = Math.floor((minutes % 1_440) / 60)
95 const mins = minutes % 60
96 if (days > 0) return `${days}d ${hours}h`
97 if (hours > 0) return `${hours}h ${String(mins).padStart(2, '0')}m`
98 return `${mins}m`
99}
100
101const timeFormat = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit' })
102const dayFormat = new Intl.DateTimeFormat('en-GB', {
103 weekday: 'long',
104 day: 'numeric',
105 month: 'long',
106})
107
108export function formatReset(resetsAt: number, now: number): string {
109 const time = timeFormat.format(resetsAt)
110 const sameDay = new Date(resetsAt).toDateString() === new Date(now).toDateString()
111 return sameDay ? `today at ${time}` : `${dayFormat.format(resetsAt)} at ${time}`
112}
113
114// How many cards fit side by side, and how wide each is.
115export function cardLayout(columns: number, count: number): { perRow: number; width: number } {
116 const perRow = Math.max(
117 1,
118 Math.min(count, Math.floor((columns + CARD_GAP) / (MIN_CARD_COLUMNS + CARD_GAP))),
119 )
120 return { perRow, width: Math.floor((columns - CARD_GAP * (perRow - 1)) / perRow) }
121}
122
123// Transcript rows: each tool call gets an icon and a label for its kind, then
124// the call itself.
125export type ToolKind = 'run' | 'read' | 'edit' | 'write' | 'search' | 'web' | 'mcp'
126
127const KINDS: Record<ToolKind, { icon: string; label: string }> = {
128 run: { icon: '$', label: 'Run' },
129 read: { icon: '≡', label: 'Read' },
130 edit: { icon: '✎', label: 'Edit' },
131 write: { icon: '✚', label: 'Write' },
132 search: { icon: '⌕', label: 'Search' },
133 web: { icon: '⊕', label: 'Web' },
134 mcp: { icon: '◇', label: 'MCP' },
135}
136
137const BUILTIN_KINDS: Record<string, ToolKind> = {
138 Bash: 'run',
139 PowerShell: 'run',
140 Read: 'read',
141 Edit: 'edit',
142 MultiEdit: 'edit',
143 NotebookEdit: 'edit',
144 Write: 'write',
145 Glob: 'search',
146 Grep: 'search',
147 WebFetch: 'web',
148 WebSearch: 'web',
149}
150
151// Only a person's own typing gets the prompt outline: a task notification or
152// another agent's message is not theirs to dress as theirs.
153const TYPED_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
154const MAX_PROMPT = 4000
155const MAX_TARGET = 120
156
157// Null leaves the row to the engine: agents, todos and plan mode draw live
158// progress and dialogs of their own.
159export function kindOf(tool: string): ToolKind | null {
160 if (tool.startsWith('mcp__')) return 'mcp'
161 return BUILTIN_KINDS[tool] ?? null
162}
163
164const fieldsOf = (input: unknown): Record<string, unknown> =>
165 typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
166const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
167const oneLine = (text: string): string => (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
168
169export function shortenPath(path: string, cwd: string): string {
170 const root = cwd.replace(/\/+$/, '')
171 return root !== '' && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
172}
173
174// The one thing about a call worth a glance: the command, the file, the pattern.
175export function summarize(tool: string, input: unknown, cwd: string): string {
176 const fields = fieldsOf(input)
177 const summary = (() => {
178 switch (tool) {
179 case 'Bash':
180 case 'PowerShell':
181 return textOf(fields.command)
182 case 'Read':
183 case 'Edit':
184 case 'MultiEdit':
185 case 'Write':
186 return shortenPath(textOf(fields.file_path), cwd)
187 case 'NotebookEdit':
188 return shortenPath(textOf(fields.notebook_path), cwd)
189 case 'Glob':
190 return textOf(fields.pattern)
191 case 'Grep': {
192 const where = textOf(fields.path)
193 return where === ''
194 ? textOf(fields.pattern)
195 : `${textOf(fields.pattern)} in ${shortenPath(where, cwd)}`
196 }
197 case 'WebFetch':
198 return textOf(fields.url).replace(/^https?:\/\//, '')
199 case 'WebSearch':
200 return textOf(fields.query)
201 default: {
202 return Object.values(fields).map(textOf).find(v => v !== '') ?? ''
203 }
204 }
205 })()
206 return oneLine(summary).slice(0, MAX_TARGET)
207}
208
209// `mcp__github__search_code` reads as `github:search_code`; a built-in keeps its name.
210export function toolName(tool: string): string {
211 if (!tool.startsWith('mcp__')) return tool
212 const [, server = '', ...name] = tool.split('__')
213 return `${server}:${name.join('__')}`
214}
215
216// Lines added and removed, read from an Edit or Write record's patch.
217export function diffstat(output: unknown): { added: number; removed: number } | null {
218 const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
219 if (!Array.isArray(patch)) return null
220 const lines = patch.flatMap(hunk => {
221 const hunkLines = (hunk as { lines?: unknown } | null)?.lines
222 return Array.isArray(hunkLines) ? hunkLines.filter(l => typeof l === 'string') : []
223 }) as string[]
224 return {
225 added: lines.filter(l => l.startsWith('+')).length,
226 removed: lines.filter(l => l.startsWith('-')).length,
227 }
228}
229
230// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
231export function formatMs(ms: number): string {
232 if (ms < 1000) return `${Math.round(ms)}ms`
233 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
234 const seconds = Math.round(ms / 1000)
235 return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
236}
237
238type CallState = { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
239
240// The worst thing that happened to any of the calls, else how far along they are.
241function status(calls: readonly CallState[]): { glyph: string; color: ThemeKey } {
242 if (calls.some(c => c.isInterrupted)) return { glyph: '◌', color: 'warning' }
243 if (calls.some(c => c.isErrored)) return { glyph: '✕', color: 'error' }
244 if (calls.some(c => c.isRunning)) return { glyph: '○', color: 'inactive' }
245 return { glyph: '●', color: 'success' }
246}
247
248const OTHER = { icon: '·', label: 'Other' }
249const MAX_GROUP_ROWS = 6
250
251type RowUi = Pick<ElementTable, 'Box' | 'Text'>
252type RowCall = CallState & { tool: string; input: unknown }
253
254// The rail ties a turn's calls into one line down the left, as skins draws it.
255const RAIL = '┃'
256const BRANCH = '─'
257const RAIL_COLOR: ThemeKey = 'inactive'
258
259// One call's row in the skins style: the rail above, then
260// `●─ $ Run npm test` with its facts flush right.
261function callRow(
262 { Box, Text }: RowUi,
263 call: RowCall,
264 cwd: string,
265 facts: { ms: number; diff: { added: number; removed: number } | null },
266) {
267 const kind = kindOf(call.tool)
268 const { icon, label } = kind === null ? OTHER : KINDS[kind]
269 const { glyph, color: statusColor } = status([call])
270 const summary = summarize(call.tool, call.input, cwd)
271 // A built-in's label says what it is; an MCP or unknown tool names itself.
272 const target = kind === 'mcp' || kind === null ? `${toolName(call.tool)} ${summary}` : summary
273
274 const meta = []
275 if (facts.diff !== null && facts.diff.added + facts.diff.removed > 0) {
276 meta.push(<Text color="diffAdded">{`+${facts.diff.added}`}</Text>)
277 meta.push(<Text color="diffRemoved">{` −${facts.diff.removed}`}</Text>)
278 }
279 if (facts.ms >= 0 && !call.isRunning) {
280 meta.push(<Text dimColor>{`${meta.length > 0 ? ' ' : ''}${formatMs(facts.ms)}`}</Text>)
281 }
282
283 const main = (
284 <Text wrap="truncate-end">
285 <Text color={statusColor}>
286 {glyph}
287 <Text dimColor>{`${BRANCH} `}</Text>
288 </Text>
289 <Text color="text" bold>
290 {`${icon} ${label}`}
291 </Text>
292 <Text color={call.isErrored ? 'error' : undefined} dimColor={!call.isErrored}>
293 {` ${target}`}
294 </Text>
295 {call.isInterrupted ? <Text color="warning">{' · interrupted'}</Text> : null}
296 </Text>
297 )
298
299 return (
300 <Box flexDirection="column">
301 <Text color={RAIL_COLOR}>{RAIL}</Text>
302 {meta.length === 0 ? (
303 main
304 ) : (
305 <Box flexDirection="row">
306 <Box flexGrow={1} flexShrink={1}>
307 {main}
308 </Box>
309 <Box flexShrink={0} paddingLeft={2}>
310 <Text>{meta}</Text>
311 </Box>
312 </Box>
313 )}
314 </Box>
315 )
316}
317
318// Long Bash output keeps its first `head` and last `tail` lines and names
319// how many it hid; the stored result, and what the model read, are untouched.
320const CLIP_HEAD = 8
321const CLIP_TAIL = 4
322
323export function clipLines(text: string, head = CLIP_HEAD, tail = CLIP_TAIL): string {
324 const lines = text.split('\n')
325 const hidden = lines.length - head - tail
326 if (hidden <= 1) return text
327 return [...lines.slice(0, head), `… ${hidden} lines hidden`, ...lines.slice(-tail)].join('\n')
328}
329
330async function openPanel($: EngineInterface) {
331 await update($, notice, () => null)
332 return $.ui.open({ id: PANE, title: PANE_TITLE, focus: true, closeOnEscape: true })
333}
334
335async function runCommand($: EngineInterface, command: string, args: string, done: string) {
336 await update($, notice, () => `/${command} ${args}…`)
337 try {
338 const result = await $.command.run({ command, args })
339 await update($, notice, () => result.text?.trim() || done)
340 } catch (error) {
341 await update($, notice, () => `/${command} failed: ${String(error)}`)
342 }
343 await update($, tick, n => n + 1)
344}
345
346export const register: Register = on => {
347 on('session.start', async ($, e, next) => {
348 $.clock.every(REFRESH_MS, () => {
349 void update($, tick, n => n + 1)
350 })
351 await $.command.register({
352 name: COMMAND,
353 description: 'Open the usage detail, with quick model and effort switches',
354 })
355
356 return next(e)
357 })
358
359 on('command.run', { command: COMMAND }, async $ => {
360 await openPanel($)
361
362 return { text: `${PANE_TITLE}: panel open.` }
363 })
364
365 on('turn.step', async function* ($, e, next) {
366 if (e.agentId === undefined) {
367 const current = e.effort ?? null
368 if ((await read($, effort)) !== current) {
369 await update($, effort, () => current)
370 }
371 }
372
373 return yield* next(e)
374 })
375
376 on('turn.complete', async ($, e, next) => {
377 const result = await next(e)
378 await update($, tick, n => n + 1)
379
380 return result
381 })
382
383 // Times every call, for the right edge of its row.
384 on('tool.call', async ($, e, next) => {
385 const startedAt = await $.clock.now()
386 const ran = await next(e)
387 const ms = (await $.clock.now()) - startedAt
388 await update($, memberOf(duration, { requestId: e.tool_use_id }), () => ms)
389
390 return ran
391 }).catch(($, e, next) => next(e))
392
393 // Notes when each prompt was sent, for its header, and which carried images,
394 // so their row keeps the engine's drawing of them.
395 on('session.append', async ($, e, next) => {
396 const stored = await next(e)
397 if (e.door === 'prompt' && stored.uuid !== undefined) {
398 const now = await $.clock.now()
399 await update($, memberOf(sentAt, { requestId: stored.uuid }), () => now)
400 if (e.message.content.some(block => block.type === 'image')) {
401 await update($, memberOf(images, { requestId: stored.uuid }), () => true)
402 }
403 }
404
405 return stored
406 }).catch(($, e, next) => next(e))
407
408 // Your prompt in a rounded outline sized to what you typed, with a small
409 // header, so it stands apart from the replies.
410 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
411 if (!TYPED_ORIGINS.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
412 return next(e)
413 }
414 const { Box, Text } = $.ui.resolve(e)
415 const hasImages = await read($, memberOf(images, e))
416 const sent = await read($, memberOf(sentAt, e))
417 // The engine draws the images; the text is already in the outline above them.
418 const theirImages = hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : null
419
420 return (
421 <Box flexDirection="column" alignItems="flex-start" marginTop={1}>
422 <Box
423 flexDirection="column"
424 borderStyle="round"
425 borderColor="promptBorder"
426 paddingX={1}
427 flexShrink={1}
428 >
429 <Text>
430 <Text color={ACCENT} bold>
431 {'❯ You'}
432 </Text>
433 {sent > 0 ? <Text dimColor>{` · ${timeFormat.format(sent)}`}</Text> : null}
434 </Text>
435 <Text>{e.props.text}</Text>
436 </Box>
437 {theirImages}
438 </Box>
439 )
440 })
441
442 // A tool call: status, icon and label in its kind's colour, the call itself
443 // (`Bash(npm test)`), and lines changed and time taken at the right edge.
444 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
445 if (kindOf(e.props.tool) === null) return next(e)
446
447 return callRow($.ui.resolve(e), e.props, await $.session.cwd(), {
448 ms: await read($, memberOf(duration, e)),
449 diff: diffstat(e.props.output),
450 })
451 })
452
453 // A folded run of reads and searches: one row per call, so every command
454 // shows without opening the group; past MAX_GROUP_ROWS a count of the rest.
455 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
456 if (e.props.isExpanded) return next(e)
457
458 const ui = $.ui.resolve(e)
459 const cwd = await $.session.cwd()
460 const shown = e.props.calls.slice(0, MAX_GROUP_ROWS)
461 const hidden = e.props.calls.length - shown.length
462 const rows = await Promise.all(
463 shown.map(async call =>
464 callRow(ui, call, cwd, {
465 ms:
466 call.tool_use_id === undefined
467 ? -1
468 : await read($, memberOf(duration, { requestId: call.tool_use_id })),
469 diff: null,
470 }),
471 ),
472 )
473
474 return (
475 <ui.Box flexDirection="column">
476 {rows}
477 {hidden > 0 ? <ui.Text dimColor>{`┃\n … +${hidden} more`}</ui.Text> : null}
478 </ui.Box>
479 )
480 })
481
482 // A long Bash output, cut to its head and tail in the transcript.
483 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
484 const output = e.props.output as { stdout?: unknown } | null
485 if (e.props.tool !== 'Bash' || typeof output?.stdout !== 'string') return next(e)
486
487 const stdout = clipLines(output.stdout)
488 return stdout === output.stdout
489 ? next(e)
490 : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
491 })
492
493 // Under the prompt: the engine's own hint line with the chips beside it and
494 // the button that opens the panel pinned to the right end; on a narrow
495 // terminal the chips wrap to the next row.
496 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
497 const hint = await next(e)
498 await read($, tick)
499 const [model, usage, level, now] = await Promise.all([
500 $.session.model(),
501 $.session.usage(),
502 read($, effort),
503 $.clock.now(),
504 ])
505 const { tokens, window, percent } = usage.context
506 const { Box, Button, Text } = $.ui.resolve(e)
507 const [full, empty] = fill(percent ?? 0)
508 const rank = typeof level === 'string' ? EFFORT_LEVELS.indexOf(level) + 1 : 0
509 const session = usage.rateLimits.find(limit => limit.kind === 'five_hour')
510 const sessionResetsAt = session?.resetsAt === undefined ? NaN : Date.parse(session.resetsAt)
511
512 return (
513 // No width on this Box: the engine refuses its own node (`hint`) under a
514 // sized Box and draws its plain line instead.
515 <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
516 {hint}
517 <Box key="row" flexGrow={1} columnGap={2} alignItems="center">
518 <Box flexGrow={1} flexShrink={1} flexWrap="wrap" columnGap={2}>
519 <Box backgroundColor={ACCENT} paddingX={1}>
520 <Text color="inverseText" bold>
521 {prettyModel(model)}
522 </Text>
523 </Box>
524 <Box backgroundColor={CARD_BG} paddingX={1}>
525 <Text dimColor>effort </Text>
526 {typeof level === 'string' ? (
527 <Text>
528 <Text color={ACCENT}>{'●'.repeat(rank)}</Text>
529 <Text dimColor>{'○'.repeat(EFFORT_LEVELS.length - rank)}</Text>
530 <Text bold> {level}</Text>
531 </Text>
532 ) : (
533 <Text bold>{level === null ? '—' : String(level)}</Text>
534 )}
535 </Box>
536 <Box backgroundColor={CARD_BG} paddingX={1}>
537 <Text dimColor>context </Text>
538 {percent === undefined ? (
539 <Text dimColor>waiting · {formatTokens(window)}</Text>
540 ) : (
541 <Text>
542 <Text color={tone(percent)}>{full}</Text>
543 <Text dimColor>{empty}</Text>
544 <Text color={tone(percent)} bold>
545 {` ${percent}%`}
546 </Text>
547 <Text dimColor>{` · ${formatTokens(tokens ?? 0)}/${formatTokens(window)}`}</Text>
548 </Text>
549 )}
550 </Box>
551 {session === undefined ? null : (
552 <Box backgroundColor={CARD_BG} paddingX={1}>
553 <Text dimColor>5h </Text>
554 <Text>
555 <Text color={tone(session.percentUsed)}>{fill(session.percentUsed)[0]}</Text>
556 <Text dimColor>{fill(session.percentUsed)[1]}</Text>
557 <Text color={tone(session.percentUsed)} bold>
558 {` ${Math.round(session.percentUsed)}%`}
559 </Text>
560 {Number.isNaN(sessionResetsAt) ? null : (
561 <Text dimColor>{` · ↻ ${formatCountdown(sessionResetsAt - now)}`}</Text>
562 )}
563 </Text>
564 </Box>
565 )}
566 </Box>
567 <Box flexShrink={0}>
568 <Button key="open-usage" label="Usage ▸" onPress={() => void openPanel($)} />
569 </Box>
570 </Box>
571 </Box>
572 )
573 })
574
575 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
576 await read($, tick)
577 const [model, usage, level, message, now, commands] = await Promise.all([
578 $.session.model(),
579 $.session.usage(),
580 read($, effort),
581 read($, notice),
582 $.clock.now(),
583 $.command.list(),
584 ])
585 const hasEffortCommand = commands.some(c => c.name === 'effort')
586 const { tokens, window, percent } = usage.context
587 const { Box, Button, Text } = $.ui.resolve(e)
588 const columns = Math.max(MIN_CARD_COLUMNS, e.props.bodyColumns - 2)
589
590 const meter = (value: number, cells: number) => {
591 const [full, empty] = fill(value, cells)
592 return (
593 <Text>
594 <Text color={tone(value)}>{full}</Text>
595 <Text dimColor>{empty}</Text>
596 </Text>
597 )
598 }
599
600 // One card: a tinted block with a small label, a big figure, a bar and
601 // two lines of detail.
602 const card = (
603 key: string,
604 width: number,
605 body: {
606 label: string
607 figure: string
608 figureTone: Tone | typeof ACCENT
609 caption?: string
610 percent?: number
611 lines: JSX.Element[]
612 },
613 ) => (
614 <Box
615 key={key}
616 width={width}
617 flexDirection="column"
618 backgroundColor={CARD_BG}
619 paddingX={2}
620 paddingY={1}
621 marginBottom={1}
622 >
623 <Text dimColor>{body.label.toUpperCase()}</Text>
624 <Text>
625 <Text color={body.figureTone} bold>
626 {body.figure}
627 </Text>
628 {body.caption === undefined ? null : <Text dimColor>{` ${body.caption}`}</Text>}
629 </Text>
630 {body.percent === undefined ? null : meter(body.percent, Math.max(8, width - 4))}
631 {body.lines}
632 </Box>
633 )
634
635 const limitCard = (limit: SessionRateLimit, width: number) => {
636 const info = WINDOWS[limit.kind] ?? { title: limit.kind, subtitle: '' }
637 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
638 const left = Math.max(0, Math.round(100 - limit.percentUsed))
639 return card(`limit-${limit.kind}`, width, {
640 label: `${info.title} · ${info.subtitle}`,
641 figure: `${Math.round(limit.percentUsed)}%`,
642 figureTone: tone(limit.percentUsed),
643 caption: `used · ${left}% left`,
644 percent: limit.percentUsed,
645 lines: Number.isNaN(resetsAt)
646 ? [<Text dimColor>Reset time not available</Text>]
647 : [
648 <Text>
649 <Text dimColor>{'↻ '}</Text>
650 <Text>{formatReset(resetsAt, now)}</Text>
651 </Text>,
652 <Text>
653 <Text dimColor>{' in '}</Text>
654 <Text color={ACCENT} bold>
655 {formatCountdown(resetsAt - now)}
656 </Text>
657 </Text>,
658 ],
659 })
660 }
661
662 const contextCard = (width: number) =>
663 card('context', width, {
664 label: 'Context · model window',
665 figure: percent === undefined ? '—' : `${percent}%`,
666 figureTone: percent === undefined ? ACCENT : tone(percent),
667 caption: percent === undefined ? 'waiting for the first reply' : 'used',
668 percent,
669 lines: [
670 <Text dimColor>{`${formatTokens(tokens ?? 0)} of ${formatTokens(window)} tokens`}</Text>,
671 <Text dimColor>
672 {usage.cost === undefined ? ' ' : `session cost $${usage.cost.usd.toFixed(2)}`}
673 </Text>,
674 ],
675 })
676
677 const cardCount = usage.rateLimits.length + 1
678 const { width: cardWidth } = cardLayout(columns, cardCount)
679
680 // A segmented control: one pill per option, the active one filled.
681 const pill = (key: string, label: string, isActive: boolean, onPress: () => void) => (
682 <Box
683 key={`pill-${key}`}
684 backgroundColor={isActive ? ACCENT : CARD_BG}
685 hover={{ backgroundColor: isActive ? ACCENT : 'promptBorder' }}
686 paddingX={1}
687 >
688 <Button key={key} label={label} plain onPress={onPress} />
689 </Box>
690 )
691
692 const effortName =
693 typeof level === 'string' ? EFFORT_INFO[level].name : level === null ? '—' : String(level)
694
695 return (
696 <Box flexDirection="column" paddingX={1}>
697 <Box justifyContent="space-between" marginBottom={1}>
698 <Text>
699 <Text color={ACCENT} bold>
700 {'◆ '}
701 </Text>
702 <Text bold>Plan usage</Text>
703 </Text>
704 <Text dimColor>updates live · esc closes</Text>
705 </Box>
706
707 {usage.rateLimits.length === 0 ? (
708 <Box marginBottom={1}>
709 <Text dimColor>
710 The 5-hour and weekly limits show after the model's first reply (Claude
711 subscriptions only).
712 </Text>
713 </Box>
714 ) : null}
715 <Box flexWrap="wrap" columnGap={CARD_GAP}>
716 {usage.rateLimits.map(limit => limitCard(limit, cardWidth))}
717 {contextCard(cardWidth)}
718 </Box>
719
720 <Box
721 flexDirection="column"
722 backgroundColor={CARD_BG}
723 paddingX={2}
724 paddingY={1}
725 marginBottom={1}
726 >
727 <Text dimColor>MODEL</Text>
728 <Text>
729 <Text bold>{prettyModel(model)}</Text>
730 <Text dimColor>{' active now · a change applies from the next message'}</Text>
731 </Text>
732 <Box flexWrap="wrap" columnGap={1} marginTop={1}>
733 {MODEL_CHOICES.map(choice =>
734 pill(`model-${choice.alias}`, choice.label, isActiveChoice(model, choice), () => {
735 void runCommand($, 'model', choice.alias, `Model set: ${choice.label}`)
736 }),
737 )}
738 </Box>
739
740 <Box marginTop={1}>
741 <Text dimColor>EFFORT</Text>
742 </Box>
743 <Text>
744 <Text bold>{effortName}</Text>
745 <Text dimColor>
746 {typeof level === 'string'
747 ? ` ${EFFORT_INFO[level].hint}`
748 : ' known after the first prompt'}
749 </Text>
750 </Text>
751 {hasEffortCommand ? (
752 <Box flexWrap="wrap" columnGap={1} marginTop={1}>
753 {EFFORT_LEVELS.map(value =>
754 pill(`effort-${value}`, EFFORT_INFO[value].name, level === value, () => {
755 void (async () => {
756 await runCommand($, 'effort', value, `Effort set: ${value}`)
757 await update($, effort, () => value)
758 })()
759 }),
760 )}
761 </Box>
762 ) : (
763 <Text dimColor>/effort is not available in this Claude Code version</Text>
764 )}
765 </Box>
766
767 <Box justifyContent="space-between">
768 <Text color="suggestion">{message ?? ' '}</Text>
769 <Button
770 key="close"
771 label="Close"
772 role="dismiss"
773 onPress={() => void $.ui.close({ id: PANE })}
774 />
775 </Box>
776 </Box>
777 )
778 })
779}
780types/index.d.ts 15 lines1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-band': {
6 effort: Effort | null
7 tick: number
8 notice: string | null
9 duration: StateFamily<number>
10 images: StateFamily<boolean>
11 sentAt: StateFamily<number>
12 }
13 }
14}
15