SLOPSHOPPER

usage-band

Shows context window fill and plan usage limits in a band above the prompt.

newbandprocesstimer
★ 1v0.2.0MITupdated 2026-10-09benpaternostro/claude-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Context ━━━━━━━━ 49% Session ━━━━━━━━ 31% Cache — + ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ━━━━━━━━ 49% Session ━━━━━━━━ 31% Cache — +
README

usage-band

Context window and plan limits, always in view, in a band above the Claude Code prompt.

Release License: MIT Claude Code Plugin Surfaces Last commit

<sub>Unofficial. This project is not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic.</sub>

<img src="docs/band.png" alt="The usage band above the prompt in the desktop Code tab: the Context, Session and Weekly meters drawn as rings, the branch, and the cache countdown">

<sub>Desktop Code tab</sub>

<img src="docs/cli-band.webp" alt="The usage band above the prompt in the terminal">

<sub>Terminal</sub>

Features

MeterShows
ContextHow full the context window is. The drawer breaks it down by category, coloured as /context shows it
SessionThe 5-hour plan limit and the time until it resets
WeeklyThe 7-day plan limit and the time until it resets
CreditsThe spend limit, when your plan has one
BranchThe session's git branch (a short commit hash when detached), shown from its last /: feature/NEXT-1777 shows as NEXT-1777. Hover it for the full name. The desktop shows a branch icon (a fork icon in a linked worktree); the terminal shows git main, and · worktree in a linked worktree
CacheEstimated prompt-cache time remaining, after a clock icon on the desktop (Cache in the terminal), to the left of the + toggle

A meter's fill turns amber at 80% of its limit and red at 95%. For the context, the limit is the auto-compact point, or the full window when auto-compact is off.

The cache countdown starts after a main-conversation response uses or writes the cache. It counts whole minutes (59m), then seconds in the last minute. The countdown uses your cache TTL settings. Without an explicit setting, it estimates the TTL from the plan limits. — means no cache time is available. A model switch or compaction clears the estimate. See Claude Code cache lifetimes.

When the band is too narrow, the bars shrink first. On the desktop, bars that would be too short to read turn into small rings. Then the branch is shortened with …, and then it is dropped. If the band is still too narrow, meters drop whole from the right, so items never overlap. The branch refreshes when the session starts and after each turn.

Click a meter or the + toggle to open the drawer. It shows the full context breakdown, the space left before auto-compact, the session cost, and a Compact session button.

The context breakdown is a local estimate. It sends no API request.

The open drawer in the desktop Code tab

The open drawer in the terminal

Quick start

In a Claude Code session, run:

/plugin install usage-band --marketplace benpaternostro/claude-usage-band

Confirm the marketplace source, choose an installation scope, and start a new session.

Where it works

SurfaceStatus
Terminal, including the VS Code integrated terminal✅ Supported. The bars are drawn as text. Click a meter's label or figures, or +, to open the drawer.
Desktop app, Code tab✅ Supported
VS Code extension❌ Not supported. The extension loads the mod and runs its hooks, but it draws no plugin UI: neither the band nor a status line. Tested on extension 2.1.291.

Requirements

  • Claude Code 2.1.275 or later. The mod was tested on 2.1.288.
  • The terminal or the desktop Code tab.

[!WARNING] The function-hook plugin API is in early access. It can change between Claude Code releases without notice.

Install

From the marketplace

From your shell, the install is two commands:

claude plugin marketplace add benpaternostro/claude-usage-band
claude plugin install usage-band@claude-usage-band

To get a new version, run:

claude plugin update usage-band@claude-usage-band

Try it for one session

Clone the repository, then start Claude Code with the folder:

git clone https://github.com/benpaternostro/claude-usage-band.git
claude --plugin-dir ./claude-usage-band

Load it from a folder in every session

Use this method when you change the mod yourself, or in the desktop app, where you cannot give a command-line flag. Add the absolute path of the cloned folder to the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/code/claude-usage-band"
  }
}

To load more than one folder, separate the paths with : on macOS and Linux, or with ; on Windows.

[!IMPORTANT] Do not load the mod from a folder and also install it from the marketplace. Use one method only.

Claude Code writes the API type declarations into .claude-plugin/types/ when it loads the mod from a folder. These files are not in the repository.

Develop

claude plugin validate .
claude plugin test .

When you load the mod from a folder, it reloads in a running terminal session after you save hooks/register.tsx. In the desktop app, set CLAUDE_CODE_PLUGIN_DIR_WATCH=1 in the same env block to get this behaviour, or start a new session.

Layout

PathContents
hooks/register.tsxThe hooks: usage refresh, band and drawer rendering
hooks/cache.tsCache TTL estimate and countdown text
hooks/git.tsBranch and worktree detection
hooks/fit.tsBand width budget: bar widths, rings and branch shortening
.claude-plugin/marketplace.jsonThe marketplace entry for /plugin install
types/index.d.tsSnapshot types and plugin state declarations
tests/band.test.tsHelper, git and render tests
tests/cache.test.mjsCache countdown tests (node --test)
tests/fit.test.mjsWidth budget tests (node --test)

License

MIT

