SLOPSHOPPER

usage-line

Two-line status under the prompt: model, project, branch, cost, context and 5h/7d rate-limit bars

spinnerstatusprocesstimer
v0.2.1no licenseupdated 2026-10-13hassanhmd23/claude-plugins/plugins/usage-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-line
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 · app · feat/auth-refresh · $0.42 · 30m ctx ━━━━━━━━━━ 49% 97k/200k · 5h ━━━━━━━━━━ 31% ↻NaNm

Draws

Prompt hint
Opus 5.5 · app · feat/auth-refresh · $0.42 · 30m ctx ━━━━━━━━━━ 49% 97k/200k · 5h ━━━━━━━━━━ 31% ↻NaNm
README

usage-line

A Claude Code mod that draws two rows under the prompt, in place of the shortcuts hint:

Opus 5.5 (medium) · my-project · main · $2.11 · 23m · [CAVEMAN]
ctx ━╸━━━━━━━━ 12% 116k/1000k · 5h ━╸━━━━━━━━ 10% ↻4h36m · 7d ━━━╸━━━━━━ 36% @Sun 17:00

Model and effort · project · git branch · session cost · minutes elapsed · the caveman badge when installed. Then the context window and the 5-hour and 7-day rate limits, each bar green under 50%, yellow under 80%, red above. Fields Claude Code doesn't have (rate limits off a subscription, branch outside git) are left out. It refreshes after each turn, when usage moves, and every minute.

Install

Type at the prompt of a Claude Code terminal session:

/plugin install usage-line --marketplace hassanhmd23/claude-plugins

Answer y to add the marketplace, then pick a scope.

To get updates automatically, run /plugin, open Marketplaces, select hassanhammoud-plugins and choose Enable auto-update. Otherwise update with claude plugin update usage-line@hassanhammoud-plugins.

