SLOPSHOPPER

session-band

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

newpanespinnerrowsguardcommand
v0.1.0MITupdated 2026-10-08AlessandroAriazzi/session-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-band
│ ┃ Usage and model ✕ │ ┃ ◆ Plan usage updates live · esc closes ╭─────────────────────────────────────────────────────────────────╮ │ ┃ │ ❯ You │ │ ┃ │ fix the failing auth test and add an audit log call │ │ ┃ SESSION · 5-HOUR WINDOW ╰─────────────────────────────────────────────────────────────────╯ │ ┃ 31% used · 69% left │ ┃ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ Reset time not available ⎿ Read 6 lines │ ┃ ┃ │ ┃ ●─ ✎ Edit src/auth.ts 20ms │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ MODEL │ ┃ Opus 5.5 active now · a change ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ applies from the next message │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Opus 1M O │ ┃ › /usage-panel │ ┃ EFFORT ⎿ session-band: Usage and model: panel open. │ ┃ — known after the first prompt │ ┃ /effort is not available in this │ ┃ Claude Code version │ ┃ │ ┃ │ ┃ [ Close ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ Opus 5.5 effort — context █████░░░░░ 49% · 97k/200k 5h ███░░░░░░░ 31% [ Usage ▸ ]

Draws

Pane · Usage and model
◆ Plan usage updates live · esc closes SESSION · 5-HOUR WINDOW C 31% used · 69% left 4 ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █ Reset time not available 9 s MODEL Opus 5.5 active now · a change applies from the next message Opus 1M O EFFORT — known after the first prompt /effort is not available in this Claude Code version [ Close ]
Prompt hint
⟨Claude Code's own drawing⟩ Opus 5.5
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ❯ You │ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tool row
┃ ●─ ✎ Edit src/auth.ts 20ms
README

session-band

A Claude Code mod that keeps your session's vitals in view and makes the transcript easier to scan.

What it does

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
IconLabelTools
$RunBash, PowerShell
≡ReadRead
✎EditEdit, MultiEdit, NotebookEdit
✚WriteWrite
⌕SearchGrep, Glob
⊕WebWebFetch, WebSearch
◇MCPany 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.

Install

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.

Develop

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 .

License

MIT

Source 2 files
hooks/register.tsx 780 lines
1import { 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}
780
types/index.d.ts 15 lines
1export 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