SLOPSHOPPER

Gas Gauge

A fuel gauge above the prompt: how much of your Claude plan is left, and when it refills.

bandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gas-gauge
› 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 › /gas ⎿ gas-gauge: 5-hour E ███ ½ █░░ F 69% left @ Thu 9:53 AM ⛽ 5-hour E ███ ½ █░░ F 69% @ Thu 9:53 AM ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⛽ 5-hour E ███ ½ █░░ F 69% @ Thu 9:53 AM ⟨Claude Code's own drawing⟩
README

Gas Gauge

A fuel gauge above the prompt: how much of your Claude plan is left in each window, and when it refills.

⛽ 5-hour E ███ ½ █░░ F 71% @ 3:40 PM

Read it like a car: E on the left, F on the right, the ½ mark in the middle. Green above half a tank, yellow above a fifth, red below that.

Each gauge takes half the band. A toast fires once when a tank drops past 25% left, and again past 10%.

  • /gas prints every tank.
  • /gas hide moves the numbers to the status line. /gas show brings the band back.

The Tanks to show setting, in /config, picks which windows the gauge shows: both (the default), 5-hour alone, or week alone. A tank it leaves out is gone everywhere, from the band, /gas, the warnings and the status line.

The numbers are the ones Claude Code itself reads off each response, so the gauge fills after Claude has answered once. On an API key with no plan windows, it stays empty.

The band draws in the terminal and in the Code tab of the Claude desktop app. The phone app does not draw it yet.

What it runs and sends

Nothing goes anywhere. Here is each thing, plainly.

What it sends, and where.

  • Nothing. It reads your plan windows from Claude Code, with $.session.usage, and shows them on your screen.
  • It stores nothing on disk. The Tanks to show setting is kept by Claude Code.

Which programs it runs, and why.

  • None. It runs no programs.

What it puts in the prompts it submits.

  • It submits no prompts, and changes none of yours.

What each hook does.

  • session.start: adds the /gas command and reads the plan windows.
  • session.measure: takes the new windows, shows a toast when a tank drops past 25% or 10% left, and fills the status line while the band is hidden.
  • command.run for gas: prints every tank, or hides or shows the band.
  • ui.render for AbovePrompt: draws the gauge above the prompt.

Tested with Claude Code 2.1.288.

More mods

See every Rostech mod, live, at rostech.app.

