Glitch HUD: one band above the prompt with turn, speed, cost and budget, prompt-cache countdown with keep-warm, context and git.

Glitch is a kit of Claude Code mods, published from this folder as a marketplace named glitch. It puts one band above the prompt that shows the session at a glance, makes spending and prompt-cache expiry visible before they cost money, and themes everything from one place. A Glitch bug never breaks the session, and Glitch refuses nothing unless you ask it to: the one refusal is the opt-in subagent block at a budget cap.
| Plugin | What it does |
|---|---|
glitch-core | Shared themes and the /glitch command. |
glitch-hud | The band above the prompt: turn, speed, cost and budget, cache countdown with keep-warm, context, git. Depends on glitch-core. |
glitch-pets | A pixel pet at the end of the HUD row, with moods, short remarks and a daily streak. Depends on glitch-core and glitch-hud. |
One line above the prompt. In the terminal it is text with theme colors and icons:
| Part | Example | Source |
|---|---|---|
| turn | #12 | count of main-session turns |
| speed | ⚡ 84 tok/s | output tokens divided by streaming time of the last main response (ignores answers under 20 tokens or 0.5 s) |
| cost | $ 0.42 reply · 3.10 session · 21.40 today | the last reply, this session, and today across every session on this machine; pick them with costShows. Colored by budget level |
| cache | ⧗ 47:12 ↻ | countdown to prompt-cache expiry (cacheTtlMinutes, 60 by default); … while a turn runs |
| context | ▓▓▓▓░░ 63% | context window used; amber from 65%, red from 85% |
| git | main ±4 ↑1 | branch, changed files, ahead/behind; hidden outside a repo |
Desktop, VS Code and mobile. These apps draw the band with vector icons, a real context bar and worded values (Turn 12, $0.42 reply · $3.10 session · $21.40 today, 47:12 left, 63% context). The desktop already shows the branch above the band, so git is left out there unless you change Parts in the settings or run /glitch hud parts ….
The three costs. A reply is everything Claude does to answer one message: every model call, tool use and subagent until it stops. It is left out while it equals the session figure (the first reply). Session is the figure /cost shows. Today adds up every session that ran glitch-hud on this computer since local midnight, in any project; it is not your account's spend on other machines or on claude.ai. With a cap set, the figure shows it: $3.10 of $5.00 session.
Width. As the window narrows, parts drop in the order git, speed, turn; then the cost figures lose their words and the context bar becomes just the percent. The row never wraps.
Hiding. The × at the end of the band hides it for the session; /glitch hud toggles it again. The band is not drawn before the first turn or while a survey shows. Other mods' bands render beneath it.
Set a cap per session (budgetSession), per local day (budgetDaily), or both. 0 turns a cap off.
The 90% and 100% toasts appear in the session whatever notify says; notify only picks how the 100% notification (and the cache warnings) reach you, and off drops those.
Each level fires once per cap per session; raising a cap re-arms the higher levels. The daily ledger sums every session's spend for the local date: each session keeps its own entry per day under its session id, so sessions running side by side, a reload, a /clear or a resume never double count; entries older than 7 days are pruned. Caps changed in /config apply when the plugin reloads, except a session cap you already set with /glitch budget.
With budgetBlocksAgents on, new subagents are refused once either cap is reached. At the session cap the refusal asks Claude to have you raise it with /glitch budget +N; at the daily cap it asks you to run /glitch budget off for this session or raise budgetDaily in /config (+N moves the session cap only). The main conversation is never blocked, and if the check itself fails the call is allowed.
Costs are Claude Code's own estimate at API prices, not a bill; on a subscription they are not charged.
Claude Code's prompt cache lives for a fixed time after the last request: 1 hour on most plans, 5 minutes on others. Set cacheTtlMinutes to match (60 by default; set 5 if your account's cache lasts 5 minutes). The cache part counts down to that deadline from each answer and pauses (…) while a turn runs; a turn interrupted before any request leaves it as it was, and /clear drops it.
Left empty, cacheWarnMinutes fits the lifetime: marks at 10 and 5 minutes left for 1 hour, at 2 and 1 for 5 minutes. Marks you type at or above the lifetime can never fire: they are ignored and named once at session start. The countdown turns the warning color at a third of the lifetime left (20 minutes of an hour, 1:40 of 5 minutes) and the alert color at a sixth (10 minutes, 0:50).
<project> — Glitch: "Prompt cache expires in N min".notify = windows, a native Windows toast is tried first; if that fails (or the OS is not Windows) Claude Code's own notification channel is used, then an in-app toast. toast goes straight to the in-app toast; off turns off the cache warnings and the budget-cap notification (the budget toasts above still show).↻ on the cache part, or /glitch warm, sends a tool-less fork of the session's last request with a one-line question after it. The request is served from the prompt cache, which refreshes it, and nothing is added to the transcript. It is refused while a turn runs. The reply reports how many tokens were read from cache, and the countdown resets to the full lifetime (60:00, or 05:00).autoWarmMaxIdle minutes; never twice in a row without a prompt from you in between.Pick a pet and it sits at the end of the HUD row on the desktop app. While Claude works, your pet types on a little laptop; between turns it does its signature move. It reacts to the session through moods, says a short word now and then as a toast, and keeps a daily streak. It is not drawn in the terminal; there, remarks arrive as toasts.
| Pet | Kind | Temperament | Signature move (idle) |
|---|---|---|---|
| Screenhead | screen bot | Earnest, literal and a little robotic; reports facts, proud of tidy output. | Types; code scrolls on its screen face. |
| Byte blob | blob | Bubbly and easily startled; squeaks with joy, wobbles when worried. | Bounces: squash, stretch and a glitch slice. |
| Pixel | cat | Dry and unimpressed, secretly proud; says little, means it. | Swishes its tail and sheds pixels. |
| Dot | ladybug | Tidy, gentle and encouraging; notices the small things. | Flutters her wings on a daisy. |
| Fork | octopus | The organised multitasker; a born lead who loves a plan. | Taps four keys with four arms. |
| Bud | dog | Eager and loyal; cheers for everything and wants to go again. | Wags, pants and flops an ear. |
Moods: idle, working, happy, tired, sleepy, worried, oops, cheering, leading, reviewing, crying. It cries when the budget cap is reached, when a daily streak of two or more days is broken, and after three failures in a row.
/glitch-pets <name | off> switches to a pet and remembers it, or empties the slot; /glitch-pets alone lists the pets. /glitch pet [name | off] works the same way.
| Setting | Default | Meaning |
|---|---|---|
pet | bud | your pet: screenhead, blob, pixel, dot, fork, bud or off; /glitch pet <name> overrides it |
voice | true | one-line remarks as toasts |
voiceEveryMinutes | 3 | at most one remark per this many minutes |
tiredMinutes | 10 | a turn this long makes the pet tired |
sleepyMinutes | 10 | this long without a prompt makes the pet sleepy |
/plugin install glitch-pets@glitch
Six built-in themes: glitch (default, electric cyan and magenta), neon, mono, sunset, matrix, paper. /glitch theme lists them, /glitch theme neon switches and remembers the choice across sessions, /glitch theme default clears it.
Resolution order: the last /glitch theme <name>, then the theme setting, then glitch. The custom setting, a JSON object of tokens, is merged over the result:
{ "accent": "#ff6ad5", "bar": "█░" }
Color tokens are accent, good, warn, alert, muted, info (as #rrggbb); bar is the full and empty characters of the context bar; icons is emoji, symbols or ascii. An unknown theme name falls back to glitch; malformed custom JSON is ignored and the chosen theme is kept. Either problem toasts at session start.
/glitch is owned by glitch-core. The HUD subcommands are answered by glitch-hud directly, the pet subcommand by glitch-pets, and the same subcommands also work as /glitch-hud ….
| Command | Owner | Does | |||
|---|---|---|---|---|---|
/glitch | core | help card: theme and the subcommands each plugin answers | |||
| `/glitch theme [name\ | default]` | core | list themes, or switch and persist | ||
| `/glitch-pets [name\ | off]` | pets | list pets, switch and persist, or turn the pet off | ||
| `/glitch pet [name\ | off]` | pets | the same, through the core command | ||
/glitch hud | hud | show or hide the band | |||
/glitch hud parts <list> | hud | parts and order for this session, as a comma list, e.g. cost,cache,context or cost, cache | |||
/glitch hud cost <list> | hud | cost figures and order for this session, e.g. session,today | |||
| `/glitch budget [+N \ | N \ | off \ | on]` | hud | show, raise, set, disable or re-enable this session's caps |
/glitch warm | hud | keep-warm ping now | |||
/glitch demo cache <min> | hud | preview the cache part and warnings at N minutes left; demo warnings say "(demo)", never auto-warm, and the real countdown comes back when the demo runs out or at the next answer | |||
/glitch demo budget <pct> | hud | preview the budget levels at N percent |
An unknown subcommand shows the help card. /glitch budget +N and N change this session's cap only; off disables both caps for this session.
Set them in /config, or in ~/.claude/settings.json under pluginConfigs. Invalid values fall back to the default and toast once.
glitch-core
| Setting | Default | Meaning |
|---|---|---|
theme | glitch | default theme: glitch, neon, mono, sunset, matrix, paper |
custom | empty | JSON object of theme tokens laid over the theme |
glitch-hud
| Setting | Default | Meaning |
|---|---|---|
parts | turn,speed,cost,cache,context,git | which parts show, in order |
costShows | reply,session,today | which cost figures show, in order |
budgetSession | 0 | USD cap per session; 0 is off |
budgetDaily | 0 | USD cap per local day, across sessions; 0 is off |
budgetBlocksAgents | false | refuse new subagents at a cap |
cacheTtlMinutes | 60 | prompt cache lifetime: 60 (most plans) or 5 |
cacheWarnMinutes | empty | minutes-left marks that notify; empty fits the lifetime (10,5 for 1 hour, 2,1 for 5 minutes); marks at or above the lifetime are ignored |
notify | windows | windows (native toast), toast (inside Claude Code) or off (no cache or cap notifications; budget toasts still show) |
autoWarm | false | keep the cache warm by itself at the last warning mark |
autoWarmMaxIdle | 120 | minutes without a prompt from you after which autoWarm stops |
/plugin install glitch-hud --marketplace <owner>/<repo>
Answer y to add the marketplace, then pick a scope. This also installs glitch-core.
From a local folder:
claude plugin marketplace add C:\path\to\this\folder
/plugin install glitch-hud@glitch
To add the pets: /plugin install glitch-pets@glitch.
Or load the plugin folders directly with CLAUDE_CODE_PLUGIN_DIRS, separated by ; on Windows. In cmd:
set CLAUDE_CODE_PLUGIN_DIRS=C:\path\to\this\folder\plugins\glitch-core;C:\path\to\this\folder\plugins\glitch-hud
In PowerShell:
$env:CLAUDE_CODE_PLUGIN_DIRS = 'C:\path\to\this\folder\plugins\glitch-core;C:\path\to\this\folder\plugins\glitch-hud'
Both last for that terminal only; to keep it, put CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json.
Edits reach a running session with /reload-plugins.
Mods use Claude Code's early-access function-hooks API; tested on Claude Code 2.1.296.
MIT, see LICENSE.
hooks/register.tsx 542 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, Timer } from 'claude-code'
3
4import { INITIAL_BUDGET, INITIAL_CACHE, INITIAL_GIT, INITIAL_STATS, INITIAL_VIEW, initialBudget } from './atoms'
5import { blockReason, dayKey, isExpired, ledgerKey, levelOf, parseLedgerKey, rebase, spendToday, withConfigCaps } from './budget'
6import { dueMark, endDemo, isDemoOver, shouldAutoWarm, startDemo, warnMarks } from './cache'
7import { BUDGET_USAGE, DEMO_USAGE, ROUTED, USAGE, WARM_PROMPT, budgetAlerts, budgetSummary, parseBudgetArg, splitArgs, warmMessage } from './commands'
8import { ALL_COST, ALL_PARTS, parseConfig, parseCostShows, parseParts } from './config'
9import type { HudConfig } from './config'
10import { readGit } from './git'
11import type { RunFn } from './git'
12import { notify, projectName } from './notify'
13import type { NotifyIO } from './notify'
14import { richPart, richWidth } from './desktop'
15import { PET_CELLS, isSprite } from './pet'
16import type { PetSprite } from './pet'
17import { cellWidth, fitParts, segments } from './parts'
18import type { PartData } from './parts'
19import { toTheme } from './theme'
20
21export const stats = atom({ plugin: 'glitch-hud', key: 'stats' } as const, INITIAL_STATS)
22export const cache = atom({ plugin: 'glitch-hud', key: 'cache' } as const, INITIAL_CACHE)
23export const budget = atom({ plugin: 'glitch-hud', key: 'budget' } as const, INITIAL_BUDGET)
24export const git = atom({ plugin: 'glitch-hud', key: 'git' } as const, INITIAL_GIT)
25export const view = atom({ plugin: 'glitch-hud', key: 'view' } as const, INITIAL_VIEW)
26
27// Responses shorter than this are mostly tool-call stubs; their timing is noise.
28const MIN_TOKENS = 20
29const MIN_SECONDS = 0.5
30// Git and today's ledger are read again on this timer while idle.
31const GIT_EVERY_MS = 30_000
32const ROW_GAP = 2
33// Cells on each side of the desktop's keep-warm glyph.
34const WARM_PAD = 1
35
36const COST_UNREADABLE = "Glitch: can't read the session cost right now; budget caps can't be checked until it's back."
37const LEDGER_FAILED = "Glitch: can't read or save the daily spend ledger right now; today's total may be incomplete."
38
39// A failed read marks cost and context stale (the band shows —, not a frozen
40// number) and says so once per conversation (session id); the next good read clears it.
41export async function refreshUsage($: EngineInterface): Promise<void> {
42 const usage = await $.session.usage().catch(() => null)
43 if (!usage) {
44 const sessionId = await $.session.id().catch(() => 'session')
45 const warned = (await read($, stats)).staleWarnedFor === sessionId
46 await update($, stats, s => ({ ...s, isStale: true, staleWarnedFor: sessionId }))
47 if (!warned) {
48 try {
49 $.ui.toast(COST_UNREADABLE, { timeoutMs: 10_000 })
50 } catch {
51 // Nothing else to tell.
52 }
53 }
54 return
55 }
56 const total = usage.cost?.usd ?? 0
57 await update($, stats, s => ({ ...s, isStale: false, total, turnCost: s.turn === 0 ? null : total - s.costAtStart, context: usage.context.percent ?? s.context }))
58}
59
60// Engine calls handed to the pure helpers: `$` stays in this file.
61const gitRun = ($: EngineInterface): RunFn => (argv, init) => $.process.run(argv, init)
62
63const engineIO = ($: EngineInterface): NotifyIO => ({
64 os: () => $.env.get('OS'),
65 run: (argv, init) => $.process.run(argv, init),
66 notifyClaude: (text, options) => $.ui.notify(text, options),
67 toast: (text, options) => $.ui.toast(text, options),
68})
69
70// The pet glitch-pets publishes, if it is installed and on; anything malformed is no pet.
71const loadPet = async ($: EngineInterface): Promise<PetSprite | null> => {
72 try {
73 const v = (await $.state.get({ plugin: 'glitch-pets', key: 'sprite' } as never)) as { value?: unknown }
74 return isSprite(v?.value) ? v.value : null
75 } catch {
76 return null
77 }
78}
79
80const loadTheme = async ($: EngineInterface) => {
81 try {
82 return toTheme((await $.state.get({ plugin: 'glitch-core', key: 'theme' } as const)).value)
83 } catch {
84 return toTheme(null)
85 }
86}
87
88async function refreshGit($: EngineInterface): Promise<void> {
89 const info = await readGit(gitRun($)).catch(() => null)
90 await update($, git, () => info)
91}
92
93export async function warnCache($: EngineInterface, config: HudConfig, minutes: number, isDemo: boolean): Promise<void> {
94 const title = `${projectName(await $.session.cwd().catch(() => ''))} — Glitch`
95 await notify(engineIO($), config.notify, title, `Prompt cache expires in ${minutes} min${isDemo ? ' (demo)' : ''}`)
96}
97
98export async function keepWarm($: EngineInterface, ttlMs: number): Promise<string> {
99 const c = await read($, cache)
100 if (c.isBusy) return 'Claude is working, so the cache is warm already.'
101 if (c.isWarming) return 'A keep-warm request is already running.'
102 await update($, cache, x => ({ ...x, isWarming: true }))
103 try {
104 const reply = await $.model.fork({ prompt: WARM_PROMPT }).catch(() => ({ isAnswered: false as const, reason: 'request refused' }))
105 if (reply.isAnswered) {
106 const now = await $.clock.now()
107 await update($, cache, x => ({ ...x, expiresAt: now + ttlMs, secondsLeft: ttlMs / 1000, warnedMark: 0, warmedSincePrompt: true, real: null }))
108 }
109 return warmMessage(reply, ttlMs / 1000)
110 } finally {
111 // The control goes back to ↻ however the request ended.
112 await update($, cache, x => ({ ...x, isWarming: false })).catch(() => undefined)
113 }
114}
115
116const storedNumber = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
117
118// Records this session's spend today under its own ledger key, prunes old days,
119// sums today over every session's key, and fires the budget warnings.
120export async function recordSpend($: EngineInterface, config: HudConfig): Promise<void> {
121 const total = (await read($, stats)).total
122 const now = await $.clock.now()
123 const sessionId = await $.session.id().catch(() => 'session')
124 const based = rebase(await read($, budget), sessionId, total)
125 const today = spendToday(based, total, now)
126 const key = ledgerKey(today.day, sessionId)
127 // Any ledger call that fails leaves today's total short: said once per conversation
128 // (session id), and only when a daily cap is in force.
129 let isShort = false
130 const missed = () => {
131 isShort = true
132 return undefined
133 }
134 // A key first written by this run may hold an earlier run's spend (a resume): add to it.
135 const prior = key === based.ledgerKey ? based.priorForKey : storedNumber(await $.store.get(key).catch(missed))
136 const mine = prior + today.spent
137 await $.store.set(key, mine).catch(missed)
138
139 let daily = mine
140 for (const k of await $.store.keys().catch(() => (missed(), [] as string[]))) {
141 const entry = parseLedgerKey(k)
142 if (!entry || k === key) continue
143 if (isExpired(entry.day, now)) await $.store.delete(k).catch(missed)
144 else if (entry.day === today.day) daily += storedNumber(await $.store.get(k).catch(missed))
145 }
146 const was = await read($, budget)
147 const tells = isShort && was.dailyCap > 0 && !was.isOff && was.ledgerWarnedFor !== sessionId
148 await update($, budget, x => ({
149 ...x, sessionId, day: today.day, dayBase: today.dayBase, lastTotal: total, ledgerKey: key, priorForKey: prior, dailySpent: daily,
150 ...(tells ? { ledgerWarnedFor: sessionId } : {}),
151 }))
152 if (tells) {
153 try {
154 $.ui.toast(LEDGER_FAILED, { timeoutMs: 10_000 })
155 } catch {
156 // The caps below are still checked.
157 }
158 }
159
160 const b = await read($, budget)
161 if (b.isOff) return
162 const alerts = budgetAlerts(b.notified, { session: total, daily, sessionCap: b.sessionCap, dailyCap: b.dailyCap })
163 await update($, budget, x => ({ ...x, notified: alerts.notified }))
164 for (const t of alerts.toasts) $.ui.toast(t.text, { timeoutMs: t.timeoutMs })
165 if (alerts.notice) {
166 const title = `${projectName(await $.session.cwd().catch(() => ''))} — Glitch`
167 await notify(engineIO($), config.notify, title, alerts.notice)
168 }
169}
170
171// Sums today's ledger again without writing it, so a session that sits idle
172// still sees what the others spent since its own last answer. Its own entry
173// counts as stored: `todaySpent` adds what it spent since.
174async function sumToday($: EngineInterface): Promise<void> {
175 const b = await read($, budget)
176 if (b.day !== dayKey(await $.clock.now())) return
177 let daily = 0
178 for (const k of await $.store.keys()) {
179 if (parseLedgerKey(k)?.day === b.day) daily += storedNumber(await $.store.get(k))
180 }
181 await update($, budget, x => ({ ...x, dailySpent: daily }))
182}
183
184async function budgetCommand($: EngineInterface, arg: string | undefined): Promise<string> {
185 const b = await read($, budget)
186 const total = (await read($, stats)).total
187 if (arg === undefined) return budgetSummary(b, total)
188 const parsed = parseBudgetArg(arg)
189 if (!parsed) return BUDGET_USAGE
190 if (parsed.kind === 'off' || parsed.kind === 'on') {
191 await update($, budget, x => ({ ...x, isOff: parsed.kind === 'off' }))
192 return parsed.kind === 'off' ? 'Budget caps off for this session.' : 'Budget caps on.'
193 }
194 const cap = parsed.kind === 'add' ? b.sessionCap + parsed.n : parsed.n
195 await update($, budget, x => ({ ...x, sessionCap: cap, sessionCapOverridden: true, notified: { ...x.notified, session: levelOf(total, cap) } }))
196 return `Session cap: $${cap.toFixed(2)}.`
197}
198
199async function demoCommand($: EngineInterface, what: string | undefined, value: string | undefined): Promise<string> {
200 const n = Number(value)
201 if (what === 'cache' && Number.isFinite(n) && n > 0) {
202 const seconds = Math.round(n * 60)
203 const now = await $.clock.now()
204 await update($, cache, c => startDemo(c, now, seconds))
205 await update($, view, v => ({ ...v, isHidden: false }))
206 return `Cache demo: ${value} min left; the real countdown comes back when it ends or at the next answer.`
207 }
208 if (what === 'budget' && Number.isFinite(n) && n >= 0) {
209 await update($, budget, b => ({ ...b, demoPercent: n }))
210 return `Budget demo: ${value}% until the next answer.`
211 }
212 return DEMO_USAGE
213}
214
215// One answer for `/glitch-hud <args>` and the routed `/glitch <args>`.
216async function answerHud($: EngineInterface, config: HudConfig, args: string): Promise<string> {
217 const [sub = '', a, b, ...more] = splitArgs(args)
218 if (sub === 'hud') {
219 if (a === 'parts') {
220 // `cost, cache` arrives split at the space: the list is the rest of the line.
221 const parts = b ? parseParts([b, ...more].join(' ')) : null
222 if (!parts) return `Unknown part. Parts: ${ALL_PARTS.join(', ')}.`
223 await update($, view, v => ({ ...v, parts, isHidden: false }))
224 return `HUD parts: ${parts.join(', ')}.`
225 }
226 if (a === 'cost') {
227 const cost = b ? parseCostShows(b) : null
228 if (!cost) return `Unknown cost figure. Figures: ${ALL_COST.join(', ')}.`
229 await update($, view, v => ({ ...v, cost, isHidden: false }))
230 return `HUD cost: ${cost.join(', ')}.`
231 }
232 const hidden = !(await read($, view)).isHidden
233 await update($, view, v => ({ ...v, isHidden: hidden }))
234 return hidden ? 'Glitch HUD hidden.' : 'Glitch HUD shown.'
235 }
236 if (sub === 'budget') return budgetCommand($, a)
237 if (sub === 'warm') return keepWarm($, config.cacheTtlMs)
238 if (sub === 'demo') return demoCommand($, a, b)
239 return USAGE
240}
241
242// The last warning mark may keep the cache warm by itself.
243async function autoWarm($: EngineInterface, config: HudConfig, mark: number, marks: number[]): Promise<void> {
244 if (!config.autoWarm) return
245 const c = await read($, cache)
246 const ok = shouldAutoWarm({ mark, marks, now: await $.clock.now(), lastPromptAt: c.lastPromptAt, maxIdleMin: config.autoWarmMaxIdle, warmedSincePrompt: c.warmedSincePrompt })
247 if (ok) $.ui.toast(`Glitch: ${await keepWarm($, config.cacheTtlMs)}`, { timeoutMs: 8000 })
248}
249
250export const register: Register = (on, options) => {
251 const { config, problems } = parseConfig(options as Record<string, unknown>)
252 // session.start fires again in the same load (compact, resume, fork): the
253 // timers of the earlier start are stopped before new ones begin.
254 let timers: Timer[] = []
255
256 on('session.start', async ($, e, next) => {
257 const started = await next(e)
258 try {
259 await $.command.register({ name: 'glitch-hud', description: 'Glitch HUD: hud, budget, warm, demo (also as /glitch <sub>)' })
260 } catch {
261 $.ui.toast('Glitch HUD: could not register /glitch-hud', { timeoutMs: 8000 })
262 }
263 if (problems.length) $.ui.toast(`Glitch HUD: ${problems.join('; ')}`, { timeoutMs: 8000 })
264
265 await refreshUsage($)
266 await refreshGit($)
267 // Today's spend counts from this session's total now: a resumed session's
268 // earlier cost belongs to the days it was spent on.
269 const total = (await read($, stats)).total
270 if ((await read($, budget)).day === '') {
271 const now = await $.clock.now()
272 const sessionId = await $.session.id().catch(() => 'session')
273 await update($, budget, () => ({ ...initialBudget(config), day: dayKey(now), dayBase: total, lastTotal: total, sessionId }))
274 } else {
275 // A reload (new /config options): the state lived on, the caps follow the settings.
276 await update($, budget, b => withConfigCaps(b, config, total))
277 }
278 // Sum the ledger now, so today counts the other sessions from the first draw.
279 await recordSpend($, config).catch(() => undefined)
280
281 // The colors and the fitted marks read the lifetime from the state.
282 await update($, cache, c => ({ ...c, ttlMs: config.cacheTtlMs }))
283
284 for (const t of timers) t.cancel()
285 // Each tick catches its own failure: one rejection must never end the interval.
286 const countdown = $.clock.every(1000, async () => {
287 try {
288 const c = await read($, cache)
289 if (c.expiresAt === null) return
290 const now = await $.clock.now()
291 // A demo that has run out (and shown "expired" a moment) gives the real deadline back.
292 if (isDemoOver(c, now)) {
293 await update($, cache, x => endDemo(x, now))
294 return
295 }
296 const left = Math.max(0, Math.floor((c.expiresAt - now) / 1000))
297 if (left !== c.secondsLeft) await update($, cache, x => ({ ...x, secondsLeft: left }))
298 if (c.isBusy) return
299 const marks = warnMarks(config.cacheWarnMinutes, c.ttlMs ?? config.cacheTtlMs)
300 const mark = dueMark(left, marks, c.warnedMark)
301 if (mark === null) return
302 await update($, cache, x => ({ ...x, warnedMark: mark }))
303 const isDemo = !!c.real
304 await warnCache($, config, Math.ceil(left / 60), isDemo)
305 // A demo's warning is only a preview: it never sends a real (paid) ping.
306 if (!isDemo) await autoWarm($, config, mark, marks)
307 } catch {
308 // The next tick tries again.
309 }
310 })
311
312 const gitTick = $.clock.every(GIT_EVERY_MS, async () => {
313 try {
314 if ((await read($, cache)).isBusy) return
315 await refreshGit($)
316 await sumToday($)
317 } catch {
318 // The next refresh tries again.
319 }
320 })
321 timers = [countdown, gitTick]
322 return started
323 })
324
325 on('turn.start', async ($, e, next) => {
326 const usage = await $.session.usage().catch(() => null)
327 const total = usage?.cost?.usd ?? (await read($, stats)).total
328 await update($, stats, s => ({ ...s, turn: s.turn + 1, costAtStart: total, total, turnCost: 0, isStale: usage ? false : s.isStale }))
329 // A /clear or a /resume since the last turn: re-base before this turn spends.
330 const sessionId = await $.session.id().catch(() => 'session')
331 await update($, budget, b => rebase(b, sessionId, total))
332 await update($, cache, c => ({ ...c, isBusy: true }))
333 return next(e)
334 })
335
336 // Output speed of each main-thread response, timed from its first chunk.
337 on('turn.step', async function* ($, e, next) {
338 if (e.agentId !== undefined) return yield* next(e)
339 let startedAt: number | null = null
340 const stream = next(e)
341 for await (const chunk of stream) {
342 // The speed is a nicety: a failure to time or store it must never break the stream.
343 try {
344 if (startedAt === null) startedAt = await $.clock.now()
345 if (chunk.kind === 'stop' && chunk.usage) {
346 const seconds = ((await $.clock.now()) - startedAt) / 1000
347 const tokens = chunk.usage.output_tokens
348 if (tokens >= MIN_TOKENS && seconds >= MIN_SECONDS) await update($, stats, s => ({ ...s, speed: Math.round(tokens / seconds) }))
349 }
350 } catch {
351 // Leave the last speed shown.
352 }
353 yield chunk
354 }
355 await refreshUsage($).catch(() => undefined)
356 return stream.result
357 })
358
359 on('turn.complete', async ($, e, next) => {
360 try {
361 const result = await next(e)
362 if (e.agentId !== undefined) return result
363 await refreshUsage($)
364 await recordSpend($, config).catch(() => undefined)
365 // Only a turn that reached the API restarts the cache's lifetime; one aborted
366 // or failed before any request leaves the countdown as it was.
367 if (e.reason === 'answer' || e.usage) {
368 const now = await $.clock.now()
369 await update($, cache, c => ({ ...c, expiresAt: now + config.cacheTtlMs, secondsLeft: config.cacheTtlMs / 1000, warnedMark: 0, real: null }))
370 }
371 await update($, budget, b => ({ ...b, demoPercent: null }))
372 void refreshGit($)
373 return result
374 } finally {
375 // A turn that threw must not leave the band busy; a failure here must not
376 // hide the turn's own error.
377 if (e.agentId === undefined) await update($, cache, c => ({ ...c, isBusy: false })).catch(() => undefined)
378 }
379 })
380
381 // A /clear ends this conversation and the process goes on under a new id: record
382 // its spend once more, and drop the countdown (its cache is no use to the next one).
383 on('session.end', async ($, e, next) => {
384 if (e.reason === 'clear') {
385 try {
386 await refreshUsage($)
387 await recordSpend($, config)
388 } catch {
389 // The next record re-bases under the new id either way.
390 }
391 await update($, cache, c => ({ ...c, expiresAt: null, secondsLeft: null, warnedMark: 0, real: null })).catch(() => undefined)
392 }
393 return next(e)
394 })
395
396 on('prompt.submit', async ($, e, next) => {
397 if (e.origin.kind !== 'plugin') {
398 const now = await $.clock.now()
399 await update($, cache, c => ({ ...c, lastPromptAt: now, warmedSincePrompt: false }))
400 }
401 return next(e)
402 })
403
404 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
405 // Kept outside the try so a drawing error never runs the chain beneath twice.
406 let drawn: unknown = null
407 let isDrawn = false
408 try {
409 const s = await read($, stats)
410 const v = await read($, view)
411 if (e.props.hasSurvey || s.turn === 0 || v.isHidden) return next(e)
412
413 const data: PartData = {
414 stats: s,
415 cache: await read($, cache),
416 budget: await read($, budget),
417 git: await read($, git),
418 theme: await loadTheme($),
419 isWorking: e.props.isWorking || (await read($, cache)).isBusy,
420 costShows: v.cost ?? config.costShows,
421 }
422 // Surfaces with Svg (desktop, VS Code, mobile) get icons and a real bar.
423 // The desktop shows the branch in its own row and the conversation above, so
424 // git and the turn count are left out there unless the person chose the parts.
425 const rich = e.surface !== 'terminal'
426 // The pet sits at the row's end on surfaces that draw images.
427 const pet = rich ? await loadPet($) : null
428 const chosen = v.parts !== null || config.partsExplicit
429 const parts = (v.parts ?? config.parts)
430 .filter(id => id !== 'git' || (data.git !== null && (!rich || chosen)))
431 .filter(id => id !== 'turn' || !rich || chosen)
432 // The cache part is never dropped, so ↻ shows exactly when it is asked for here.
433 const showsWarm = parts.includes('cache') && data.cache.secondsLeft !== null && !e.props.isWorking
434 // ↻ is part of the cache part: one cell of gap, then the glyph (padded on desktop).
435 const warmCells = 1 + (rich ? 1 + 2 * WARM_PAD : 1)
436 const width = (id: (typeof parts)[number], compact: boolean) =>
437 (rich
438 ? richWidth(richPart(id, data, compact))
439 : cellWidth(segments(id, data, compact).map(x => x.text).join(''))) + (id === 'cache' && showsWarm ? warmCells : 0)
440 // × takes one cell and a gap, the spacer a gap, the pet its cells and a gap.
441 const reserve = ROW_GAP + (1 + ROW_GAP) + (pet ? PET_CELLS + ROW_GAP : 0)
442 const { shown, compact } = fitParts(parts, width, e.props.bodyColumns, ROW_GAP, reserve)
443
444 // Other mods' bands draw beneath this row; the engine's own placeholder is never nested.
445 drawn = await Promise.resolve(next(e)).catch(() => null)
446 isDrawn = true
447 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? (drawn as RenderNode) : null
448
449 const ui = $.ui.resolve(e)
450 const { Box, Button, Text } = ui
451 const texts = (id: string, segs: { text: string; color?: string; bold?: boolean; dim?: boolean }[]) =>
452 segs.map((seg, n) => (
453 <Text key={`${id}-${n}`} color={seg.color} bold={seg.bold} dimColor={seg.dim}>
454 {seg.text}
455 </Text>
456 ))
457 const Svg = 'Svg' in ui ? ui.Svg : null
458 // A failed keep-warm only loses its toast.
459 const warm = async () => {
460 try {
461 $.ui.toast(`Glitch: ${await keepWarm($, config.cacheTtlMs)}`, { timeoutMs: 8000 })
462 } catch {
463 // Nothing to show.
464 }
465 }
466 // ↻ rides with the cache part, right after its text. One glyph: the desktop
467 // draws a Button as a native button with its whole label, so a long label over
468 // an icon showed as cut-off text.
469 const warmControl = (id: string) =>
470 id === 'cache' && showsWarm
471 ? rich
472 ? (
473 // The desktop draws the glyph flush against its neighbours: a cell each side.
474 <Box key="glitch-warm-box" paddingX={WARM_PAD} flexShrink={0}>
475 <Button key="glitch-warm" label={data.cache.isWarming ? '…' : '↻'} plain dimColor onPress={warm} />
476 </Box>
477 )
478 : (
479 <Box key="glitch-warm-box" marginLeft={1} flexShrink={0}>
480 <Button key="glitch-warm" label={data.cache.isWarming ? '…' : '↻'} plain onPress={warm} />
481 </Box>
482 )
483 : null
484 const items = rich && Svg
485 ? [
486 <Box key="glitch-parts" flexDirection="row" flexGrow={1} justifyContent="space-between" columnGap={ROW_GAP} overflow="hidden">
487 {shown.map(id => {
488 const p = richPart(id, data, compact)
489 return (
490 <Box key={id} flexDirection="row" flexShrink={0} alignItems="center" columnGap={1}>
491 {p.graphic ? <Svg source={p.graphic.source} alt={p.graphic.alt} width={p.graphic.width} height={p.graphic.height} /> : null}
492 <Box flexDirection="row">{texts(id, p.segments)}</Box>
493 {warmControl(id)}
494 </Box>
495 )
496 })}
497 </Box>,
498 ]
499 : shown.map(id => (
500 <Box key={id} flexDirection="row" flexShrink={0}>
501 {texts(id, segments(id, data, compact))}
502 {warmControl(id)}
503 </Box>
504 ))
505 return (
506 <Box flexDirection="column">
507 <Box flexDirection="row" columnGap={ROW_GAP} flexWrap="nowrap" overflow="hidden" alignItems="center">
508 {items}
509 {/* Pet and ✕ are siblings under the row's one columnGap: the same space on
510 both sides of the pet, and none left behind without one. */}
511 {pet && Svg ? (
512 <Box key="glitch-pet" flexShrink={0} alignItems="flex-end">
513 <Svg source={pet.source} alt={pet.alt} width={pet.width} height={pet.height} />
514 </Box>
515 ) : null}
516 {rich && Svg ? null : <Box flexGrow={1} />}
517 <Button key="glitch-hide" label={rich && Svg ? '✕' : '×'} plain role="dismiss" dimColor onPress={() => update($, view, x => ({ ...x, isHidden: true }))} />
518 </Box>
519 {below}
520 </Box>
521 )
522 } catch {
523 return isDrawn && drawn ? (drawn as never) : next(e)
524 }
525 })
526
527 // `/glitch hud|budget|warm|demo` reaches this plugin through glitch-core; the host
528 // refuses a nested command run, so the answer is given here. Anything else goes on.
529 on('command.run', { command: 'glitch-hud' }, async ($, e) => ({ text: await answerHud($, config, e.args) }))
530 // A failed answer falls through to the hooks beneath, as an unknown subcommand does.
531 on('command.run', { command: 'glitch' }, async ($, e, next) =>
532 ROUTED.includes(splitArgs(e.args)[0] ?? '') ? { text: await answerHud($, config, e.args) } : next(e)).catch(($, e, next) => next(e))
533
534 // The only refusing hook: at a cap, new subagents are refused. A crash in the
535 // check lets the call through.
536 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
537 if (!config.budgetBlocksAgents) return next(e)
538 const reason = blockReason(await read($, budget), (await read($, stats)).total)
539 return reason ? { deny: reason } : next(e)
540 }).catch(($, e, next) => next(e))
541}
542hooks/atoms.ts 37 lines1// Initial values of the plugin's state atoms. The atoms themselves are declared in
2// register.tsx: the load-time scan only follows atoms declared in the file that
3// reads and writes them.
4import type { Budget, CacheState, GitInfo, Stats, View } from '../types'
5import { DEFAULT_CONFIG } from './config'
6import type { HudConfig } from './config'
7
8export const INITIAL_STATS: Stats = {
9 turn: 0, speed: null, turnCost: null, total: 0, context: 0, costAtStart: 0, isStale: false,
10}
11
12export const INITIAL_CACHE: CacheState = {
13 expiresAt: null, secondsLeft: null, warnedMark: 0, isBusy: false, lastPromptAt: 0, warmedSincePrompt: false, isWarming: false,
14}
15
16export const initialBudget = (config: HudConfig): Budget => ({
17 sessionCap: config.budgetSession,
18 dailyCap: config.budgetDaily,
19 isOff: false,
20 day: '',
21 dayBase: 0,
22 dailySpent: 0,
23 notified: { session: 'ok', daily: 'ok' },
24 demoPercent: null,
25 sessionId: '',
26 lastTotal: 0,
27 ledgerKey: '',
28 priorForKey: 0,
29 sessionCapOverridden: false,
30})
31
32export const INITIAL_BUDGET: Budget = initialBudget(DEFAULT_CONFIG)
33
34export const INITIAL_GIT: GitInfo | null = null
35
36export const INITIAL_VIEW: View = { isHidden: false, parts: null, cost: null }
37hooks/budget.ts 96 lines1import type { Budget, Level } from '../types'
2
3const RANK: Record<Level, number> = { ok: 0, warn: 1, alert: 2, over: 3 }
4
5export const rank = (l: Level): number => RANK[l]
6export const worse = (a: Level, b: Level): Level => (RANK[a] >= RANK[b] ? a : b)
7
8export const levelOf = (spent: number, cap: number): Level =>
9 cap <= 0 ? 'ok' : spent >= cap ? 'over' : spent >= cap * 0.9 ? 'alert' : spent >= cap * 0.75 ? 'warn' : 'ok'
10
11export function costLevel(b: Budget, sessionSpent: number): Level {
12 if (b.demoPercent !== null) return levelOf(b.demoPercent, 100)
13 if (b.isOff) return 'ok'
14 return worse(levelOf(sessionSpent, b.sessionCap), levelOf(b.dailySpent, b.dailyCap))
15}
16
17export const crossed = (prev: Level, now: Level): Level | null => (RANK[now] > RANK[prev] ? now : null)
18
19export const usd = (n: number): string => `$${n.toFixed(2)}`
20
21const pad = (n: number): string => String(n).padStart(2, '0')
22
23export const dayKey = (ms: number): string => {
24 const d = new Date(ms)
25 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
26}
27
28// The daily ledger: one store key per (local day, session id) holding the USD that
29// session spent that day. Each session writes only its own key, so sessions
30// running side by side never overwrite each other's spend.
31export const LEDGER_PREFIX = 'ledger:'
32
33export const ledgerKey = (day: string, sessionId: string): string => `${LEDGER_PREFIX}${day}:${sessionId}`
34
35export function parseLedgerKey(key: string): { day: string; sessionId: string } | null {
36 const m = /^ledger:(\d{4}-\d{2}-\d{2}):(.+)$/.exec(key)
37 return m ? { day: m[1] as string, sessionId: m[2] as string } : null
38}
39
40/** True when `day` is older than the last `keepDays` local calendar days. */
41export function isExpired(day: string, nowMs: number, keepDays = 7): boolean {
42 const now = new Date(nowMs)
43 return day < dayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (keepDays - 1)).getTime())
44}
45
46// A /clear or a /resume keeps the process under a new session id. Called before
47// anything is spent under the new id (at the turn's start, or a /clear's record
48// with no turn since), today counts from the total now: a /clear that started
49// over is at 0, and a resumed session's earlier spend already sits on its days.
50export function rebase(b: Budget, sessionId: string, total: number): Budget {
51 if (!b.sessionId || b.sessionId === sessionId) return { ...b, sessionId }
52 return { ...b, sessionId, dayBase: total, lastTotal: total }
53}
54
55// What this session spent on the current local day. A new day starts at the
56// session's total then, so yesterday's share stays in yesterday's entry.
57export function spendToday(b: Budget, total: number, nowMs: number): { day: string; dayBase: number; spent: number } {
58 const day = dayKey(nowMs)
59 const dayBase = day === b.day ? b.dayBase : total
60 return { day, dayBase, spent: Math.max(0, total - dayBase) }
61}
62
63// Today's spend on this machine as it stands now: the ledger sum taken when the
64// last turn ended, plus what this session spent since. A total that started over
65// (a /clear) adds nothing.
66export const todaySpent = (b: Budget, total: number): number => b.dailySpent + Math.max(0, total - b.lastTotal)
67
68// The caps from /config, applied again when the plugin reloads with new options; a
69// session cap set with /glitch budget stays. A cap that moved re-arms its warnings
70// from where the spend stands now, as /glitch budget does.
71export function withConfigCaps(b: Budget, c: { budgetSession: number; budgetDaily: number }, sessionSpent: number): Budget {
72 const sessionCap = b.sessionCapOverridden ? b.sessionCap : c.budgetSession
73 const dailyCap = c.budgetDaily
74 return {
75 ...b,
76 sessionCap,
77 dailyCap,
78 notified: {
79 session: sessionCap === b.sessionCap ? b.notified.session : levelOf(sessionSpent, sessionCap),
80 daily: dailyCap === b.dailyCap ? b.notified.daily : levelOf(b.dailySpent, dailyCap),
81 },
82 }
83}
84
85export function blockReason(b: Budget, sessionSpent: number): string | null {
86 if (b.isOff) return null
87 if (b.sessionCap > 0 && sessionSpent >= b.sessionCap) {
88 return `Glitch budget cap ${usd(b.sessionCap)} reached (session). Ask the user to raise it with /glitch budget +N.`
89 }
90 // `/glitch budget +N` moves the session cap only, so the daily cap names what lifts it.
91 if (b.dailyCap > 0 && b.dailySpent >= b.dailyCap) {
92 return `Glitch budget cap ${usd(b.dailyCap)} reached (daily). Ask the user to run /glitch budget off for this session, or raise budgetDaily in /config.`
93 }
94 return null
95}
96hooks/cache.ts 54 lines1import type { CacheState, Level } from '../types'
2
3const HOUR_MS = 60 * 60_000
4/** How long a run-out demo shows "expired" before the real countdown is back. */
5export const DEMO_HOLD_MS = 5000
6
7/** A demo countdown of `seconds`; the real deadline is set aside, once, to come back. */
8export const startDemo = (c: CacheState, now: number, seconds: number): CacheState => ({
9 ...c,
10 expiresAt: now + seconds * 1000,
11 secondsLeft: seconds,
12 warnedMark: 0,
13 real: c.real ?? { expiresAt: c.expiresAt, warnedMark: c.warnedMark },
14})
15
16export const isDemoOver = (c: CacheState, now: number): boolean => !!c.real && c.expiresAt !== null && now >= c.expiresAt + DEMO_HOLD_MS
17
18/** The real deadline back in place of a demo's. */
19export function endDemo(c: CacheState, now: number): CacheState {
20 if (!c.real) return c
21 const { expiresAt, warnedMark } = c.real
22 const secondsLeft = expiresAt === null ? null : Math.max(0, Math.floor((expiresAt - now) / 1000))
23 return { ...c, expiresAt, secondsLeft, warnedMark, real: null }
24}
25
26// The warning mark to announce now, if any: the smallest mark whose window
27// holds `secondsLeft`, when it is below the last one announced (0 = none yet).
28export function dueMark(secondsLeft: number, marks: number[], warned: number): number | null {
29 if (secondsLeft <= 0) return null
30 const due = marks.filter(m => secondsLeft <= m * 60)
31 if (due.length === 0) return null
32 const mark = Math.min(...due)
33 return warned !== 0 && warned <= mark ? null : mark
34}
35
36/** The marks used when the person typed none: 10 and 5 for an hour, 2 and 1 for 5 minutes. */
37export const defaultMarks = (ttlMs: number): number[] => (ttlMs >= HOUR_MS ? [10, 5] : [2, 1])
38
39/** The marks that can fire within `ttlMs`: the typed ones, or those fitted to the lifetime. */
40export const warnMarks = (typed: number[] | null, ttlMs: number): number[] =>
41 (typed ?? defaultMarks(ttlMs)).filter(m => m * 60_000 < ttlMs)
42
43// Warn at a third of the lifetime left, alert at a sixth (20 and 10 min of an hour).
44export const cacheLevel = (secondsLeft: number | null, isBusy: boolean, ttlMs = HOUR_MS): Level =>
45 isBusy || secondsLeft === null ? 'ok' : secondsLeft * 1000 <= ttlMs / 6 ? 'alert' : secondsLeft * 1000 <= ttlMs / 3 ? 'warn' : 'ok'
46
47const pad = (n: number): string => String(n).padStart(2, '0')
48
49export const clockText = (seconds: number): string => `${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`
50
51export function shouldAutoWarm(a: { mark: number; marks: number[]; now: number; lastPromptAt: number; maxIdleMin: number; warmedSincePrompt: boolean }): boolean {
52 return a.mark === Math.min(...a.marks) && !a.warmedSincePrompt && a.now - a.lastPromptAt <= a.maxIdleMin * 60_000
53}
54hooks/commands.ts 81 lines1// Pure helpers of the /glitch-hud command: argument parsing and message texts.
2// Everything that touches `$` or an atom lives in register.tsx.
3import type { Budget, Level } from '../types'
4import { crossed, levelOf, usd } from './budget'
5import { clockText } from './cache'
6
7export const USAGE = 'Glitch HUD: /glitch hud [parts <list> | cost <list>] · budget [+N|N|off|on] · warm · demo cache <min> | demo budget <pct>'
8export const BUDGET_USAGE = 'Usage: /glitch budget [+N | N | off | on]'
9export const DEMO_USAGE = 'Usage: /glitch demo cache <minutes> | demo budget <percent>'
10export const WARM_PROMPT = 'Keep-warm ping from the Glitch HUD. Reply with just: ok'
11
12/** The first words of `/glitch <sub>` that this plugin answers. */
13export const ROUTED = ['hud', 'budget', 'warm', 'demo']
14
15export const splitArgs = (args: string): string[] => args.trim().split(/\s+/).filter(Boolean)
16
17export const kTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
18
19const pct = (spent: number, cap: number): string => (cap > 0 ? ` (${Math.round((spent / cap) * 100)}%)` : '')
20
21export function budgetSummary(b: Budget, total: number): string {
22 if (b.isOff) return 'Budget caps are off for this session (/glitch budget on).'
23 const session = b.sessionCap > 0 ? `${usd(total)} / ${usd(b.sessionCap)}${pct(total, b.sessionCap)}` : `${usd(total)} / off`
24 const daily = b.dailyCap > 0 ? `${usd(b.dailySpent)} / ${usd(b.dailyCap)}${pct(b.dailySpent, b.dailyCap)}` : `${usd(b.dailySpent)} / off`
25 return `Session ${session} · Today ${daily}`
26}
27
28export type BudgetArg = { kind: 'off' } | { kind: 'on' } | { kind: 'add' | 'set'; n: number }
29
30export function parseBudgetArg(arg: string): BudgetArg | null {
31 if (arg === 'off') return { kind: 'off' }
32 if (arg === 'on') return { kind: 'on' }
33 const m = /^(\+)?(\d+(?:\.\d+)?)$/.exec(arg)
34 return m ? { kind: m[1] ? 'add' : 'set', n: Number(m[2]) } : null
35}
36
37/** The part of a ModelForkResult the keep-warm message reads. */
38export type WarmReply =
39 | { isAnswered: true; usage: { cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null } }
40 | { isAnswered: false; reason: string }
41
42export function warmMessage(reply: WarmReply, nextExpirySeconds: number): string {
43 if (!reply.isAnswered) {
44 return reply.reason === 'nothing-to-fork'
45 ? 'Nothing to keep warm yet: no answer in this session.'
46 : `Keep-warm failed (${reply.reason}); the countdown is unchanged.`
47 }
48 const next = clockText(nextExpirySeconds)
49 const read = reply.usage.cache_read_input_tokens ?? 0
50 return read > 0
51 ? `Cache kept warm (${kTokens(read)} tokens read from cache). Next expiry in ${next}.`
52 : `The cache had expired; the ping wrote it again (${kTokens(reply.usage.cache_creation_input_tokens ?? 0)} tokens). Next expiry in ${next}.`
53}
54
55export type Spend = { session: number; daily: number; sessionCap: number; dailyCap: number }
56type Toast = { text: string; timeoutMs: number }
57
58/** What each rise past 90% / 100% of a cap says; `notified` is the new memory. */
59export function budgetAlerts(prev: Budget['notified'], s: Spend): { notified: Budget['notified']; toasts: Toast[]; notice: string | null } {
60 const notified = { ...prev }
61 const toasts: Toast[] = []
62 let notice: string | null = null
63 const checks: [scope: 'session' | 'daily', spent: number, cap: number][] = [
64 ['session', s.session, s.sessionCap],
65 ['daily', s.daily, s.dailyCap],
66 ]
67 for (const [scope, spent, cap] of checks) {
68 const level: Level = levelOf(spent, cap)
69 const rise = crossed(prev[scope], level)
70 if (!rise) continue
71 notified[scope] = level
72 const what = scope === 'session' ? 'session spend' : "today's spend"
73 if (rise === 'alert') toasts.push({ text: `Glitch: ${what} ${usd(spent)} is past 90% of ${usd(cap)}`, timeoutMs: 10_000 })
74 if (rise === 'over') {
75 toasts.push({ text: `Glitch: ${what} ${usd(spent)} reached the ${usd(cap)} cap`, timeoutMs: 15_000 })
76 notice = `Budget cap reached: ${what} ${usd(spent)} of ${usd(cap)}`
77 }
78 }
79 return { notified, toasts, notice }
80}
81hooks/config.ts 124 lines1import type { CostScope, PartId } from '../types'
2import { defaultMarks } from './cache'
3
4export type NotifyMode = 'windows' | 'toast' | 'off'
5
6export type HudConfig = {
7 parts: PartId[]
8 /** The cost figures shown, in order. */
9 costShows: CostScope[]
10 /** The person changed Parts from the default; the desktop then shows git too. */
11 partsExplicit: boolean
12 budgetSession: number
13 budgetDaily: number
14 budgetBlocksAgents: boolean
15 /** The marks the person typed; null for none, then they fit the lifetime (warnMarks). */
16 cacheWarnMinutes: number[] | null
17 /** How long the prompt cache lives after a request: 60 or 5 minutes. */
18 cacheTtlMs: number
19 notify: NotifyMode
20 autoWarm: boolean
21 autoWarmMaxIdle: number
22}
23
24export const ALL_PARTS: PartId[] = ['turn', 'speed', 'cost', 'cache', 'context', 'git']
25
26export const ALL_COST: CostScope[] = ['reply', 'session', 'today']
27
28export const DEFAULT_CONFIG: HudConfig = {
29 parts: [...ALL_PARTS],
30 partsExplicit: false,
31 costShows: [...ALL_COST],
32 budgetSession: 0,
33 budgetDaily: 0,
34 budgetBlocksAgents: false,
35 cacheWarnMinutes: null,
36 cacheTtlMs: 60 * 60_000,
37 notify: 'windows',
38 autoWarm: false,
39 autoWarmMaxIdle: 120,
40}
41
42export const parseParts = (s: string): PartId[] | null => parseList(s, ALL_PARTS)
43
44// A comma list of `all`'s ids, in the given order without duplicates; null when
45// any entry is unknown or the list is empty.
46const parseList = <T extends string>(s: string, all: readonly T[]): T[] | null => {
47 const ids = s.split(',').map(x => x.trim())
48 if (ids.length === 0 || ids.some(id => !all.includes(id as T))) return null
49 return [...new Set(ids)] as T[]
50}
51
52export const parseCostShows = (s: string): CostScope[] | null => parseList(s, ALL_COST)
53
54export function parseMarks(s: string): number[] | null {
55 const marks = s.split(',').map(x => x.trim()).filter(Boolean).map(Number)
56 if (marks.length === 0 || marks.some(m => !Number.isFinite(m) || m < 1 || m > 59)) return null
57 return [...new Set(marks)].sort((a, b) => b - a)
58}
59
60const num = (v: unknown): number | null => {
61 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
62 return Number.isFinite(n) && n >= 0 ? n : null
63}
64
65const bool = (v: unknown): boolean | null => (v === true || v === 'true' ? true : v === false || v === 'false' ? false : null)
66
67// Settings as the person stored them, made safe: each bad value falls back to its
68// default and is named in `problems`.
69export function parseConfig(o: Record<string, unknown>): { config: HudConfig; problems: string[] } {
70 const config: HudConfig = { ...DEFAULT_CONFIG, parts: [...DEFAULT_CONFIG.parts], costShows: [...DEFAULT_CONFIG.costShows], cacheWarnMinutes: DEFAULT_CONFIG.cacheWarnMinutes }
71 const problems: string[] = []
72 const readNumbers = (keys: ('budgetSession' | 'budgetDaily' | 'autoWarmMaxIdle')[]) => {
73 for (const key of keys) {
74 if (o[key] === undefined) continue
75 const n = num(o[key])
76 if (n !== null) config[key] = n
77 else problems.push(`${key}: must be a number ≥ 0, using ${DEFAULT_CONFIG[key]}`)
78 }
79 }
80
81 if (o.parts !== undefined) {
82 const parts = typeof o.parts === 'string' ? parseParts(o.parts) : null
83 if (parts) {
84 config.parts = parts
85 config.partsExplicit = parts.join(',') !== ALL_PARTS.join(',')
86 } else problems.push('parts: unknown or empty entry, using default')
87 }
88 if (o.costShows !== undefined) {
89 const shows = typeof o.costShows === 'string' ? parseCostShows(o.costShows) : null
90 if (shows) config.costShows = shows
91 else problems.push('costShows: use reply, session or today, using reply,session,today')
92 }
93 readNumbers(['budgetSession', 'budgetDaily'])
94 for (const key of ['budgetBlocksAgents', 'autoWarm'] as const) {
95 if (o[key] === undefined) continue
96 const b = bool(o[key])
97 if (b !== null) config[key] = b
98 else problems.push(`${key}: must be true or false, using ${DEFAULT_CONFIG[key]}`)
99 }
100 if (o.cacheTtlMinutes !== undefined) {
101 const ttl = String(o.cacheTtlMinutes).trim()
102 if (ttl === '60' || ttl === '5') config.cacheTtlMs = Number(ttl) * 60_000
103 else problems.push('cacheTtlMinutes: must be 60 or 5, using 60')
104 }
105 // Left empty, the marks fit the lifetime; only marks the person typed are checked.
106 if (o.cacheWarnMinutes !== undefined && String(o.cacheWarnMinutes).trim() !== '') {
107 const marks = typeof o.cacheWarnMinutes === 'string' ? parseMarks(o.cacheWarnMinutes) : null
108 if (marks) config.cacheWarnMinutes = marks
109 else problems.push(`cacheWarnMinutes: must be minutes between 1 and 59, using ${defaultMarks(config.cacheTtlMs).join(',')}`)
110 }
111 // A typed mark at or above the lifetime would be due the moment an answer lands.
112 const typed = config.cacheWarnMinutes
113 if (typed && typed.some(m => m * 60_000 >= config.cacheTtlMs)) {
114 config.cacheWarnMinutes = typed.filter(m => m * 60_000 < config.cacheTtlMs)
115 problems.push('cacheWarnMinutes: marks at or above the cache lifetime are ignored')
116 }
117 if (o.notify !== undefined) {
118 if (o.notify === 'windows' || o.notify === 'toast' || o.notify === 'off') config.notify = o.notify
119 else problems.push('notify: must be windows, toast or off, using windows')
120 }
121 readNumbers(['autoWarmMaxIdle'])
122 return { config, problems }
123}
124hooks/git.ts 42 lines1import type { GitInfo } from '../types'
2
3export type RunFn = (argv: readonly string[], init: { timeoutMs: number }) => Promise<{ exitCode: number; stdout: string }>
4
5export const GIT_ARGV: readonly string[] = ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch']
6
7// `--no-optional-locks`: the status refresh never takes index.lock, so it cannot
8// race the person's own git commands.
9// `git status --porcelain=v2 --branch`: headers start with `# branch.`, entries
10// with 1 (changed), 2 (renamed), u (unmerged) or ? (untracked); ! is ignored.
11export function parseStatus(stdout: string): GitInfo | null {
12 let branch = ''
13 let oid = ''
14 let ahead = 0
15 let behind = 0
16 let changed = 0
17 for (const line of stdout.split(/\r?\n/)) {
18 if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length).trim()
19 else if (line.startsWith('# branch.head ')) branch = line.slice('# branch.head '.length).trim()
20 else if (line.startsWith('# branch.ab ')) {
21 const m = /\+(\d+) -(\d+)/.exec(line)
22 if (m) {
23 ahead = Number(m[1])
24 behind = Number(m[2])
25 }
26 } else if (/^[12u?] /.test(line)) changed++
27 }
28 if (!branch) return null
29 if (branch === '(detached)') branch = oid && oid !== '(initial)' ? oid.slice(0, 7) : 'detached'
30 return { branch, changed, ahead, behind }
31}
32
33// The engine's process call is injected, so this stays free of `$`.
34export async function readGit(run: RunFn): Promise<GitInfo | null> {
35 try {
36 const ran = await run(GIT_ARGV, { timeoutMs: 5000 })
37 return ran.exitCode === 0 ? parseStatus(ran.stdout) : null
38 } catch {
39 return null
40 }
41}
42hooks/notify.ts 45 lines1import type { NotifyMode } from './config'
2
3export type NotifyIO = {
4 os: () => Promise<string | null | undefined>
5 run: (argv: readonly string[], init: { timeoutMs: number }) => Promise<{ exitCode: number }>
6 notifyClaude: (text: string, options: { title: string }) => Promise<{ isSent: boolean }>
7 toast: (text: string, options: { timeoutMs: number }) => void
8}
9
10export const projectName = (cwd: string): string => cwd.split(/[\\/]/).filter(Boolean).pop() ?? 'session'
11
12// PowerShell's own app id: Windows shows its toasts with no app to register.
13const POWERSHELL_APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
14
15const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
16const quoted = (s: string): string => `'${s.replace(/['‘’‚‛]/g, m => m + m)}'`
17
18export function toastArgv(title: string, message: string): string[] {
19 const toast = `<toast><visual><binding template="ToastGeneric"><text>${xml(title)}</text><text>${xml(message)}</text></binding></visual></toast>`
20 const script = [
21 '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
22 '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null',
23 '$x = New-Object Windows.Data.Xml.Dom.XmlDocument',
24 `$x.LoadXml(${quoted(toast)})`,
25 `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(${quoted(POWERSHELL_APP_ID)}).Show([Windows.UI.Notifications.ToastNotification]::new($x))`,
26 ].join('; ')
27 return ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script]
28}
29
30// Native Windows toast, else Claude Code's own notification, else a toast in the
31// session. Says which one carried it.
32export async function notify(io: NotifyIO, mode: NotifyMode, title: string, message: string): Promise<'windows' | 'claude' | 'toast' | 'off'> {
33 if (mode === 'off') return 'off'
34 if (mode === 'windows') {
35 if ((await io.os().catch(() => null)) === 'Windows_NT') {
36 const ran = await io.run(toastArgv(title, message), { timeoutMs: 15_000 }).catch(() => null)
37 if (ran?.exitCode === 0) return 'windows'
38 }
39 const sent = await io.notifyClaude(message, { title }).catch(() => null)
40 if (sent?.isSent) return 'claude'
41 }
42 io.toast(`${title}: ${message}`, { timeoutMs: 30_000 })
43 return 'toast'
44}
45hooks/desktop.ts 163 lines1// The band as the surfaces with `Svg` draw it (desktop, VS Code, mobile): vector
2// icons and a real context bar, and worded values in place of the terminal's bare
3// symbols. Pure: register.tsx draws what this returns.
4//
5// Each graphic is drawn as a plain image at a fixed size. Its markup only changes
6// with its color or fill, never with the clock, so a redraw each second reuses it.
7// No hover tooltips: an interactive Svg is a frame of its own, which the desktop
8// drew far taller than the row and rebuilt on every tick.
9import type { CostScope, Level, PartId } from '../types'
10import { costLevel, todaySpent, usd } from './budget'
11import { cacheLevel, clockText } from './cache'
12import { cellWidth, costFigures, levelColor } from './parts'
13import type { PartData, Segment } from './parts'
14
15export type Graphic = { source: string; alt: string; width: number; height: number; cells: number }
16
17export type RichPart = { graphic: Graphic | null; segments: Segment[] }
18
19/** The cells an icon takes in the row, for fitting; the bar takes more. */
20export const ICON_CELLS = 2
21export const BAR_CELLS = 7
22
23const BAR_WIDTH = 48
24const BAR_HEIGHT = 8
25const ICON_SIZE = 14
26
27// Theme colors come from a custom theme too: only a hex value or a color name
28// reaches the markup.
29export const safeColor = (c: string | undefined): string => (c && /^(#[0-9a-f]{3,8}|[a-z]+)$/i.test(c) ? c : 'currentColor')
30
31// The cost in the host's own status colors, as its diff counts are drawn: the
32// themes' colors are made for a dark terminal and wash out on a light band. The
33// text names them, so they follow the person's light or dark mode; the icon is an
34// image and cannot, so it takes the light-mode shades sampled from the desktop.
35export const HOST_COLOR = { ok: 'success', warn: 'warning', alert: 'error' } as const
36export const HOST_HEX = { ok: '#24a041', warn: '#b7791f', alert: '#ce295b' } as const
37const hostLevel = (l: Level): keyof typeof HOST_COLOR => (l === 'over' ? 'alert' : l)
38
39type IconKind = 'speed' | 'cost' | 'cache'
40
41const SHAPES: Record<IconKind, (c: string) => string> = {
42 speed: c => `<path d="M9.5 1 3 9.2h4.2L6.5 15 13 6.8H8.8z" fill="${c}"/>`,
43 cost: c =>
44 `<circle cx="8" cy="8" r="6.5" fill="none" stroke="${c}" stroke-width="1.5"/>` +
45 `<path d="M10 5.9c-.4-.6-1.1-.9-2-.9-1.1 0-1.9.6-1.9 1.4 0 .9.8 1.2 1.9 1.5 1.1.3 2 .7 2 1.6 0 .9-.9 1.5-2 1.5-.9 0-1.6-.3-2.1-.9M8 3.8V5m0 6v1.2" fill="none" stroke="${c}" stroke-width="1.3" stroke-linecap="round"/>`,
46 cache:
47 c =>
48 `<circle cx="8" cy="8" r="6.5" fill="none" stroke="${c}" stroke-width="1.5"/>` +
49 `<path d="M8 4.5V8l2.5 1.6" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round"/>`,
50}
51
52export const iconSvg = (kind: IconKind, color: string, alt: string): Graphic => ({
53 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 16 16">${SHAPES[kind](safeColor(color))}</svg>`,
54 alt,
55 width: ICON_SIZE,
56 height: ICON_SIZE,
57 cells: ICON_CELLS,
58})
59
60export const barSvg = (percent: number, color: string, track: string, alt: string): Graphic => {
61 // Any fill at all is drawn at least as a round dot.
62 const fill = percent > 0 ? Math.max(BAR_HEIGHT, Math.round((Math.min(100, percent) / 100) * BAR_WIDTH)) : 0
63 const r = BAR_HEIGHT / 2
64 return {
65 source:
66 `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_WIDTH}" height="${BAR_HEIGHT}" viewBox="0 0 ${BAR_WIDTH} ${BAR_HEIGHT}">` +
67 `<rect width="${BAR_WIDTH}" height="${BAR_HEIGHT}" rx="${r}" fill="${safeColor(track)}" fill-opacity="0.3"/>` +
68 (fill > 0 ? `<rect width="${fill}" height="${BAR_HEIGHT}" rx="${r}" fill="${safeColor(color)}"/>` : '') +
69 `</svg>`,
70 alt,
71 width: BAR_WIDTH,
72 height: BAR_HEIGHT,
73 cells: BAR_CELLS,
74 }
75}
76
77// The cap a figure is held to, when the budget is on: none for a reply.
78const capOf = (d: PartData, scope: CostScope): number =>
79 d.budget.isOff ? 0 : scope === 'session' ? d.budget.sessionCap : scope === 'today' ? d.budget.dailyCap : 0
80
81const ofCap = (d: PartData, scope: CostScope): string => (capOf(d, scope) > 0 ? ` of ${usd(capOf(d, scope))}` : '')
82
83// Every figure, whichever the band shows: the tooltip is where they are spelled out.
84export function costTip(d: PartData): string {
85 const s = d.stats
86 if (s.isStale) return 'Session cost: cannot be read right now'
87 const showsReply = s.turnCost !== null && usd(s.turnCost) !== usd(s.total)
88 return [
89 showsReply ? `${d.isWorking ? 'This' : 'Last'} reply ${usd(s.turnCost as number)}` : null,
90 `Session ${usd(s.total)}${ofCap(d, 'session')}`,
91 `Today on this machine ${usd(todaySpent(d.budget, s.total))}${ofCap(d, 'today')}`,
92 ].filter(Boolean).join(' · ')
93}
94
95export function cacheTip(d: PartData): string {
96 const left = d.cache.secondsLeft
97 if (d.isWorking) return 'Prompt cache: a request is running'
98 if (left === null) return 'Prompt cache: no request yet'
99 if (left === 0) return 'Prompt cache expired: the next message reads the whole context again'
100 // No clock here: the time is in the text beside it, and an icon whose props
101 // change each second is redrawn each second.
102 return 'Prompt cache countdown. Press ↻ to keep it warm.'
103}
104
105export function richPart(id: PartId, d: PartData, compact: boolean): RichPart {
106 const t = d.theme
107 const s = d.stats
108 switch (id) {
109 case 'turn':
110 return { graphic: null, segments: [{ text: 'Turn ', dim: true }, { text: String(s.turn), bold: true }] }
111 case 'speed':
112 return {
113 graphic: iconSvg('speed', t.info, 'Output speed of the last answer'),
114 segments: [{ text: s.speed === null ? '—' : String(s.speed), bold: true }, { text: ' tok/s', dim: true }],
115 }
116 case 'cost': {
117 // Compact keeps the figures alone; the tooltip still names them.
118 const level = hostLevel(costLevel(d.budget, s.total))
119 const color = HOST_COLOR[level]
120 return {
121 graphic: iconSvg('cost', HOST_HEX[level], costTip(d)),
122 segments: s.isStale
123 ? [{ text: '—', bold: true, color }, ...(compact ? [] : [{ text: ' session', dim: true }])]
124 : costFigures(d).flatMap((f, n): Segment[] => [
125 ...(n > 0 ? [{ text: ' · ', dim: true }] : []),
126 { text: usd(f.value), bold: true, color },
127 ...(compact
128 ? []
129 : [...(capOf(d, f.scope) > 0 ? [{ text: ' of ', dim: true }, { text: usd(capOf(d, f.scope)), color }] : []), { text: ` ${f.scope}`, dim: true }]),
130 ]),
131 }
132 }
133 case 'cache': {
134 const left = d.cache.secondsLeft
135 const level = cacheLevel(left, d.isWorking, d.cache.ttlMs)
136 const color = level === 'ok' ? t.accent : levelColor(t, level)
137 const value = d.isWorking ? '…' : left === null ? '—' : left === 0 ? 'expired' : clockText(left)
138 return {
139 graphic: iconSvg('cache', color, cacheTip(d)),
140 segments: [{ text: value, bold: true, ...(level === 'ok' ? {} : { color }) }, ...(left && !d.isWorking ? [{ text: ' left', dim: true }] : [])],
141 }
142 }
143 case 'context': {
144 const color = s.context >= 85 ? t.alert : s.context >= 65 ? t.warn : t.info
145 const tip = `Context ${s.context}% full`
146 return {
147 graphic: compact || s.isStale ? null : barSvg(s.context, color, t.muted, tip),
148 segments: [{ text: s.isStale ? '—' : `${s.context}%`, bold: true }, { text: ' context', dim: true }],
149 }
150 }
151 case 'git': {
152 const g = d.git
153 if (!g) return { graphic: null, segments: [] }
154 const extra = [g.changed ? ` ±${g.changed}` : '', g.ahead ? ` ↑${g.ahead}` : '', g.behind ? ` ↓${g.behind}` : ''].join('')
155 return { graphic: null, segments: [{ text: g.branch, bold: true }, { text: extra, color: t.muted }] }
156 }
157 }
158}
159
160/** Cells a rich part takes: its graphic, a gap, its text. */
161export const richWidth = (p: RichPart): number =>
162 (p.graphic ? p.graphic.cells + 1 : 0) + cellWidth(p.segments.map(x => x.text).join(''))
163hooks/pet.ts 15 lines1// The pet slot: glitch-pets publishes its current frame; the band draws it at the
2// row's end when it is well formed, and nothing otherwise.
3export type PetSprite = { source: string; alt: string; width: number; height: number }
4
5/** The cells the 24 px pet takes in the desktop row (about 3.4 cells; one spare). */
6export const PET_CELLS = 4
7const MAX_PX = 64
8
9export function isSprite(v: unknown): v is PetSprite {
10 if (!v || typeof v !== 'object') return false
11 const o = v as Record<string, unknown>
12 const px = (n: unknown) => typeof n === 'number' && n > 0 && n <= MAX_PX
13 return typeof o.source === 'string' && o.source.startsWith('<svg') && typeof o.alt === 'string' && px(o.width) && px(o.height)
14}
15hooks/parts.ts 119 lines1import type { Budget, CacheState, CostScope, GitInfo, HudTheme, Level, PartId, Stats } from '../types'
2import { costLevel, todaySpent, usd } from './budget'
3import { cacheLevel, clockText } from './cache'
4
5export type Segment = { text: string; color?: string; bold?: boolean; dim?: boolean }
6
7export type PartData = {
8 stats: Stats
9 cache: CacheState
10 budget: Budget
11 git: GitInfo | null
12 theme: HudTheme
13 isWorking: boolean
14 /** The cost figures to show, in order. */
15 costShows: CostScope[]
16}
17
18export type CostFigure = { scope: CostScope; value: number }
19
20// The chosen cost figures with their values. The reply figure is left out before
21// any reply, and while it reads the same as the session figure beside it (the
22// first turn).
23export function costFigures(d: PartData): CostFigure[] {
24 const s = d.stats
25 const value = (scope: CostScope): number =>
26 scope === 'reply' ? (s.turnCost ?? 0) : scope === 'session' ? s.total : todaySpent(d.budget, s.total)
27 const hidesReply = s.turnCost === null || (d.costShows.includes('session') && usd(s.turnCost) === usd(s.total))
28 return d.costShows.filter(scope => scope !== 'reply' || !hidesReply).map(scope => ({ scope, value: value(scope) }))
29}
30
31export const DROP_ORDER: PartId[] = ['git', 'speed', 'turn']
32
33type IconSet = { turn: string; speed: string; cost: string; cache: string; context: string; git: string; changed: string; ahead: string; behind: string }
34
35const ICONS: Record<HudTheme['icons'], IconSet> = {
36 emoji: { turn: '#', speed: '⚡', cost: '💰', cache: '⏳', context: '🧠', git: '🌿', changed: '±', ahead: '↑', behind: '↓' },
37 symbols: { turn: '#', speed: '⚡', cost: '$', cache: '⧗', context: '◔', git: '⎇', changed: '±', ahead: '↑', behind: '↓' },
38 ascii: { turn: '#', speed: '>', cost: '$', cache: 'T', context: 'C', git: '@', changed: '+-', ahead: '^', behind: 'v' },
39}
40
41// Terminals draw these two cells wide.
42const WIDE = new Set(['⏳', '⚡'])
43
44export const cellWidth = (s: string): number => [...s].reduce((w, ch) => w + ((ch.codePointAt(0) ?? 0) >= 0x1f000 || WIDE.has(ch) ? 2 : 1), 0)
45
46export const levelColor = (t: HudTheme, l: Level): string => (l === 'ok' ? t.good : l === 'warn' ? t.warn : t.alert)
47
48const bar = (t: HudTheme, percent: number, width: number): string => {
49 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
50 return t.bar[0].repeat(filled) + t.bar[1].repeat(width - filled)
51}
52
53export function segments(id: PartId, d: PartData, compact: boolean): Segment[] {
54 const t = d.theme
55 const i = ICONS[t.icons]
56 const s = d.stats
57 switch (id) {
58 case 'turn':
59 return [{ text: `${i.turn}${s.turn}`, bold: true }]
60 case 'speed':
61 return [{ text: `${i.speed} `, color: t.info }, { text: s.speed === null ? '—' : `${s.speed} tok/s`, bold: true }]
62 case 'cost': {
63 // Unreadable usage: — rather than a number that looks frozen.
64 if (s.isStale) return [{ text: `${i.cost} `, color: t.muted }, { text: '—', bold: true }]
65 // The icon says dollars, so the figures go without the sign; compact drops the words.
66 const color = levelColor(t, costLevel(d.budget, s.total))
67 return [
68 { text: `${i.cost} `, color },
69 ...costFigures(d).flatMap((f, n): Segment[] => [
70 ...(n > 0 ? [{ text: ' · ', dim: true }] : []),
71 { text: f.value.toFixed(2), bold: true, color },
72 ...(compact ? [] : [{ text: ` ${f.scope}`, dim: true }]),
73 ]),
74 ]
75 }
76 case 'cache': {
77 const left = d.cache.secondsLeft
78 const level = cacheLevel(left, d.isWorking, d.cache.ttlMs)
79 const color = level === 'ok' ? t.accent : levelColor(t, level)
80 const value = d.isWorking ? '…' : left === null ? '—' : left === 0 ? 'expired' : clockText(left)
81 return [{ text: `${i.cache} `, color }, { text: value, bold: true, ...(level === 'ok' ? {} : { color }) }]
82 }
83 case 'context': {
84 if (s.isStale) return [{ text: `${i.context} `, color: t.muted }, { text: '—', bold: true }]
85 const color = s.context >= 85 ? t.alert : s.context >= 65 ? t.warn : t.info
86 return compact
87 ? [{ text: `${i.context} `, color }, { text: `${s.context}%`, bold: true }]
88 : [{ text: `${i.context} `, color }, { text: `${bar(t, s.context, 6)} `, color }, { text: `${s.context}%`, bold: true }]
89 }
90 case 'git': {
91 const g = d.git
92 if (!g) return []
93 const extra = [g.changed ? ` ${i.changed}${g.changed}` : '', g.ahead ? ` ${i.ahead}${g.ahead}` : '', g.behind ? ` ${i.behind}${g.behind}` : ''].join('')
94 return [{ text: `${i.git} `, color: t.muted }, { text: g.branch, bold: true }, { text: extra, color: t.muted }]
95 }
96 }
97}
98
99// Which parts fit in `columns` cells on one line: drop by DROP_ORDER, then go
100// compact (the cost loses its words, the context bar becomes its percent).
101// `reserve` holds the row's controls.
102export function fitParts(
103 parts: PartId[],
104 width: (id: PartId, compact: boolean) => number,
105 columns: number,
106 gap = 2,
107 reserve = 2,
108): { shown: PartId[]; compact: boolean } {
109 let shown = [...parts]
110 let compact = false
111 const total = () => shown.reduce((sum, id) => sum + width(id, compact), 0) + gap * Math.max(0, shown.length - 1) + reserve
112 for (const id of DROP_ORDER) {
113 if (total() <= columns) break
114 shown = shown.filter(p => p !== id)
115 }
116 if (total() > columns) compact = true
117 return { shown, compact }
118}
119hooks/theme.ts 23 lines1import type { HudTheme } from '../types'
2
3// glitch-core's default theme, used when glitch-core is not installed. Keep it
4// equal to glitch-core's `THEMES.glitch`.
5export const FALLBACK_THEME: HudTheme = {
6 name: 'glitch',
7 accent: '#00e5ff',
8 good: '#5af78e',
9 warn: '#f3c969',
10 alert: '#ff5c8a',
11 muted: '#7a7f8c',
12 info: '#c77dff',
13 bar: ['▓', '░'],
14 icons: 'symbols',
15}
16
17const isTheme = (v: unknown): v is HudTheme =>
18 typeof v === 'object' && v !== null && typeof (v as HudTheme).accent === 'string' && Array.isArray((v as HudTheme).bar)
19
20// The kit's theme as glitch-core publishes it (read by register.tsx while
21// drawing, so a theme switch redraws the band).
22export const toTheme = (value: unknown): HudTheme => (isTheme(value) ? value : FALLBACK_THEME)
23