An oil gauge above the prompt: this week's Fable use, beside Gas Gauge.

Gas Gauge's neighbour. A band above the prompt that shows this week's Fable use.
/oil prints the numbers./oil hide moves it to the status line. /oil show brings the band back.Everything stays on your machine: a count in the plugin's own store. Nothing is sent anywhere.
See every Rostech mod, live, at rostech.app.
hooks/register.tsx 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, TurnUsage } from 'claude-code'
3
4import type { Limit, Week } from '../types'
5
6// His idea, 3 Oct 2026: "Oil - shows your Fable use similar to gas gauge".
7//
8// The engine reports the plan's windows (5-hour, week) but not what each model
9// used. So Oil counts it: every model response, main thread and subagents alike,
10// carries the model that answered and its tokens. Fable's tokens over everyone's,
11// since the plan's week last refilled, is the oil level. If the plan ever reports
12// a window of Fable's own, Oil reads that instead, the way Gas Gauge does.
13
14const week = atom({ plugin: 'oil', key: 'week' } as const, { resetsAt: '', fable: 0, all: 0 } as Week)
15const limit = atom({ plugin: 'oil', key: 'limit' } as const, null as Limit | null)
16const isHidden = atom({ plugin: 'oil', key: 'isHidden' } as const, false)
17
18const STORE_KEY = 'week'
19const CELLS = 6
20
21export function isFable(model: string): boolean {
22 return model.toLowerCase().includes('fable')
23}
24
25// Cache reads are left out: they are the cheap re-read of a conversation, and on a
26// long session they would drown out the work itself.
27export function tokens(u: TurnUsage): number {
28 return u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
29}
30
31// The level, in percent: Fable's share of the week's work, or how much of
32// Fable's own window is left when the plan reports one.
33export function level(w: Week, l: Limit | null): number {
34 if (l) return Math.max(0, Math.round(100 - l.percentUsed))
35 return w.all > 0 ? Math.round((w.fable / w.all) * 100) : 0
36}
37
38export function size(n: number): string {
39 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
40 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
41 return String(n)
42}
43
44function halves(pct: number): [string, string] {
45 const full = Math.round((pct / 100) * CELLS)
46 const cells = '█'.repeat(full) + '░'.repeat(CELLS - full)
47 return [cells.slice(0, CELLS / 2), cells.slice(CELLS / 2)]
48}
49
50export function words(w: Week, l: Limit | null): string {
51 if (l) return `Fable ${level(w, l)}% left`
52 return `Fable ${size(w.fable)} tokens this week`
53}
54
55// A new week starts when the plan's week window moves to a later refill.
56export function rollover(w: Week, resetsAt: string | undefined): Week {
57 if (!resetsAt || resetsAt === w.resetsAt) return w
58 if (w.resetsAt && new Date(resetsAt) <= new Date(w.resetsAt)) return w
59 return { resetsAt, fable: w.resetsAt ? 0 : w.fable, all: w.resetsAt ? 0 : w.all }
60}
61
62function fableWindow(all: readonly SessionRateLimit[]): Limit | null {
63 return all.find(t => isFable(t.kind)) ?? null
64}
65
66// Every session on this machine adds to the same week, kept in the store.
67async function load($: EngineInterface): Promise<Week> {
68 const kept = (await $.store.get(STORE_KEY)) as Week | undefined
69 return kept && typeof kept.all === 'number' ? kept : { resetsAt: '', fable: 0, all: 0 }
70}
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 await $.command.register({ name: 'oil', description: "Show this week's Fable use. /oil hide or /oil show for the band." })
75 const usage = await $.session.usage()
76 await $.store.set('windows', usage.rateLimits)
77 const seven = usage.rateLimits.find(t => t.kind === 'seven_day')
78 const w = rollover(await load($), seven?.resetsAt)
79 await $.store.set(STORE_KEY, w)
80 await update($, week, () => w)
81 await update($, limit, () => fableWindow(usage.rateLimits))
82 return next(e)
83 })
84
85 on('session.measure', async ($, e, next) => {
86 // Every window the engine reports, kept as received, so a window by another name can be found.
87 await $.store.set('windows', e.rateLimits)
88 if (e.changed.includes('rateLimits')) {
89 const seven = e.rateLimits.find(t => t.kind === 'seven_day')
90 const w = rollover(await load($), seven?.resetsAt)
91 await $.store.set(STORE_KEY, w)
92 await update($, week, () => w)
93 await update($, limit, () => fableWindow(e.rateLimits))
94 }
95 return next(e)
96 })
97
98 // Every model response, main thread and subagents, says which model answered.
99 on('turn.step', async function* ($, e, next) {
100 const r = yield* next(e)
101 if (r.usage) {
102 const used = tokens(r.usage)
103 const kept = await load($)
104 const w: Week = { ...kept, all: kept.all + used, fable: kept.fable + (isFable(r.usage.model) ? used : 0) }
105 await $.store.set(STORE_KEY, w)
106 await update($, week, () => w)
107 if ((await read($, isHidden)) && w.fable > 0) $.ui.status(`🛢️ ${words(w, await read($, limit))}`)
108 }
109 return r
110 })
111
112 on('command.run', { command: 'oil' }, async ($, e) => {
113 const arg = e.args.trim().toLowerCase()
114 if (arg === 'hide' || arg === 'show') {
115 await update($, isHidden, () => arg === 'hide')
116 if (arg === 'show') $.ui.status(undefined)
117 return { text: arg === 'hide' ? 'Band hidden. The status line keeps the number.' : 'Band showing.' }
118 }
119 const w = await read($, week)
120 const l = await read($, limit)
121 if (w.all === 0 && !l) return { text: 'No reading yet. The gauge fills after Claude answers once.' }
122 return { text: `${words(w, l)}\nAll models this week: ${size(w.all)} tokens. Counted on this machine, cache reads left out.` }
123 })
124
125 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
126 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
127 const w = await read($, week)
128 const l = await read($, limit)
129 if (w.fable === 0 && !l) return next(e)
130
131 const { Box, Text } = $.ui.resolve(e)
132 const below = await next(e)
133 // His catch, 3 Oct: the share of the work read 82% while his Fable limit read 49%.
134 // The engine sends mods the 5-hour and week windows only, never Fable's, so
135 // without one Oil shows the count it can stand behind and no percent at all.
136 if (!l) {
137 return (
138 <Box flexDirection="column">
139 <Box flexDirection="row" flexWrap="wrap">
140 <Text>🛢️ Fable </Text>
141 <Text color="magenta" bold>{size(w.fable)} tokens</Text>
142 <Text dimColor> this week · your Fable limit is not shown to mods</Text>
143 </Box>
144 {below}
145 </Box>
146 )
147 }
148 const pct = level(w, l)
149 const [low, high] = halves(pct)
150 return (
151 <Box flexDirection="column">
152 <Box flexDirection="row" flexWrap="wrap">
153 <Text>🛢️ Fable </Text>
154 <Text bold>L </Text>
155 <Text color="magenta">{low}</Text>
156 <Text bold> ½ </Text>
157 <Text color="magenta">{high}</Text>
158 <Text bold> H</Text>
159 <Text color="magenta" bold> {pct}% </Text>
160 <Text dimColor>left</Text>
161 </Box>
162 {below}
163 </Box>
164 )
165 })
166}
167types/index.d.ts 11 lines1// This week's work, counted off every model response: all of it, and Fable's share.
2export type Week = { resetsAt: string; fable: number; all: number }
3// A plan window that names Fable, if the plan ever reports one.
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5
6declare module 'claude-code' {
7 interface PluginState {
8 oil: { week: Week; limit: Limit | null; isHidden: boolean }
9 }
10}
11