Source 2 files
hooks/register.tsx 144 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { Tanks } from '../types'
5
6// The plan's windows, as the engine last measured them. Kept in $.state so a
7// reload does not empty the tank.
8const tanks = atom({ plugin: 'gas-gauge', key: 'tanks' } as const, [] as Tanks)
9const isHidden = atom({ plugin: 'gas-gauge', key: 'isHidden' } as const, false)
10
11// A warning fires once each time a tank drops past one of these, in percent left.
12const WARN_AT = [25, 10]
13const CELLS = 6
14
15const NAMES: Record<string, string> = { five_hour: '5-hour', seven_day: 'week', spend_limit: 'spend' }
16
17// The tanks setting, picked in /config: both windows, or one of them alone. A
18// tank it leaves out is gone everywhere: the band, /gas, the toasts and the
19// status line. Any other window the plan has, such as spend, always shows.
20const LEFT_OUT: Record<string, string> = { '5-hour': 'seven_day', week: 'five_hour' }
21
22function left(t: SessionRateLimit): number {
23  return Math.max(0, Math.round(100 - t.percentUsed))
24}
25
26function name(t: SessionRateLimit): string {
27  return NAMES[t.kind] ?? t.kind
28}
29
30function colour(pct: number): string {
31  return pct >= 50 ? 'green' : pct >= 20 ? 'yellow' : 'red'
32}
33
34// A car's fuel gauge, his shape: E .. ½ .. F. Half the cells sit each side of
35// the ½ mark, and the fill runs straight through it.
36function halves(pct: number): [string, string] {
37  const full = Math.round((pct / 100) * CELLS)
38  const cells = '█'.repeat(full) + '░'.repeat(CELLS - full)
39  return [cells.slice(0, CELLS / 2), cells.slice(CELLS / 2)]
40}
41
42function gauge(pct: number): string {
43  const [low, high] = halves(pct)
44  return `E ${low} ½ ${high} F`
45}
46
47// The refill, in the machine's own clock and words: a time today, a weekday
48// and time later in the week.
49function refill(t: SessionRateLimit): string {
50  if (!t.resetsAt) return ''
51  const at = new Date(t.resetsAt)
52  if (isNaN(at.getTime())) return ''
53  const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
54  const isToday = at.toDateString() === new Date().toDateString()
55  return isToday ? time : `${at.toLocaleDateString([], { weekday: 'short' })} ${time}`
56}
57
58function line(t: SessionRateLimit): string {
59  const r = refill(t)
60  return `${name(t)}  ${gauge(left(t))}  ${left(t)}% left${r ? ` @ ${r}` : ''}`
61}
62
63export const register: Register = (on, options) => {
64  const leftOut = LEFT_OUT[String(options.tanks ?? 'both').trim().toLowerCase()]
65  const shown = (all: readonly SessionRateLimit[]) => all.filter(t => t.kind !== leftOut)
66
67  on('session.start', async ($, e, next) => {
68    await $.command.register({ name: 'gas', description: 'Show how much of your plan is left. /gas hide or /gas show for the band.' })
69    const usage = await $.session.usage()
70    await update($, tanks, () => shown(usage.rateLimits))
71    return next(e)
72  })
73
74  // Pushed by the engine after each turn and whenever a window moves a point.
75  on('session.measure', async ($, e, next) => {
76    if (e.changed.includes('rateLimits')) {
77      const measured = shown(e.rateLimits)
78      const before = await read($, tanks)
79      for (const t of measured) {
80        const was = before.find(b => b.kind === t.kind)
81        if (!was) continue
82        for (const mark of WARN_AT) {
83          if (left(was) > mark && left(t) <= mark) {
84            $.ui.toast(`⛽ ${name(t)} tank is down to ${left(t)}%`)
85          }
86        }
87      }
88      await update($, tanks, () => measured)
89      const isBandHidden = await read($, isHidden)
90      $.ui.status(isBandHidden && measured.length ? '⛽ ' + measured.map(t => `${name(t)} ${left(t)}%`).join(' · ') : undefined)
91    }
92    return next(e)
93  })
94
95  on('command.run', { command: 'gas' }, async ($, e) => {
96    const arg = e.args.trim().toLowerCase()
97    if (arg === 'hide' || arg === 'show') {
98      await update($, isHidden, () => arg === 'hide')
99      return { text: arg === 'hide' ? 'Band hidden. The status line keeps the numbers.' : 'Band showing.' }
100    }
101    const now = await read($, tanks)
102    if (!now.length) return { text: 'No reading yet. The gauge fills after Claude answers once, on a Claude plan.' }
103    return { text: now.map(line).join('\n') }
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
108    const now = await read($, tanks)
109    if (!now.length) return next(e)
110
111    const { Box, Text } = $.ui.resolve(e)
112    // Half the band is ours; every other band above the prompt draws in the rest.
113    const half = Math.floor((e.props.bodyColumns ?? e.viewport?.columns ?? 80) / 2)
114    const below = await next(e)
115    return (
116      <Box flexDirection="row">
117        <Box width={half} flexDirection="column">
118        <Box flexDirection="row" flexWrap="wrap">
119          <Text>⛽ </Text>
120          {now.map(t => {
121            const pct = left(t)
122            const r = refill(t)
123            const [low, high] = halves(pct)
124            return (
125              <Box key={t.kind} flexDirection="row" marginRight={2}>
126                <Text>{name(t)}  </Text>
127                <Text bold>E </Text>
128                <Text color={colour(pct)}>{low}</Text>
129                <Text bold> ½ </Text>
130                <Text color={colour(pct)}>{high}</Text>
131                <Text bold> F</Text>
132                <Text color={colour(pct)} bold>  {pct}% </Text>
133                {r ? <Text dimColor>@ {r}</Text> : null}
134              </Box>
135            )
136          })}
137        </Box>
138        </Box>
139        {below}
140      </Box>
141    )
142  })
143}
144
types/index.d.ts 9 lines
1export type Tank = { kind: string; percentUsed: number; resetsAt?: string }
2export type Tanks = Tank[]
3
4declare module 'claude-code' {
5  interface PluginState {
6    'gas-gauge': { tanks: Tanks; isHidden: boolean }
7  }
8}
9