SLOPSHOPPER

usage-line

Shows context fill and the session and weekly limits as bars above the message box.

bandtimer
v0.4.0no licenseupdated 2026-10-21thomthom/claude-usage-line
A shopper browsing a rack in a slop shop
README

usage-line

A Claude Code mod that shows three bars above the message box:

Three bars above the Claude Code message box: Context 21%, Session 86% resetting in 37m, Weekly 11% resetting in 2d 6h.

  • Context: how full the session's context window is, in tokens.
  • Session: how much of the 5-hour limit is used, and when it resets.
  • Weekly: how much of the weekly limit is used, and when it resets.

A bar turns amber from 70% and red from 90%. The terminal shows the same figures as text.

Settings

Show usage in the terminal (showInTerminal, off by default) can be turned on in /config to show the figures as text in the terminal. The desktop app shows the bars either way.

Install

Type this at the Claude Code prompt:

/plugin install usage-line --marketplace thomthom/claude-usage-line

Answer y to add the marketplace, then pick a scope. New sessions show the rings.

Update

claude plugin update usage-line

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 76 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { BAR_HEIGHT, barSvg, gauges } from './format'
5
6// The band reads this only so that each new measurement redraws it.
7const measuredAt = atom({ plugin: 'usage-line', key: 'measuredAt' } as const, 0)
8
9async function remeasure($: EngineInterface): Promise<void> {
10  const now = await $.clock.now()
11  await update($, measuredAt, () => now)
12}
13
14const LEVEL_COLORS = { normal: undefined, warning: 'warning', critical: 'error' } as const
15
16export const register: Register = (on, options) => {
17  on('session.start', async ($, e, next) => {
18    const started = await next(e)
19    // Re-measuring each minute keeps the time until each limit resets current between turns.
20    $.clock.every(60_000, () => remeasure($))
21    await remeasure($)
22    return started
23  })
24
25  on('session.measure', async ($, e, next) => {
26    const measured = await next(e)
27    await remeasure($)
28    return measured
29  })
30
31  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
32    await read($, measuredAt)
33    if (e.props.hasSurvey) return next(e)
34    if (e.surface === 'terminal' && options.showInTerminal !== true) return next(e)
35
36    const { context, rateLimits } = await $.session.usage()
37    const shown = gauges(context, rateLimits, await $.clock.now())
38    if (shown.length === 0) return next(e)
39
40    if (e.surface === 'terminal') {
41      const { Box, Text } = $.ui.resolve(e)
42      return (
43        <Box flexDirection="row" gap={3}>
44          {shown.map(gauge => (
45            <Text key={gauge.key} color={LEVEL_COLORS[gauge.level]}>
46              {gauge.label} {gauge.percent}% <Text dimColor>{gauge.detail}</Text>
47            </Text>
48          ))}
49        </Box>
50      )
51    }
52
53    const { Box, Text, Svg } = $.ui.resolve(e)
54    return (
55      <Box flexDirection="row" gap="lg">
56        {shown.map(gauge => (
57          <Box key={gauge.key} flexDirection="column" gap="xs" flexGrow={1} flexShrink={1}>
58            <Box flexDirection="row" justifyContent="space-between" gap="sm">
59              <Text weight="medium">{gauge.label}</Text>
60              <Text size="footnote" dimColor tabular color={LEVEL_COLORS[gauge.level]}>
61                {gauge.percent}%{gauge.detail ? ` · ${gauge.detail}` : ''}
62              </Text>
63            </Box>
64            {/* An Svg is sized in pixels. A Box's height counts text rows, which is far too tall for a bar. */}
65            <Svg
66              source={barSvg(gauge.percent, gauge.level)}
67              alt={`${gauge.label} ${gauge.percent}%`}
68              height={BAR_HEIGHT}
69            />
70          </Box>
71        ))}
72      </Box>
73    )
74  })
75}
76
hooks/format.ts 88 lines
1import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3export type Level = 'normal' | 'warning' | 'critical'
4
5export type Gauge = {
6  key: string
7  label: string
8  percent: number
9  detail: string
10  level: Level
11}
12
13const LIMIT_LABELS: Record<string, string> = {
14  five_hour: 'Session',
15  seven_day: 'Weekly',
16}
17
18export function formatTimeLeft(resetsAt: string, now: number): string {
19  const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60_000))
20  const days = Math.floor(minutes / 1440)
21  const hours = Math.floor((minutes % 1440) / 60)
22  if (days > 0) return `${days}d ${hours}h`
23  if (hours > 0) return `${hours}h ${minutes % 60}m`
24  return `${minutes}m`
25}
26
27export function formatTokens(tokens: number): string {
28  if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
29  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
30  return `${tokens}`
31}
32
33export function levelOf(percent: number): Level {
34  if (percent >= 90) return 'critical'
35  if (percent >= 70) return 'warning'
36  return 'normal'
37}
38
39export function gauges(
40  context: SessionContextUsage,
41  rateLimits: SessionRateLimit[],
42  now: number,
43): Gauge[] {
44  const result: Gauge[] = []
45  if (context.percent !== undefined) {
46    result.push({
47      key: 'context',
48      label: 'Context',
49      percent: context.percent,
50      detail: `${formatTokens(context.tokens ?? 0)} / ${formatTokens(context.window)}`,
51      level: levelOf(context.percent),
52    })
53  }
54  for (const limit of rateLimits) {
55    const percent = Math.round(limit.percentUsed)
56    result.push({
57      key: limit.kind,
58      label: LIMIT_LABELS[limit.kind] ?? limit.kind,
59      percent,
60      detail: limit.resetsAt ? `resets in ${formatTimeLeft(limit.resetsAt, now)}` : '',
61      level: levelOf(percent),
62    })
63  }
64  return result
65}
66
67const BAR_COLORS: Record<Level, string> = {
68  normal: '#3b82f6',
69  warning: '#e0a526',
70  critical: '#e5534b',
71}
72
73export const BAR_HEIGHT = 4
74
75// The viewBox is 100 units wide so the filled width is the percentage itself, and
76// preserveAspectRatio="none" stretches it to whatever width the column has.
77// The track is grey at partial opacity so it reads on light and dark themes.
78export function barSvg(percent: number, level: Level): string {
79  const filled = Math.min(100, Math.max(0, percent))
80  return (
81    `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${BAR_HEIGHT}" ` +
82    `viewBox="0 0 100 ${BAR_HEIGHT}" preserveAspectRatio="none">` +
83    `<rect width="100" height="${BAR_HEIGHT}" fill="#808080" fill-opacity="0.35"/>` +
84    `<rect width="${filled}" height="${BAR_HEIGHT}" fill="${BAR_COLORS[level]}"/>` +
85    '</svg>'
86  )
87}
88
types/index.d.ts 9 lines
1// Milliseconds since the epoch, from $.clock.now().
2export type Timestamp = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-line': { measuredAt: Timestamp }
7  }
8}
9