SLOPSHOPPER

glitch-hud

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

newbandguardcommandtoastprompt
v0.4.1MITupdated 2026-10-14Sri-Ln/glitch/plugins/glitch-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glitch-hud
› 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 › /glitch-hud ⎿ glitch-hud: Glitch HUD: /glitch hud [parts <list> | cost <list>] · budget [+N|N|off|on] · warm · demo cache <min> | demo b #1 ⚡ — $ 0.00 reply · 0.42 session · 0.00 today ⧗ 60:00 ↻ ◔ ▓▓▓░░░ 49% × ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
#1 ⚡ — $ 0.00 reply · 0.42 session · 0.00 today ⧗ 60:00 ↻ ◔ ▓▓▓░░░ 49% ×
README

Glitch

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.

PluginWhat it does
glitch-coreShared themes and the /glitch command.
glitch-hudThe band above the prompt: turn, speed, cost and budget, cache countdown with keep-warm, context, git. Depends on glitch-core.
glitch-petsA pixel pet at the end of the HUD row, with moods, short remarks and a daily streak. Depends on glitch-core and glitch-hud.

The HUD

One line above the prompt. In the terminal it is text with theme colors and icons:

PartExampleSource
turn#12count of main-session turns
speed⚡ 84 tok/soutput 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 todaythe 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 ↑1branch, 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.

Budget guard

Set a cap per session (budgetSession), per local day (budgetDaily), or both. 0 turns a cap off.

  • 75% of a cap: the cost turns amber.
  • 90%: red, plus a toast.
  • 100%: toast plus a notification.

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.

Cache keeper

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).

  • Warnings. At each warning mark, once per answer, while idle, you get a notification titled <project> — Glitch: "Prompt cache expires in N min".
  • Notification chain. With 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).
  • Keep-warm. ↻ 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).
  • Cost. One ping is a cache read of the whole context: about $0.04 at 200k tokens of context on Opus 5.5, versus about $1.60 to rebuild an expired cache.
  • autoWarm (off by default). At the last warning mark, ping once if you submitted a prompt within autoWarmMaxIdle minutes; never twice in a row without a prompt from you in between.

glitch-pets

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.

PetKindTemperamentSignature move (idle)
Screenheadscreen botEarnest, literal and a little robotic; reports facts, proud of tidy output.Types; code scrolls on its screen face.
Byte blobblobBubbly and easily startled; squeaks with joy, wobbles when worried.Bounces: squash, stretch and a glitch slice.
PixelcatDry and unimpressed, secretly proud; says little, means it.Swishes its tail and sheds pixels.
DotladybugTidy, gentle and encouraging; notices the small things.Flutters her wings on a daisy.
ForkoctopusThe organised multitasker; a born lead who loves a plan.Taps four keys with four arms.
BuddogEager 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.

SettingDefaultMeaning
petbudyour pet: screenhead, blob, pixel, dot, fork, bud or off; /glitch pet <name> overrides it
voicetrueone-line remarks as toasts
voiceEveryMinutes3at most one remark per this many minutes
tiredMinutes10a turn this long makes the pet tired
sleepyMinutes10this long without a prompt makes the pet sleepy
/plugin install glitch-pets@glitch

Themes

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.

Commands

/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 ….

CommandOwnerDoes
/glitchcorehelp card: theme and the subcommands each plugin answers
`/glitch theme [name\default]`corelist themes, or switch and persist
`/glitch-pets [name\off]`petslist pets, switch and persist, or turn the pet off
`/glitch pet [name\off]`petsthe same, through the core command
/glitch hudhudshow or hide the band
/glitch hud parts <list>hudparts and order for this session, as a comma list, e.g. cost,cache,context or cost, cache
/glitch hud cost <list>hudcost figures and order for this session, e.g. session,today
`/glitch budget [+N \N \off \on]`hudshow, raise, set, disable or re-enable this session's caps
/glitch warmhudkeep-warm ping now
/glitch demo cache <min>hudpreview 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>hudpreview 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.

Settings

Set them in /config, or in ~/.claude/settings.json under pluginConfigs. Invalid values fall back to the default and toast once.

glitch-core

SettingDefaultMeaning
themeglitchdefault theme: glitch, neon, mono, sunset, matrix, paper
customemptyJSON object of theme tokens laid over the theme

glitch-hud

SettingDefaultMeaning
partsturn,speed,cost,cache,context,gitwhich parts show, in order
costShowsreply,session,todaywhich cost figures show, in order
budgetSession0USD cap per session; 0 is off
budgetDaily0USD cap per local day, across sessions; 0 is off
budgetBlocksAgentsfalserefuse new subagents at a cap
cacheTtlMinutes60prompt cache lifetime: 60 (most plans) or 5
cacheWarnMinutesemptyminutes-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
notifywindowswindows (native toast), toast (inside Claude Code) or off (no cache or cap notifications; budget toasts still show)
autoWarmfalsekeep the cache warm by itself at the last warning mark
autoWarmMaxIdle120minutes without a prompt from you after which autoWarm stops

Install

/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.

Notes

Mods use Claude Code's early-access function-hooks API; tested on Claude Code 2.1.296.

License

MIT, see LICENSE.

Source 13 files
hooks/register.tsx 542 lines
1import { 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}
542
hooks/atoms.ts 37 lines
1// 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 }
37
hooks/budget.ts 96 lines
1import 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}
96
hooks/cache.ts 54 lines
1import 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}
54
hooks/commands.ts 81 lines
1// 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}
81
hooks/config.ts 124 lines
1import 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}
124
hooks/git.ts 42 lines
1import 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}
42
hooks/notify.ts 45 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
45
hooks/desktop.ts 163 lines
1// 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(''))
163
hooks/pet.ts 15 lines
1// 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}
15
hooks/parts.ts 119 lines
1import 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}
119
hooks/theme.ts 23 lines
1import 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