SLOPSHOPPER

usage-gauge

Gauge in the prompt footer for the 5-hour session and weekly usage limits, then context window fill

newspinner
A shopper browsing a rack in a slop shop
README

.DOTFILES for Firgrep

The start of a dotty journey.

Feel welcome to copy!

make stow to populate symlinks.

stow defaults

Without a .stow-local-ignore file, stow will use defaults, but if this file exists then the defaults will be ignored.

tmux plugins setup

atuin better history search

  • run bash <(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)
  • restart terminals
  • run atuin import auto (local syncing only, no cloud)

neovim requirements

  • sudo apt install ripgrep
  • sudo apt install fd-find
  • Lazygit `` LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | \grep -Po '"tag_name": *"v\K[^"]*') curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" tar xf lazygit.tar.gz lazygit sudo install lazygit -D -t /usr/local/bin/ ``

claude usage-gauge mod

Status line mod in .claude/mods/usage-gauge/. It shows:

  • model name with an icon
  • compaction count (hidden at 0, gray up to 3, orange from 4, red from 10)
  • Session and Week limits with a bar, percent and reset time
  • context size, e.g. ctx 84k/1M (8%)

Needs a Nerd Font for the icons.

Source 2 files
hooks/register.tsx 178 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, ThemeKey } from 'claude-code'
3
4import type { Fill, Limit } from '../types'
5
6const limits = atom({ plugin: 'usage-gauge', key: 'limits' } as const, [] as Limit[])
7const fill = atom({ plugin: 'usage-gauge', key: 'fill' } as const, null as Fill | null)
8const model = atom({ plugin: 'usage-gauge', key: 'model' } as const, null as string | null)
9const compactions = atom({ plugin: 'usage-gauge', key: 'compactions' } as const, 0)
10
11const WIDTH = 10
12const NARROW = 100
13const LABELS: Record<string, string> = { five_hour: 'Session', seven_day: 'Week' }
14
15const ICONS: Record<string, string> = {
16  fable: '\u{F14F7}',
17  opus: '\u{F075A}',
18  sonnet: '\u{F03EB}',
19  haiku: '\u{F024A}',
20}
21
22export const modelLabel = (name: string) => {
23  const short = name.replace(/^claude-/, '')
24  const icon = ICONS[Object.keys(ICONS).find(k => short.toLowerCase().includes(k)) ?? '']
25
26  return icon ? `${icon} ${short}` : short
27}
28
29const COMPACT_ICON = '\u{F0615}'
30
31export const compactColor = (count: number): ThemeKey =>
32  count >= 10 ? 'error' : count >= 4 ? 'warning' : 'subtle'
33
34export const bar = (percent: number) => {
35  const filled = Math.round((Math.min(percent, 100) / 100) * WIDTH)
36
37  return '█'.repeat(filled) + '░'.repeat(WIDTH - filled)
38}
39
40export const colorFor = (percent: number): ThemeKey =>
41  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'subtle'
42
43export const until = (resetsAt: string | undefined, now: number) => {
44  if (!resetsAt) {
45    return ''
46  }
47
48  const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
49  const days = Math.floor(minutes / 1440)
50  const hours = Math.floor((minutes % 1440) / 60)
51
52  return days > 0 ? `${days}d${hours}h` : hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
53}
54
55const short = (n: number) =>
56  n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
57
58const toFill = (c: SessionContextUsage): Fill => {
59  const b = c.breakdown
60
61  return {
62    tokens: c.tokens ?? null,
63    window: c.window,
64    percent: c.percent ?? null,
65    compact: b?.isAutoCompactEnabled && b.rawMaxTokens < c.window ? b.rawMaxTokens : null,
66  }
67}
68
69export const contextPercent = (f: Fill) =>
70  f.tokens === null
71    ? null
72    : f.compact === null
73      ? (f.percent ?? 0)
74      : Math.round((f.tokens / f.compact) * 100)
75
76export const contextLabel = (f: Fill) => {
77  const limit = f.compact ?? f.window
78  const percent = contextPercent(f)
79
80  return f.tokens === null
81    ? `ctx –/${short(limit)}`
82    : `ctx ${short(f.tokens)}/${short(limit)} (${percent}%)`
83}
84
85const refresh = async ($: EngineInterface) => {
86  const { context } = await $.session.usage({ breakdown: 'summary' })
87  await update($, fill, () => toFill(context))
88  const name = await $.session.model()
89  await update($, model, () => name)
90}
91
92export const gauges = (all: Limit[]) =>
93  Object.keys(LABELS).flatMap(kind => {
94    const limit = all.find(l => l.kind === kind)
95
96    return limit ? [{ ...limit, label: LABELS[kind] ?? kind }] : []
97  })
98
99export const register: Register = on => {
100  on('session.start', async ($, e, next) => {
101    const result = await next(e)
102    const { rateLimits } = await $.session.usage()
103    await update($, limits, () => rateLimits)
104    await refresh($)
105
106    return result
107  })
108
109  on('session.compact', async ($, e, next) => {
110    const result = await next(e)
111
112    if (!('skip' in result && result.skip) && e.trigger !== 'precompute' && !e.agentId) {
113      await update($, compactions, n => n + 1)
114    }
115
116    return result
117  })
118
119  on('session.measure', async ($, e, next) => {
120    await update($, limits, () => e.rateLimits)
121
122    if (e.changed.includes('context')) {
123      await refresh($)
124    }
125
126    return next(e)
127  })
128
129  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
130    const shown = gauges(await read($, limits))
131    const context = await read($, fill)
132    const name = await read($, model)
133    const compacted = await read($, compactions)
134
135    if (shown.length === 0 && context === null && name === null) {
136      return next(e)
137    }
138
139    const { Box, Text } = $.ui.resolve(e)
140    const now = await $.clock.now()
141    const isNarrow = (e.viewport?.columns ?? NARROW) < NARROW
142    const hasModes = e.props.modes.length > 0
143
144    return (
145      <Box flexDirection="row" flexShrink={0}>
146        {hasModes ? <Box marginRight={3}>{await next(e)}</Box> : ''}
147        {name === null ? '' : <Text>{modelLabel(name)}   </Text>}
148        {compacted === 0 ? '' : (
149          <Text color={compactColor(compacted)} dimColor={compacted <= 3}>
150            {COMPACT_ICON} {compacted}{'   '}
151          </Text>
152        )}
153        {shown.map((g, i) => {
154          const left = until(g.resetsAt, now)
155
156          return (
157            <Text key={g.kind}>
158              {i > 0 ? '   ' : ''}
159              <Text dimColor>{g.label} </Text>
160              {isNarrow ? '' : <Text color={colorFor(g.percentUsed)}>{bar(g.percentUsed)} </Text>}
161              <Text color={colorFor(g.percentUsed)}>{Math.round(g.percentUsed)}%</Text>
162              {left ? <Text dimColor> · {left}</Text> : ''}
163            </Text>
164          )
165        })}
166        {context === null ? '' : (
167          <Text>
168            {shown.length > 0 ? '   ' : ''}
169            {(contextPercent(context) ?? 0) < 50
170              ? <Text dimColor>{contextLabel(context)}</Text>
171              : <Text color={colorFor(contextPercent(context) ?? 0)}>{contextLabel(context)}</Text>}
172          </Text>
173        )}
174      </Box>
175    )
176  })
177}
178
types/index.d.ts 9 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Fill = { tokens: number | null; window: number; percent: number | null; compact: number | null }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-gauge': { limits: Limit[]; fill: Fill | null; model: string | null; compactions: number }
7  }
8}
9