Source 3 files
hooks/register.tsx 118 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3import type { Snapshot } from '../types'
4import { bar, k, modelName, snapshot, tone } from './format'
5
6const current = atom({ plugin: 'usage-line', key: 'snapshot' } as const, null)
7
8// Set from each model request; the session API has no getter for it.
9let effort: string | undefined
10
11// The caveman plugin's badge, when it is installed; its colours are dropped for ours.
12async function badge($: EngineInterface) {
13  try {
14    const home = await $.env.get('HOME')
15    const ran = await $.process.run(['bash', `${home}/.claude/hooks/caveman-statusline.sh`], { timeoutMs: 2000 })
16    const text = ran.exitCode === 0 ? ran.stdout.replace(/\x1b\[[0-9;]*m/g, '').trim() : ''
17    return text || undefined
18  } catch {
19    return undefined
20  }
21}
22
23async function refresh($: EngineInterface) {
24  const [model, root, usage, now, git, caveman] = await Promise.all([
25    $.session.model(),
26    $.session.root(),
27    $.session.usage(),
28    $.clock.now(),
29    $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 2000 }).catch(() => undefined),
30    badge($),
31  ])
32  const branch = git?.exitCode === 0 ? git.stdout.trim() || undefined : undefined
33  const next = snapshot({ model: modelName(model), effort, project: root.split('/').pop() ?? root, branch, badge: caveman, usage, now })
34  await update($, current, () => next)
35}
36
37export const register: Register = on => {
38  on('session.start', async ($, e, next) => {
39    const started = await next(e)
40    // Clears the notice 0.1.0 pinned with $.ui.status; the host keeps it across updates.
41    $.ui.status(undefined)
42    await refresh($)
43    // Keeps the reset countdowns moving between turns.
44    $.clock.every(60_000, () => refresh($))
45    return started
46  })
47
48  on('turn.step', async function* ($, e, next) {
49    effort = e.effort === undefined ? undefined : String(e.effort)
50    return yield* next(e)
51  })
52
53  on('session.measure', async ($, e, next) => {
54    await refresh($)
55    return next(e)
56  })
57
58  on('turn.complete', async ($, e, next) => {
59    await refresh($)
60    return next(e)
61  })
62
63  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
64    const s: Snapshot | null = await read($, current)
65    if (!s) return next(e)
66
67    const { Box, Text } = $.ui.resolve(e)
68    const sep = <Text dimColor> · </Text>
69    const gauge = (label: string, percent: number) => {
70      const [fill, track] = bar(percent)
71      return (
72        <Text>
73          {label} <Text color={tone(percent)}>{fill}</Text>
74          <Text dimColor>{track}</Text> <Text color={tone(percent)}>{Math.round(percent)}%</Text>
75        </Text>
76      )
77    }
78
79    const top = [
80      <Text>
81        {s.model}
82        {s.effort && <Text dimColor> ({s.effort})</Text>}
83      </Text>,
84      <Text>{s.project}</Text>,
85      s.branch && <Text>{s.branch}</Text>,
86      s.cost !== undefined && <Text>${s.cost.toFixed(2)}</Text>,
87      <Text>{s.minutes}m</Text>,
88      s.badge && <Text color="#d78700">{s.badge}</Text>,
89    ].filter(Boolean) as RenderChildren[]
90
91    const bottom = [
92      s.context && (
93        <Text>
94          {gauge('ctx', s.context.percent)}
95          <Text dimColor> {k(s.context.tokens)}/{k(s.context.window)}</Text>
96        </Text>
97      ),
98      ...s.limits.map(l => (
99        <Text>
100          {gauge(l.label, l.percent)}
101          {l.reset && <Text dimColor> {l.reset}</Text>}
102        </Text>
103      )),
104    ].filter(Boolean) as RenderChildren[]
105
106    const row = (parts: RenderChildren[]) => (
107      <Text wrap="truncate">{parts.flatMap((p, i) => (i ? [sep, p] : [p]))}</Text>
108    )
109
110    return (
111      <Box flexDirection="column">
112        {row(e.props.isWorking ? [...top, <Text dimColor>{e.props.hint}</Text>] : top)}
113        {bottom.length > 0 && row(bottom)}
114      </Box>
115    )
116  })
117}
118
hooks/format.ts 62 lines
1import type { SessionUsage } from 'claude-code'
2import type { Limit, Snapshot } from '../types'
3
4// 10-cell bar with half steps, split so the track can be drawn dim: 45 -> ["━━━━╸", "━━━━━"]
5export const bar = (percent: number): [fill: string, track: string] => {
6  const halves = Math.round(Math.min(Math.max(percent, 0), 100) / 5)
7  const full = Math.floor(halves / 2)
8  const half = halves % 2 ? '╸' : ''
9  return ['━'.repeat(full) + half, '━'.repeat(10 - full - half.length)]
10}
11
12// Green under 50, yellow under 80, red from 80.
13export const tone = (percent: number): 'success' | 'warning' | 'error' =>
14  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
15
16// 152340 -> "152k"
17export const k = (n: number): string => (n >= 1000 ? `${Math.floor(n / 1000)}k` : `${n}`)
18
19// ms until reset -> "2h10m" / "3d11h" / "now"
20export const until = (ms: number): string => {
21  const s = Math.floor(ms / 1000)
22  if (s <= 0) return 'now'
23  if (s >= 86400) return `${Math.floor(s / 86400)}d${Math.floor((s % 86400) / 3600)}h`
24  if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
25  return `${Math.floor(s / 60)}m`
26}
27
28// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else as given.
29export const modelName = (id: string): string => {
30  const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
31  return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}.${m[3]}` : id
32}
33
34const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
35
36// Epoch ms -> "Sun 17:00", local time.
37export const clock = (ms: number): string => {
38  const d = new Date(ms)
39  return `${DAYS[d.getDay()]} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
40}
41
42// The 5h window counts down; the 7d one names when it resets, as the bash status line did.
43const limit = ({ kind, percentUsed, resetsAt }: SessionUsage['rateLimits'][number], now: number): Limit => {
44  const at = resetsAt ? Date.parse(resetsAt) : undefined
45  if (kind === 'five_hour') return { label: '5h', percent: percentUsed, reset: at === undefined ? undefined : `↻${until(at - now)}` }
46  if (kind === 'seven_day') return { label: '7d', percent: percentUsed, reset: at === undefined ? undefined : `@${clock(at)}` }
47  return { label: kind === 'spend_limit' ? '$' : kind, percent: percentUsed }
48}
49
50export type SnapshotInput = Omit<Snapshot, 'cost' | 'minutes' | 'context' | 'limits'> & { usage: SessionUsage; now: number }
51
52export const snapshot = ({ usage, now, ...rest }: SnapshotInput): Snapshot => {
53  const { percent, tokens, window } = usage.context
54  return {
55    ...rest,
56    cost: usage.cost?.usd,
57    minutes: Math.floor((now - usage.startedAt) / 60000),
58    context: percent === undefined ? undefined : { percent, tokens: tokens ?? 0, window },
59    limits: usage.rateLimits.map(l => limit(l, now)),
60  }
61}
62
types/index.d.ts 20 lines
1export type Limit = { label: string; percent: number; reset?: string }
2
3export type Snapshot = {
4  model: string
5  effort?: string
6  project: string
7  branch?: string
8  cost?: number
9  minutes: number
10  context?: { percent: number; tokens: number; window: number }
11  limits: Limit[]
12  badge?: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'usage-line': { snapshot: Snapshot | null }
18  }
19}
20