Source 5 files
hooks/register.tsx 633 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { GitHead, Limit, Segment, Snapshot } from '../types'
5import { cacheText, cacheTtl } from './cache'
6import { BAR_CELLS, BAR_PX, CACHE_WORD, DESKTOP, GIT_WORD, ICON_PX, OPEN_GLYPH, TERMINAL, WORKTREE_NOTE, fitBand, roomOf } from './fit'
7import { HEAD_ARGV, branchName, isDetached, parseHead } from './git'
8
9const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, null)
10const isOpen = atom({ plugin: 'usage-band', key: 'isOpen' } as const, false)
11const cacheExpiresAt = atom({ plugin: 'usage-band', key: 'cacheExpiresAt' } as const, null)
12const git = atom({ plugin: 'usage-band', key: 'git' } as const, null)
13
14// Full-width glyphs: the same size as the app's close control, and the same
15// advance as each other, so the toggle never moves.
16export { OPEN_GLYPH }
17export const CLOSE_GLYPH = '-'
18
19const SHORT_LABELS: Record<string, string> = { five_hour: 'Session', seven_day: 'Weekly', spend_limit: 'Credits' }
20const LONG_LABELS: Record<string, string> = {
21  five_hour: 'Session limit',
22  seven_day: 'Weekly · all models',
23  spend_limit: 'Usage credits',
24}
25
26// The app's usage popover: blue fill on a dark track; context categories in theme colours.
27const BLUE = '#4a80e8'
28const AMBER = '#e0a526'
29const RED = '#e5484d'
30const TRACK = '#8888884d'
31const BUFFER = '#8888888c'
32const HOVER_BG = '#8888881f'
33// The whole branch name over the short one, on the desktop: the band's dark
34// grey as a meter under the pointer lights it, opaque so the name hides what
35// it covers.
36const TIP_BG = '#2e2e2e'
37const TIP_TEXT = '#f2f2f2'
38const THEME: Record<string, string> = {
39  permission: BLUE,
40  suggestion: BLUE,
41  claude: '#d97757',
42  success: '#3fae6a',
43  warning: AMBER,
44  error: RED,
45  inactive: '#b4b4b4',
46  promptBorder: '#b4b4b4',
47  remember: '#a48fd8',
48  purple_FOR_SUBAGENTS_ONLY: '#a48fd8',
49  cyan_FOR_SUBAGENTS_ONLY: '#4fb3c4',
50}
51const BY_RANK = [BLUE, '#d97757', '#3fae6a', AMBER, '#b4b4b4', '#a48fd8', '#4fb3c4']
52// Braille blanks: drawn empty, never trimmed as spaces are. About a cell's
53// worth for each cell of bar.
54const hitLabel = (px: number) => '⠀'.repeat(Math.max(1, Math.round((BAR_CELLS * px) / BAR_PX)))
55
56// Desktop icons, 24-unit strokes in a grey that reads on light and dark themes.
57const ICON_GREY = '#8c8c8c'
58const ICONS = {
59  branch: '<circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="6" r="2"/><path d="M6 7v10M18 8v1a4 4 0 0 1-4 4h-4a4 4 0 0 0-4 4"/>',
60  worktree: '<circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v1a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4v-1M12 12v5"/>',
61  clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
62}
63const svgIcon = (body: string) =>
64  `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${ICON_GREY}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
65const SVG_DIVIDER = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="14"><rect width="1" height="14" fill="${ICON_GREY}" fill-opacity="0.45"/></svg>`
66
67type Usage = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
68type Part = { color: string; part: number; title?: string; tokens?: number }
69// What the bar's empty track says under the pointer.
70type Bar = { parts: Part[]; rest?: string; restTokens?: number }
71
72const limitsOf = (u: Usage): Limit[] =>
73  u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
74
75// The breakdown is what /context and the popover show; the raw figure is the last
76// response's input, which can be a subagent's, so it is only the fallback.
77export const toSnapshot = (u: Usage, prev: Snapshot | null): Snapshot => {
78  const b = u.context.breakdown
79  if (!b) {
80    return {
81      tokens: u.context.tokens,
82      window: u.context.window,
83      percent: u.context.percent,
84      limits: limitsOf(u),
85      usd: u.cost?.usd,
86      segments: prev?.segments ?? [],
87      buffer: prev?.buffer,
88      autoCompactAt: prev?.autoCompactAt,
89    }
90  }
91  const segments: Segment[] = b.categories
92    .filter(c => c.kind === 'used' && c.tokens > 0)
93    .sort((x, y) => y.tokens - x.tokens)
94    .map((c, i) => ({ name: c.name, tokens: c.tokens, color: THEME[c.color] ?? BY_RANK[i % BY_RANK.length] ?? BLUE }))
95  const buffer = b.categories.filter(c => c.kind === 'buffer').reduce((a, c) => a + c.tokens, 0)
96  return {
97    tokens: b.totalTokens,
98    window: b.rawMaxTokens,
99    percent: b.percentage,
100    limits: limitsOf(u),
101    usd: u.cost?.usd,
102    segments,
103    buffer: buffer > 0 ? buffer : undefined,
104    autoCompactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
105  }
106}
107
108export const kTokens = (n: number) =>
109  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${n}`
110
111export const relative = (ms: number, isLong = false) => {
112  const mins = Math.max(1, Math.round(ms / 60_000))
113  const d = Math.floor(mins / 1440)
114  const h = Math.floor((mins % 1440) / 60)
115  const m = mins % 60
116  if (isLong) return d > 0 ? `${d} d ${h} hr` : h > 0 ? `${h} hr ${m} min` : `${m} min`
117  return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
118}
119
120export const resetIn = (l: Limit, now: number) => {
121  const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
122  return at > now ? relative(at - now) : ''
123}
124
125// Under a day: "Resets in 1 hr 8 min"; longer: "Resets Thu 7:00 AM".
126export const resetText = (l: Limit, now: number) => {
127  const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
128  if (!(at > now)) return ''
129  if (at - now < 86_400_000) return `Resets in ${relative(at - now, true)}`
130  try {
131    const when = new Date(at).toLocaleString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit' })
132    return `Resets ${when.replace(',', '')}`
133  } catch {
134    return `Resets in ${relative(at - now, true)}`
135  }
136}
137
138const share = (tokens: number, window: number) => `${Math.round((tokens / window) * 100)}%`
139
140// Category segments scaled to the window's fill, then the buffer; the rest is track.
141export const contextBar = (s: Snapshot): Bar => {
142  if (s.window <= 0) return { parts: [] }
143  const tokens = s.tokens ?? 0
144  const fill = Math.min(1, tokens / s.window)
145  const total = s.segments.reduce((a, x) => a + x.tokens, 0)
146  const used: Part[] =
147    fill <= 0
148      ? []
149      : total <= 0
150        ? [{ color: BLUE, part: fill, tokens, title: `Used · ${kTokens(tokens)} (${share(tokens, s.window)})` }]
151        : s.segments.map(x => ({
152            color: x.color,
153            part: (x.tokens / total) * fill,
154            tokens: x.tokens,
155            title: `${x.name} · ${kTokens(x.tokens)} (${share(x.tokens, s.window)})`,
156          }))
157  const bufferPart = s.buffer ? Math.min(1 - fill, s.buffer / s.window) : 0
158  const parts =
159    bufferPart > 0
160      ? [...used, { color: BUFFER, part: bufferPart, tokens: s.buffer, title: `Autocompact buffer · ${kTokens(s.buffer ?? 0)} (${share(s.buffer ?? 0, s.window)})` }]
161      : used
162  const free = Math.max(0, s.window - tokens - (bufferPart > 0 ? (s.buffer ?? 0) : 0))
163  return { parts, restTokens: free, rest: `Free space · ${kTokens(free)} (${share(free, s.window)})` }
164}
165
166// A fill turns amber from 80% of the way to its limit, and red from 95%.
167export const fillColor = (used: number, limit = 100) => {
168  const way = limit > 0 ? used / limit : 0
169  return way >= 0.95 ? RED : way >= 0.8 ? AMBER : BLUE
170}
171
172// The context's limit is where it auto-compacts, or the window when it never does.
173export const contextColor = (s: Snapshot) => fillColor(s.tokens ?? 0, s.autoCompactAt ?? s.window)
174
175export const limitBar = (l: Limit, now: number): Bar => {
176  const percent = Math.min(100, Math.max(0, l.percentUsed))
177  const reset = resetText(l, now)
178  const name = LONG_LABELS[l.kind] ?? l.kind
179  return {
180    parts: [{ color: fillColor(percent), part: percent / 100, title: `${name} · ${percent}% used${reset ? ` · ${reset}` : ''}` }],
181    rest: `${100 - percent}% left${reset ? ` · ${reset}` : ''}`,
182  }
183}
184
185const svgBar = (bar: Bar, width: number, height: number, gap: number) => {
186  let x = 0
187  const rects = bar.parts
188    .map(p => {
189      const w = p.part * width
190      const isLast = x + w >= width - 0.5
191      const r = `<rect x="${x.toFixed(2)}" width="${Math.max(0, isLast ? w : w - gap).toFixed(2)}" height="${height}" fill="${p.color}"/>`
192      x += w
193      return r
194    })
195    .join('')
196  return (
197    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">` +
198    `<clipPath id="c"><rect width="${width}" height="${height}" rx="${height / 2}"/></clipPath>` +
199    `<g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="${TRACK}"/>${rects}</g></svg>`
200  )
201}
202
203// A bar wound into a ring, for a row with no room for bars: the fill as one
204// arc in its colour, clockwise from the top, on the same track.
205const svgRing = (part: number, size: number, color: string) => {
206  const c = size / 2
207  const r = c - 1.25
208  const around = 2 * Math.PI * r
209  const arc = Math.min(1, Math.max(0, part)) * around
210  return (
211    `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none" stroke-width="2">` +
212    `<g transform="rotate(-90 ${c} ${c})"><circle cx="${c}" cy="${c}" r="${r}" stroke="${TRACK}"/>` +
213    (arc > 0
214      ? `<circle cx="${c}" cy="${c}" r="${r}" stroke="${color}" stroke-linecap="round" stroke-dasharray="${arc.toFixed(2)} ${around.toFixed(2)}"/>`
215      : '') +
216    `</g></svg>`
217  )
218}
219
220
221export const textCells = (parts: Part[], width: number) => {
222  const cells: { color: string; n: number }[] = []
223  let used = 0
224  for (const p of parts) {
225    const n = Math.min(width - used, Math.round(p.part * width))
226    if (n > 0) {
227      cells.push({ color: p.color, n })
228      used += n
229    }
230  }
231  return { cells, free: Math.max(0, width - used) }
232}
233
234// The summary breakdown is a local estimate: it sends no API request.
235async function refresh($: EngineInterface) {
236  const prev = await read($, snapshot)
237  let usage: Usage
238  try {
239    usage = await $.session.usage({ breakdown: 'summary' })
240  } catch {
241    usage = await $.session.usage()
242  }
243  await update($, snapshot, () => toSnapshot(usage, prev))
244}
245
246// Outside a repository, or with no commit yet, the band shows no branch.
247async function refreshGit($: EngineInterface) {
248  let head: GitHead | null = null
249  try {
250    const ran = await $.process.run(HEAD_ARGV, { timeoutMs: 5000 })
251    if (ran.exitCode === 0) {
252      const sha = isDetached(ran.stdout) ? (await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { timeoutMs: 5000 })).stdout : ''
253      head = parseHead(ran.stdout, sha)
254    }
255  } catch {
256    // No git on the host: no branch.
257  }
258  await update($, git, () => head)
259}
260
261async function toggle($: EngineInterface) {
262  await update($, isOpen, open => !open)
263}
264
265export const register: Register = on => {
266  let ticker: ReturnType<EngineInterface['clock']['every']> | undefined
267
268  on('session.start', async ($, e, next) => {
269    const result = await next(e)
270    await update($, isOpen, () => false)
271    await update($, cacheExpiresAt, () => null)
272    await Promise.all([refresh($), refreshGit($)])
273    ticker ??= $.clock.every(1000, () => $.ui.invalidate('ui.render'))
274    return result
275  })
276
277  on('session.end', async (_, e, next) => {
278    ticker?.cancel()
279    ticker = undefined
280    return next(e)
281  })
282
283  on('turn.step', async function* ($, e, next) {
284    const result = yield* next(e)
285    if (e.agentId || !result.usage) return result
286    const usage = result.usage
287    if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens === 0) {
288      await update($, cacheExpiresAt, () => null)
289      return result
290    }
291    const now = await $.clock.now()
292    const [force5m, ttl, enable1h, settings, session] = await Promise.all([
293      $.env.get('FORCE_PROMPT_CACHING_5M'),
294      $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
295      $.env.get('ENABLE_PROMPT_CACHING_1H'),
296      $.settings.read(),
297      $.session.usage(),
298    ])
299    const duration = cacheTtl({ force5m, ttl, enable1h, setting: settings.promptCacheTtl, limits: limitsOf(session) })
300    await update($, cacheExpiresAt, () => now + duration)
301    return result
302  })
303
304  // A turn may switch the branch or move into a worktree; so may the person,
305  // outside the session, between turns.
306  on('turn.complete', async ($, e, next) => {
307    const result = await next(e)
308    if (!e.agentId) await refreshGit($)
309    return result
310  })
311
312  on('classic.PostModelSwitch', async ($, e, next) => {
313    const result = await next(e)
314    await update($, cacheExpiresAt, () => null)
315    return result
316  })
317
318  on('session.compact', async ($, e, next) => {
319    const result = await next(e)
320    if (!e.agentId && e.trigger !== 'precompute' && result.skip === undefined) {
321      await update($, cacheExpiresAt, () => null)
322    }
323    return result
324  })
325
326  on('session.measure', async ($, e, next) => {
327    if (e.changed.includes('context')) {
328      await refresh($)
329    } else {
330      // Limits and cost only: keep the context figures the breakdown gave.
331      const limits = limitsOf(e)
332      await update($, snapshot, prev => (prev ? { ...prev, limits, usd: e.cost?.usd ?? prev.usd } : toSnapshot(e, null)))
333    }
334    return next(e)
335  })
336
337  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
338    const s = await read($, snapshot)
339    if (e.props.hasSurvey || s === null) return next(e)
340
341    const { Box, Button, Text } = $.ui.resolve(e)
342    const now = await $.clock.now()
343    const open = await read($, isOpen)
344    ticker ??= $.clock.every(1000, () => $.ui.invalidate('ui.render'))
345    const cacheLabel = cacheText(await read($, cacheExpiresAt), now)
346    const head = await read($, git)
347
348    // A wide bar spans `width` cells on the terminal: the drawer's inner width
349    // unless it sits in a narrower column. A narrow one is `width` cells, or px
350    // on the desktop.
351    const bar = (id: string, bar: Bar, label: string, isWide: boolean, width = e.props.bodyColumns - 2) => {
352      if (e.surface === 'desktop') {
353        const { Svg } = $.ui.resolve(e)
354        // A wide bar fills a row of its own, centred in it, so the text above
355        // and below sits the same distance away.
356        return isWide ? (
357          <Box key={id} height={1} flexDirection="column" justifyContent="center">
358            <Svg key={`${id}-svg`} source={svgBar(bar, 1000, 4, 4)} alt={label} height={4} />
359          </Box>
360        ) : (
361          <Svg key={id} source={svgBar(bar, width, 4, 1)} alt={label} width={width} height={4} />
362        )
363      }
364      const { cells, free } = textCells(bar.parts, isWide ? Math.max(10, width) : width)
365      return (
366        <Box key={id}>
367          {cells.map((c, i) => (
368            <Text key={`${id}-${i}`} color={c.color}>
369              {'━'.repeat(c.n)}
370            </Text>
371          ))}
372          <Text dimColor>{'━'.repeat(free)}</Text>
373        </Box>
374      )
375    }
376
377    const dot = (id: string, color: string) => (
378      <Text key={id} color={color}>
379        ●
380      </Text>
381    )
382
383    // The legend as a grid, as /context lays it out: each cell a dot and the
384    // name on the left, the size and share aligned on the right.
385    const cols = e.props.bodyColumns >= 128 ? 4 : e.props.bodyColumns >= 90 ? 3 : e.props.bodyColumns >= 60 ? 2 : 1
386    const legend = (id: string, b: Bar, maxRows = Infinity) => {
387      const items = [
388        ...b.parts.map(p => ({ color: p.color, title: p.title ?? '', tokens: p.tokens ?? 0 })),
389        ...(b.rest ? [{ color: TRACK, title: b.rest, tokens: b.restTokens ?? 0 }] : []),
390      ].sort((x, y) => y.tokens - x.tokens)
391      const width = Math.floor((e.props.bodyColumns - 2 - 3 * (cols - 1)) / cols)
392      const rows = Array.from({ length: Math.max(1, Math.min(maxRows, Math.ceil(items.length / cols))) }, (_, r) => items.slice(r * cols, r * cols + cols))
393      return (
394        <Box key={id} flexDirection="column">
395          {rows.map((row, r) => (
396            <Box key={`${id}-r${r}`} flexDirection="row" columnGap={3}>
397              {row.map((it, c) => {
398                const [name, ...rest] = it.title.split(' · ')
399                const value = rest.join(' · ').replace(/ \((\d+%)\)$/, '  $1')
400                return (
401                  <Box key={`${id}-${r}-${c}`} width={width} flexDirection="row" columnGap={1}>
402                    {dot(`${id}-dot-${r}-${c}`, it.color)}
403                    <Box key={`${id}-n-${r}-${c}`} flexGrow={1} flexShrink={1}>
404                      <Text dimColor wrap="truncate-end">
405                        {name}
406                      </Text>
407                    </Box>
408                    <Text dimColor>{value}</Text>
409                  </Box>
410                )
411              })}
412            </Box>
413          ))}
414        </Box>
415      )
416    }
417
418    // Its own keyed Box: the toggle brightens under the pointer, not the whole row.
419    const toggleButton = (
420      <Box key="details-box">
421        <Button
422          key="details"
423          plain
424          dimColor
425          hover={{ dimColor: false }}
426          label={open ? CLOSE_GLYPH : OPEN_GLYPH}
427          onPress={() => toggle($)}
428        />
429      </Box>
430    )
431
432    const ctx = contextBar(s)
433    const ctxRight =
434      s.tokens === undefined ? `— / ${kTokens(s.window)}` : `${kTokens(s.tokens)} / ${kTokens(s.window)} (${s.percent ?? 0}%)`
435    const toCompact =
436      s.autoCompactAt !== undefined && s.tokens !== undefined ? Math.max(0, s.autoCompactAt - s.tokens) : undefined
437    const compactLine =
438      toCompact === undefined
439        ? s.tokens === undefined
440          ? 'No reply yet in this window'
441          : `${kTokens(Math.max(0, s.window - s.tokens))} free`
442        : toCompact < 10_000
443          ? 'Auto-compacts soon'
444          : `${kTokens(toCompact)} until auto-compact`
445
446    if (open) {
447      // The drawer takes the band's place and must fit it whole: past maxRows
448      // the band scrolls. The limits share a row and the cost rides the compact
449      // row, so the breakdown keeps its room; gaps only when all of it fits.
450      const limitCols = e.props.bodyColumns >= 70 ? 2 : 1
451      // The columns share the inner width; the first ones take any odd cells.
452      const limitSpan = e.props.bodyColumns - 2 - 4 * (limitCols - 1)
453      const limitWidth = (c: number) => Math.floor(limitSpan / limitCols) + (c < limitSpan % limitCols ? 1 : 0)
454      const limitRows = Array.from({ length: Math.ceil(s.limits.length / limitCols) }, (_, r) =>
455        s.limits.slice(r * limitCols, r * limitCols + limitCols),
456      )
457      const legendRows = Math.ceil((ctx.parts.length + (ctx.rest ? 1 : 0)) / cols)
458      const base = 3 + 2 * limitRows.length
459      const hasGaps = base + legendRows + 1 + limitRows.length <= e.props.maxRows
460      return (
461        <Box flexDirection="column" rowGap={hasGaps ? 1 : 0} paddingX={1}>
462          <Box key="d-ctx" flexDirection="column">
463            <Box key="d-ctx-row" flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
464              <Text dimColor>Context window</Text>
465              <Box key="d-ctx-right" flexDirection="row" alignItems="center" columnGap={2}>
466                <Text dimColor>{ctxRight}</Text>
467                {toggleButton}
468              </Box>
469            </Box>
470            {bar('d-ctx-bar', ctx, `Context ${s.percent ?? 0}%`, true)}
471            {legend('d-ctx-legend', ctx, e.props.maxRows - base)}
472          </Box>
473          <Box key="d-compact" flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
474            <Box key="d-compact-left" flexDirection="row" alignItems="center" columnGap={1}>
475              <Text dimColor>{compactLine}</Text>
476              {s.usd !== undefined && <Text dimColor>· Session cost</Text>}
477              {s.usd !== undefined && <Text dimColor>{`$${s.usd.toFixed(2)}`}</Text>}
478            </Box>
479            <Button key="compact-now" variant="secondary" label="Compact session" onPress={() => $.session.compact()} />
480          </Box>
481          {limitRows.map((row, r) => (
482            <Box key={`d-lims-${r}`} flexDirection="row" columnGap={4}>
483              {row.map((l, c) => {
484                const reset = resetText(l, now)
485                return (
486                  <Box key={`d-lim-${l.kind}`} flexDirection="column" flexGrow={1} flexShrink={1} width={`${Math.floor(100 / limitCols)}%`}>
487                    <Box key={`d-lim-row-${l.kind}`} flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
488                      <Text wrap="truncate-end">{LONG_LABELS[l.kind] ?? l.kind}</Text>
489                      <Text dimColor wrap="truncate-end">{`${reset}${reset ? '  ' : ''}${l.percentUsed}%`}</Text>
490                    </Box>
491                    {bar(`d-lim-bar-${l.kind}`, limitBar(l, now), `${l.kind} ${l.percentUsed}%`, true, limitWidth(c))}
492                  </Box>
493                )
494              })}
495            </Box>
496          ))}
497        </Box>
498      )
499    }
500
501    // A desktop cell is about 8px wide: the band's inner gaps take half of one
502    // there. Terminal gaps are whole cells.
503    const isDesktop = e.surface === 'desktop'
504    const half = isDesktop ? 0.5 : 1
505    const percentText = (percent: number | undefined) => (percent === undefined ? '—' : `${percent}%`)
506
507    const meters = [
508      // One fill, as the limits have: at this size the context's grey
509      // categories vanish into the track. The drawer breaks it down.
510      { id: 'ctx', label: 'Context', b: { parts: [{ color: contextColor(s), part: Math.min(1, (s.percent ?? 0) / 100) }] }, percent: s.percent, note: '' },
511      ...s.limits.map(l => ({
512        id: `lim-${l.kind}`,
513        label: SHORT_LABELS[l.kind] ?? l.kind,
514        b: limitBar(l, now),
515        percent: l.percentUsed,
516        note: resetIn(l, now),
517      })),
518    ]
519    // The bars narrow, then turn to rings on the desktop, then the branch
520    // shortens, before anything is dropped.
521    const model = isDesktop ? DESKTOP : TERMINAL
522    const fit = fitBand(
523      {
524        meters: meters.map(m => ({ label: m.label, percent: percentText(m.percent), note: m.note })),
525        branch: head ? branchName(head) : undefined,
526        worktree: head?.worktree !== undefined,
527        cache: cacheLabel,
528      },
529      roomOf(model, e.props.bodyColumns),
530      model,
531    )
532
533    const svg = (id: string, source: string, alt: string, width: number, height: number) => {
534      if (e.surface !== 'desktop') return null
535      const { Svg } = $.ui.resolve(e)
536      return <Svg key={id} source={source} alt={alt} width={width} height={height} />
537    }
538    const icon = (id: string, body: string, alt: string) => svg(id, svgIcon(body), alt, ICON_PX, ICON_PX)
539
540    const meter = ({ id, label, b, percent, note }: (typeof meters)[number]) => (
541      <Box
542        key={id}
543        flexDirection="row"
544        alignItems="center"
545        columnGap={half}
546        paddingX={half}
547        flexShrink={0}
548        hover={{ backgroundColor: HOVER_BG }}
549      >
550        {/* Only Buttons take a press, so each word of the meter is one: a click
551            anywhere on its text opens the drawer. */}
552        <Button key={`${id}-label`} plain dimColor label={label} onPress={() => toggle($)} />
553        {/* A bar or ring takes no press, so on the desktop a blank plain Button
554            lies over it. On a text surface that Button would paint over the bar's
555            cells, so there the label and figures take the press alone. */}
556        {/* A ring sits closer to its figure than to its label, so the two read
557            as one. */}
558        <Box key={`${id}-fill`} flexDirection="row" alignItems="center" columnGap={fit.isRing ? 0 : half}>
559          <Box flexDirection="row" alignItems="center">
560            {fit.isRing
561              ? svg(`${id}-bar`, svgRing((percent ?? 0) / 100, fit.bar, b.parts[0]?.color ?? BLUE), `${label} ${percent ?? 0}%`, fit.bar, fit.bar)
562              : bar(`${id}-bar`, b, `${label} ${percent ?? 0}%`, false, fit.bar)}
563            {isDesktop && (
564              <Box position="absolute" top={0} left={0}>
565                <Button key={`${id}-hit`} plain label={hitLabel(fit.bar)} hover={{ inverse: false }} onPress={() => toggle($)} />
566              </Box>
567            )}
568          </Box>
569          <Button key={`${id}-pct`} plain label={percentText(percent)} onPress={() => toggle($)} />
570        </Box>
571        {note !== '' && <Button key={`${id}-note`} plain dimColor label={note} onPress={() => toggle($)} />}
572      </Box>
573    )
574
575    return (
576      <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2 * half}>
577        {/* One row that wraps onto a hidden second one: whatever the fit
578            misjudges leaves whole, from the right, rather than overlapping or
579            being cut mid-word. The branch sits last and grows to push itself
580            right, so it is the first to go, with the divider that parts it
581            from the cache. On the desktop the meters' own padding spaces them. */}
582        <Box key="meters" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={model.gap} height={1} flexGrow={1} flexShrink={1} overflow="hidden">
583          {meters.map(meter)}
584          {head && fit.branch !== undefined && (
585            <Box key="git" flexDirection="row" justifyContent="flex-end" alignItems="center" columnGap={half} paddingLeft={isDesktop ? 0 : 1} flexGrow={1} flexShrink={0}>
586              <Box key="git-name" flexDirection="row" alignItems="center" columnGap={half}>
587                {isDesktop && icon('git-icon', head.worktree === undefined ? ICONS.branch : ICONS.worktree, head.worktree === undefined ? 'Branch' : 'Worktree branch')}
588                {/* The terminal has no icons: a label stands in, as "Cache" does. */}
589                {!isDesktop && <Text dimColor>{GIT_WORD}</Text>}
590                <Text>{fit.branch}</Text>
591                {/* A name shown short is whole under the pointer, drawn over the
592                    row and ending where the short one does. Unkeyed, so the
593                    pointer on the name reveals it. An interactive Svg's
594                    tooltip would sit in a frame that paints white each redraw.
595                    Terminal cells cover what is under them; the desktop needs
596                    a ground. */}
597                {fit.branch !== head.branch && (
598                  <Box
599                    position="absolute"
600                    top={0}
601                    right={0}
602                    display="none"
603                    hover={{ display: 'flex' }}
604                    backgroundColor={isDesktop ? TIP_BG : undefined}
605                    paddingX={isDesktop ? half : 0}
606                  >
607                    <Text color={isDesktop ? TIP_TEXT : undefined}>{head.branch}</Text>
608                  </Box>
609                )}
610              </Box>
611              {!isDesktop && head.worktree !== undefined && <Text dimColor>{WORKTREE_NOTE}</Text>}
612              <Box key="git-divider" marginLeft={half} flexDirection="row" alignItems="center">
613                {/* With an empty alt the desktop drew nothing here. */}
614                {isDesktop ? svg('divider', SVG_DIVIDER, 'Separator', 1, 14) : <Text dimColor>│</Text>}
615              </Box>
616            </Box>
617          )}
618        </Box>
619        <Box key="band-right" flexDirection="row" alignItems="center" columnGap={2 * half} flexShrink={0}>
620          {/* The time's Button pads it enough from the clock, so they sit as
621              close as the branch and its icon; the terminal's word keeps a cell. */}
622          <Box key="cache" flexDirection="row" alignItems="center" columnGap={isDesktop ? 0 : half}>
623            {/* An icon or a dim word, then the value bright, as the branch is. */}
624            {isDesktop ? icon('cache-icon', ICONS.clock, 'Prompt cache') : <Button key="cache-label" plain dimColor label={CACHE_WORD} onPress={() => toggle($)} />}
625            <Button key="cache-time" plain label={cacheLabel} onPress={() => toggle($)} />
626          </Box>
627          {toggleButton}
628        </Box>
629      </Box>
630    )
631  })
632}
633
hooks/cache.ts 32 lines
1import type { Limit } from '../types'
2
3type CacheOptions = {
4  force5m?: string
5  ttl?: string
6  setting?: unknown
7  enable1h?: string
8  limits: Limit[]
9}
10
11// Limits identify the subscription default. The host does not expose its
12// billing mode, so this remains an estimate when no TTL is configured.
13export const cacheTtl = ({ force5m, ttl, setting, enable1h, limits }: CacheOptions): number => {
14  if (force5m === '1') return 300_000
15  for (const value of [ttl, setting]) {
16    if (value === '5m') return 300_000
17    if (value === '1h') return 3_600_000
18  }
19  if (enable1h === '1') return 3_600_000
20  const plan = limits.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
21  return plan.length > 0 && plan.every(l => l.percentUsed < 100) ? 3_600_000 : 300_000
22}
23
24// Whole minutes until the last one, then seconds: the label keeps its width
25// rather than changing every second. The band puts a clock or "Cache" before it.
26export const cacheText = (expiresAt: number | null, now: number): string => {
27  if (expiresAt === null) return '—'
28  const seconds = Math.max(0, Math.ceil((expiresAt - now) / 1000))
29  if (seconds === 0) return 'expired'
30  return seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`
31}
32
hooks/fit.ts 135 lines
1// The band's width budget. The meters' words, the cache and the toggle keep
2// their size; the bars give way first, down to a stub or on the desktop to a
3// ring, then the branch is cut short, then it goes. The band's wrapping row
4// still drops whatever this misjudges, whole and from the right, so nothing
5// ever overlaps.
6
7export type Meter = { label: string; percent: string; note: string }
8export type BandText = { meters: Meter[]; branch?: string; worktree: boolean; cache: string }
9// The bars' width (cells on the terminal, px on the desktop), whether rings
10// stand in for them, and the branch as drawn: absent when it does not fit.
11export type Fit = { bar: number; isRing?: boolean; branch?: string }
12
13export type Model = {
14  bar: { min: number; max: number }
15  // A ring `size` wide stands in for bars narrower than `min`, and sits
16  // `closer` to its figure than a bar does. Without one the bars stop at `min`.
17  ring?: { size: number; closer: number }
18  text: (s: string) => number
19  // Between the band's row items: the meters and the branch.
20  gap: number
21  // A meter with no bar.
22  meter: (m: Meter) => number
23  git: (branch: string, worktree: boolean) => number
24  // The cache and the toggle, with the gap before them.
25  right: (cache: string) => number
26}
27
28// The words the terminal spells out where the desktop draws icons.
29export const GIT_WORD = 'git'
30export const WORKTREE_NOTE = '· worktree'
31export const CACHE_WORD = 'Cache'
32export const OPEN_GLYPH = '+'
33
34export const BAR_CELLS = 8
35export const BAR_PX = 48
36export const ICON_PX = 13
37export const RING_PX = 14
38// A branch keeps this many characters, the ellipsis included, or goes.
39const BRANCH_MIN = 8
40
41const isWide = (c: number) =>
42  (c >= 0x1100 && c <= 0x115f) ||
43  (c >= 0x2e80 && c <= 0xa4cf) ||
44  (c >= 0xac00 && c <= 0xd7a3) ||
45  (c >= 0xf900 && c <= 0xfaff) ||
46  (c >= 0xfe30 && c <= 0xfe4f) ||
47  (c >= 0xff00 && c <= 0xff60) ||
48  (c >= 0xffe0 && c <= 0xffe6) ||
49  (c >= 0x1f300 && c <= 0x1faff) ||
50  c >= 0x20000
51
52export const cells = (s: string) => [...s].reduce((n, c) => n + (isWide(c.codePointAt(0) ?? 0) ? 2 : 1), 0)
53
54// Terminal cells are exact: the engine lays the band out in `bodyColumns`.
55// A meter is padded a cell each side, its words a cell apart; the branch is
56// "git main │" after a cell of padding, the divider a cell further off.
57export const TERMINAL: Model = {
58  bar: { min: 3, max: BAR_CELLS },
59  text: cells,
60  gap: 1,
61  meter: m => 2 + cells(m.label) + 2 + cells(m.percent) + (m.note ? 1 + cells(m.note) : 0),
62  git: (branch, worktree) => 1 + cells(GIT_WORD) + 1 + cells(branch) + (worktree ? 1 + cells(WORKTREE_NOTE) : 0) + 3,
63  right: cache => 2 + cells(CACHE_WORD) + 1 + cells(cache) + 2 + cells(OPEN_GLYPH),
64}
65
66// The desktop's text is proportional, so its widths are estimates, measured
67// from the Code tab at 14px and rounded up: a cell is about 8.1px, its gaps
68// half of one, and a plain Button pads its label 6px each side.
69export const CELL_PX = 8.1
70// The Code tab's column is about 768px wide at most; the band's padding
71// leaves this much inside it.
72const BAND_PX = 750
73const HALF = CELL_PX / 2
74const PAD = 6
75const PX: [RegExp, number][] = [
76  [/[ijl.,:;'|!`]/, 3.2],
77  [/[ftrI()[\]{}\\]/, 4.4],
78  [/[/-]/, 5.4],
79  [/ /, 3.6],
80  [/[scz]/, 5.8],
81  [/[mwMW%—]/, 10.5],
82  [/…/, 13],
83  [/1/, 5.4],
84  [/[0-9]/, 7.4],
85  [/[A-Z]/, 8.2],
86]
87export const px = (s: string) =>
88  [...s].reduce((n, c) => n + (isWide(c.codePointAt(0) ?? 0) ? 11 : (PX.find(([r]) => r.test(c))?.[1] ?? 7.2)), 0)
89const button = (s: string) => px(s) + 2 * PAD
90
91// A meter is padded half a cell each side, its words half a cell apart; the
92// meters touch. The branch is its icon, the name and the divider. A bar much
93// under half its width reads as a dash, so a ring takes its place.
94export const DESKTOP: Model = {
95  bar: { min: 28, max: BAR_PX },
96  ring: { size: RING_PX, closer: HALF },
97  text: px,
98  gap: 0,
99  meter: m => 2 * HALF + button(m.label) + 2 * HALF + button(m.percent) + (m.note ? HALF + button(m.note) : 0),
100  git: branch => ICON_PX + HALF + px(branch) + 2 * HALF + 1,
101  right: cache => 2 * HALF + ICON_PX + button(cache) + 2 * HALF + button(OPEN_GLYPH),
102}
103
104// What the band may fill: the terminal's columns, or on the desktop the pane's
105// width less a margin, within the column's own.
106export const roomOf = (model: Model, bodyColumns: number) =>
107  model === DESKTOP ? Math.min(BAND_PX, (bodyColumns - 2) * CELL_PX) : bodyColumns
108
109export const clip = (s: string, max: number) => ([...s].length > max ? `${[...s].slice(0, max - 1).join('')}…` : s)
110
111export const fitBand = (t: BandText, room: number, model: Model): Fit => {
112  const n = t.meters.length
113  const fixed = t.meters.reduce((w, m) => w + model.meter(m), 0) + Math.max(0, n - 1) * model.gap + model.right(t.cache)
114  const barIn = (left: number) => (n === 0 ? model.bar.max : Math.min(model.bar.max, Math.floor(left / n)))
115  // The narrowest the meters draw: stub bars, or rings, and the room each
116  // takes from the row.
117  const least: Fit = model.ring === undefined ? { bar: model.bar.min } : { bar: model.ring.size, isRing: true }
118  const leastTakes = model.ring === undefined ? model.bar.min : model.ring.size - model.ring.closer
119  const sized = (bar: number): Fit => (bar >= model.bar.min ? { bar } : least)
120  const alone = sized(barIn(room - fixed))
121  if (t.branch === undefined) return alone
122  const withGit = (name: string) => fixed + (n > 0 ? model.gap : 0) + model.git(name, t.worktree)
123  const whole = barIn(room - withGit(t.branch))
124  if (whole >= model.bar.min) return { bar: whole, branch: t.branch }
125  // The narrowest meters, and the name whole or cut to what the row has left.
126  const left = room - withGit('') - n * leastTakes
127  const chars = [...t.branch].length
128  for (let k = chars; k >= Math.min(chars, BRANCH_MIN); k--) {
129    const cut = clip(t.branch, k)
130    if (model.text(cut) <= left) return { ...least, branch: cut }
131  }
132  // No room for the branch: it goes, and the bars take back its share.
133  return alone
134}
135
hooks/git.ts 34 lines
1import type { GitHead } from '../types'
2import { clip } from './fit'
3
4// One call answers all four: the git dir, the common dir, the working tree's
5// top and the branch ("HEAD" when detached).
6export const HEAD_ARGV = [
7  'git',
8  'rev-parse',
9  '--path-format=absolute',
10  '--git-dir',
11  '--git-common-dir',
12  '--show-toplevel',
13  '--abbrev-ref',
14  'HEAD',
15] as const
16
17export const isDetached = (stdout: string) => stdout.trim().split(/\r?\n/)[3] === 'HEAD'
18
19// A linked worktree keeps a git dir of its own under the common one; the main
20// working tree's are the same.
21export const parseHead = (stdout: string, sha = ''): GitHead | null => {
22  const [gitDir, commonDir, top, ref] = stdout.trim().split(/\r?\n/).map(l => l.trim())
23  if (!gitDir || !commonDir || !top || !ref) return null
24  const branch = ref === 'HEAD' ? sha.trim() : ref
25  if (!branch) return null
26  const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
27  return norm(gitDir) === norm(commonDir) ? { branch } : { branch, worktree: norm(top).split('/').pop() ?? top }
28}
29
30// The name after its last slash: "feature/NEXT-1777" is "NEXT-1777", and a
31// worktree's "claude/fix-band" is "fix-band". The band shortens it further
32// when the row is full, and shows the whole on hover.
33export const branchName = (head: GitHead, max = 48) => clip(head.branch.split('/').pop() || head.branch, max)
34
types/index.d.ts 26 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Segment = { name: string; tokens: number; color: string }
4
5export type Snapshot = {
6  tokens?: number
7  window: number
8  percent?: number
9  limits: Limit[]
10  usd?: number
11  /** Used categories, largest first, as /context and the usage popover colour them. */
12  segments: Segment[]
13  /** The auto-compact buffer, drawn after the used segments in a darker gray. */
14  buffer?: number
15  autoCompactAt?: number
16}
17
18/** The session's checkout: its branch (a short sha when detached) and, in a linked worktree, that worktree's folder name. */
19export type GitHead = { branch: string; worktree?: string }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'usage-band': { snapshot: Snapshot | null; isOpen: boolean; cacheExpiresAt: number | null; git: GitHead | null }
24  }
25}
26