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

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.
Nothing goes anywhere. Here is each thing, plainly.
What it sends, and where.
$.session.usage, and shows them on your screen.Which programs it runs, and why.
What it puts in the prompts it submits.
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.
See every Rostech mod, live, at rostech.app.
hooks/register.tsx 144 lines1import { 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}
144types/index.d.ts 9 lines1export 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