A two-row HUD above the Claude Code prompt: model, path and git branch, context, plan-window and GitHub API quota gauges that warm from green to red…

A two-row HUD for Claude Code, drawn right above the prompt. One glance tells you which model you are on, where you are, how full the context and your plan windows are, whether the prompt cache is still warm, and what Claude has been doing.
Opus 5.5 ~/Project/app git:(main*) ↑1 Turn $0.42 +0.8% Session $3.10 ● Cache 1h ████████████ 58m hit 99.6% misses 1
Context ███▌████████ 22% 5h █▎██████████ 10% Week ▌███████████ 4% GH ██▍█████████ 20% Tools Bash×20 Edit×3 Skills tdd
It is a Claude Code mod (a plugin of function hooks), not a status line command: it redraws every second from the engine's own figures, so the cache countdown is live and a /model switch shows at once.
Row one: who and where, then what it costs and the cache
~ for home, shortened from the left when the row is tight.git:(branch): a yellow * when the tree is dirty, ↑n / ↓n against upstream.+0.8%: grey under 1%, yellow to 3%, red past it), then the session's total. The figures stay after the turn ends until you send the next prompt.5m or 1h), a bar that drains as it runs out and the time left, all green, then yellow, then red as it lapses; once it has lapsed, ○ Cache cold. Then hit, the session's hit rate (every main-loop request's cache reads over all its prompt tokens; the last request alone sits near 100% in any long conversation, and 99.5% never rounds up to 100%), and misses n when some requests wrote the prefix again instead of reading it. Words appear only when there is something to do: expiring · send a message; once lapsed, next message re-caches 82k tokens, with · /compact first on a large context; missed; not cached; caching off; and waiting for first request before the first one.Row two: how much is used, then the activity
↻3h).| Level | green | lime | yellow | orange | red |
|---|---|---|---|---|---|
| Context | < 30% | 30-50% | 50-65% | 65-80% | ≥ 80% |
| 5h / Week | < 40% | 40-60% | 60-75% | 75-90% | ≥ 90% |
The context window turns sooner: a fuller context degrades answers and nears auto-compact well before a plan window runs out.
gh api rate_limit every two minutes in an interactive session (that call costs none of the quota). 未登入 when gh has no token (never logged in, or the anonymous 60 an SSH session without the keychain gets). When a check fails the last reading stays with a grey ?, and goes once its hour has reset. No gh, no GH gauge.✗n when some failed; Skills loaded, by name.On a narrow terminal the two rows fold into a stack with aligned labels, and the activity is left out so the essentials keep room around them:
Opus 5.5 ~/Project/app
● Cache 1h ████████████ 58m hit 99.6%
Turn $0.42 +0.8% Session $3.10
Context ███▌████████ 22%
5h █▎██████████ 10% Week ▌███████████ 4%
The dollars are Claude Code's own ledger, the figure /cost shows: every request priced by its four token counts, uncached input at the base rate, cache reads at about 0.1x, cache writes at 1.25x (5-minute) or 2x (1-hour), and output. So a well-kept cache shows up as a cheap turn, and a lapsed one on a large context as an expensive one. Two things to keep in mind:
+0.8%) is what actually counts against you.+0.0%./cache opens a pane with a per-turn table of cache reads, writes and uncached tokens.
From Anthropic's prompt caching documentation:
input_tokens (uncached remainder) + cache_read_input_tokens + cache_creation_input_tokens./compact./clear starts a new conversation in the same process, so the meter and the /cache table start over with it. A change in the prefix (model, effort or thinking settings, tool set, system prompt, CLAUDE.md) makes the next request write instead of read. The mod names the cause when it sees a miss: model changed, the cache had lapsed, or the prefix changed.The mod follows Claude Code's own rules (prompt caching: cache lifetime, Claude Code 2.1.242 or later). For the main conversation the TTL is the first match of:
| # | Source | Result |
|---|---|---|
| 1 | the mod's ttl option (5m / 1h) | what you set |
| 2 | FORCE_PROMPT_CACHING_5M=1 | 5 minutes |
| 3 | CLAUDE_CODE_PROMPT_CACHE_TTL | 5m or 1h |
| 4 | the promptCacheTtl setting (as Claude Code merges it: managed, --settings, local, project, user) | 5m or 1h |
| 5 | ENABLE_PROMPT_CACHING_1H=1 | 1 hour |
| 6 | the account | 1 hour on a Claude subscription within its plan usage; 5 minutes on usage credits, an API key or a cloud provider |
The account comes from the rate-limit windows the last response reported: a five_hour or seven_day window means a subscription, and one at 100% means requests now draw on usage credits. An API key or a cloud provider reports no such window, and before the first response nothing is known, so the mod starts from 5 minutes there. Managed settings are not readable from a mod.
On top of that, while the lifetime is the account's default (row 6), the mod watches the traffic: a request that hits the cache more than 5 minutes after the previous one proves the 1-hour lifetime (a later miss does not undo it, since a changed prefix looks the same), and a miss 5 to 60 minutes after the previous request, with the same model and a prompt that did not shrink, says the entry lapsed, so 5 minutes (a later hit overrules it). That covers what the mod cannot see, such as a gateway that rewrites the TTL. What the traffic showed is dropped when the account's default changes, so a subscription that runs out of plan usage mid-session counts down the 5 minutes of usage credits again, and a later hit on an entry written before the change proves nothing; a lifetime pinned by rows 1 to 5 is never overruled. The pane header names the source in use.
Why the mod infers instead of reading it: the API names the TTL of each write (cache_creation.ephemeral_5m_input_tokens / ephemeral_1h_input_tokens) and Claude Code's status line exposes it as prompt_cache.ttl, but the mod API passes on only the four token counts. To check by hand, claude -p "hello" --output-format json and read usage.cache_creation.
Other switches read from the environment at session start:
| Variable | Effect on the meter |
|---|---|
DISABLE_PROMPT_CACHING=1 (and _HAIKU, _SONNET, _OPUS) | the band says caching is off for that model |
turn.start: notes the session's cost and the plan windows as last read, so the turn's share can show (the main loop's turns only: a subagent's runs inside one)turn.step: each main-loop request's cache usage (subagents have their own prefixes and are left out)tool.call: counts tools and skills, and whether a call failedsession.end: a /clear or a resume starts the meter, the /cache table and the activity over, and the clock runs on$.clock.every(1000): the countdown and the model; context and plan windows are re-read every 5 seconds, git status every 30, the GitHub quota every 2 minutes$.process.run: git --no-optional-locks status --porcelain -b (so it never takes the index lock from your own git commands) and gh api rate_limit, both in the background: no hook waits on them, the HUD draws the last reading and redraws when a new one landsui.render on AbovePrompt (the HUD), on PromptHint (the HUD under the prompt, with position: below) and on Pane (/cache); the HUD gives way to a survey and to the open /cache pane$.ui.toast: at the warning threshold (60 s by default) and at 10, 3, 2 and 1 seconds left, for prompts of 20k tokens or moreRequires Claude Code 2.1.287 or later (mods on by default).
/plugin marketplace add audichuang/mods
/plugin install prompt-glance@audi-mods
It loads as prompt-glance@audi-mods in every session. To try it for one session instead: claude --plugin-dir /path/to/prompt-glance.
It does not replace your statusLine setting; if you used a status line for the same figures, remove it from ~/.claude/settings.json to avoid showing them twice.
To check a change, run the repository's gate from its root: bash scripts/check.sh validates every plugin (claude plugin validate --strict), runs this mod's tests (claude plugin test prompt-glance) and type-checks it.
Set them in the /plugin menu (prompt-glance's options), or in user settings (~/.claude/settings.json), --settings <file> or managed settings:
{ "pluginConfigs": { "prompt-glance@audi-mods": { "options": { "ttl": "auto" } } } }
ttl: string "auto" | "5m" | "1h" (default auto)
position: string "above" | "below": the band over the prompt, or the hint line under it, right of the mode label (default above)
warnSeconds: number countdown threshold for the warning state and the first toast (default 60)
compactAtTokens: number prompt size from which an expired cache suggests /compact (default 100000)
band: boolean the HUD above the prompt (default true)
status: boolean also a short entry under the prompt, "cache 98% · 3:41" (default false)
toast: boolean toasts as the cache lapses (default true)
Grew out of prompt-cache-control from claude-code-templates by Daniel (San) Ávila, MIT licensed: the cache accounting, TTL rules and /cache pane come from there. The HUD layout borrows its contents from claude-hud.
MIT
hooks/glance.tsx 895 lines1/**
2 * prompt-glance — Claude Mod (grew out of prompt-glance from davila7/claude-code-templates)
3 *
4 * A prompt-cache meter for Claude Code. Every main-loop request reports how
5 * many prompt tokens the cache served (`cache_read_input_tokens`), wrote
6 * (`cache_creation_input_tokens`) and sent uncached (`input_tokens`); this mod
7 * keeps those per request and per turn, counts down to the moment the cache
8 * lapses, and says what to do about it: keep going, /compact or /clear.
9 *
10 * - `turn.step` reads each main-loop request's usage (subagents have their
11 * own prefixes and are left out)
12 * - `$.clock.every(1000)` redraws the countdown, and only while its text
13 * changes: an idle, expired session costs nothing
14 * - a row above the prompt (the AbovePrompt component), an optional status
15 * line entry, and `/cache`, a pane with one row per turn
16 *
17 * The lifetime is counted from the start of the request that last wrote or read
18 * the cache, as Anthropic documents it. Which lifetime Claude Code asked for
19 * follows its documented rules (see decideTtl in ./cache.ts): FORCE_PROMPT_CACHING_5M,
20 * CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H,
21 * then the account (1 hour on a Claude subscription, 5 minutes otherwise). The
22 * API names the TTL of a write but the mod API passes on only the token counts,
23 * so while the lifetime is the account's default the mod also watches the gaps
24 * between requests (a hit after more than 5 minutes proves 1 hour; see
25 * observeTtl), and drops what it saw when that default changes. `ttl: "5m" | "1h"`,
26 * the environment variables and the setting pin it.
27 *
28 * Needs Claude Code >= 2.1.287.
29 *
30 * Options (the /plugin menu, or pluginConfigs["prompt-glance@audi-mods"].options):
31 * ttl: "auto" | "5m" | "1h" cache lifetime (default auto)
32 * position: "above" | "below" the band over the prompt, or the hint line under it (default above)
33 * warnSeconds: number countdown threshold for the warning (default 60)
34 * compactAtTokens: number prompt size that makes an expired cache suggest /compact (default 100000)
35 * band: boolean row above the prompt (default true)
36 * status: boolean entry under the prompt (default false)
37 * toast: boolean toasts near expiry: at warnSeconds, then 10, 3, 2 and 1 s (default true)
38 */
39import type { EngineInterface, Register, ResolveInput } from 'claude-code'
40import {
41 advise,
42 COUNTDOWN_MARKS,
43 byTurn,
44 fit,
45 fmtClock,
46 fmtTokens,
47 hitRatio,
48 isCachingDisabled,
49 accountOf,
50 cells,
51 addToTotals,
52 emptyTotals,
53 totalsHit,
54 decideTtl,
55 isAccountDefault,
56 observeTtl,
57 lifeColor,
58 lifeRatio,
59 nextToastMark,
60 positive,
61 promptTokens,
62 remainingMs,
63 rowRatio,
64 segments,
65} from './cache'
66import type { Account, Advice, BandPart, BandSeg, CacheEnv, Sample, SessionTotals, Ttl } from './cache'
67import { deltaColor, fmtPct, fmtShort, fmtUsd, heat, turnSpend, heatGauge, lineGauge, modelColor, parseGitStatus, parseRateLimit, prettyModel, SCALE, shortAdvice, shortPath, topTools } from './hud'
68import type { GhQuota, ToolTally } from './hud'
69
70const PANE = 'cache'
71const COMMAND = 'cache'
72const KEEP = 200
73// below this a lapsed cache costs too little to interrupt anyone about
74const TOAST_MIN_TOKENS = 20_000
75
76// the lifetime Claude Code asks for, and why, before any request timing says otherwise
77let baseTtl: Ttl = '5m'
78let envSource = 'default'
79// whether that came from the account's default, the only lifetime request timing may overrule
80let inferable = true
81// the ttl option, as the config menu set it
82let ttlOption: unknown
83let setting: Ttl | undefined
84let account: Account = 'other'
85let env: CacheEnv = {}
86let timer: { cancel: () => void } | undefined
87let isPaneOpen = false
88
89// the HUD's session figures, read off the engine every few seconds and drawn from here
90type Window = { pct: number; resetsAt?: number }
91let model = ''
92let cwd = ''
93let home: string | undefined
94let git: { branch: string; dirty: boolean; ahead: number; behind: number } | undefined
95let ctxPct: number | undefined
96// each usage read is numbered (asked); one answered after a newer one has landed (applied) is dropped,
97// so a slow read cannot undo it
98const usageReads = { asked: 0, applied: 0 }
99let five: Window | undefined
100let week: Window | undefined
101// git status: one running at a time (busy), and when the last one started
102const gitReads = { busy: false, startedAt: 0 }
103// the account's GitHub REST quota (rate_limit itself costs none of it); stale: the last check failed,
104// so the reading on show is an old one
105let ghReading: { quota?: GhQuota; stale: boolean } = { stale: false }
106let ghBin: string | null | undefined
107const GH_EVERY_TICKS = 120
108const GH_JQ = '[.resources.core.remaining, .resources.core.limit, .resources.core.reset] | @tsv'
109// $.process.run takes no shell, and the Claude process's PATH may lack Homebrew's bin
110const GH_PATHS = ['gh', '/opt/homebrew/bin/gh', '/home/linuxbrew/.linuxbrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'] as const
111// one line per change of why the HUD is or isn't drawn, newest HUD_LOG_MAX kept
112const HUD_LOG = '.local/state/prompt-glance/hud.log'
113const HUD_LOG_MAX = 500
114let lastNote = ''
115// a plan window's reset time shows only from here up
116const LOUD = 50
117// cells between the band's parts: room to breathe
118const GAP = 2
119// the /cache pane's rows drawn around the turn table, so the table takes what is left:
120// always the title 1, the clock 2, the advice 2, the table's head 2 and the footer 6
121const PANE_FIXED_ROWS = 1 + 2 + 2 + 2 + 6
122// the life bar under the clock, while it counts down
123const PANE_LIFE_BAR_ROWS = 1
124// once a request has landed: its model line under the advice, and the stack 3
125const PANE_LAST_REQUEST_ROWS = 1 + 3
126// the table shows at least this many turns, however short the pane
127const PANE_MIN_TABLE_ROWS = 3
128
129/** A main-loop request, and the lifetime generation (Conversation.ttlGen) in force when it started. */
130type Request = Sample & { ttlGen: number }
131
132/** What belongs to one conversation: /clear and a resume start it over, the account's figures stay. */
133type Conversation = {
134 // the last KEEP main-loop requests, each with the lifetime generation it started under
135 samples: Request[]
136 // the whole conversation's reads, writes, uncached input and misses, beyond the KEEP samples
137 totals: SessionTotals
138 // each turn's number in the conversation, counted apart from the samples
139 turnNo: Map<string, number>
140 // the lifetime in force and why: what the requests' timing showed (observed), else baseTtl
141 ttl: Ttl
142 ttlSource: string
143 observed: Ttl | undefined
144 // counts the changes of the lifetime Claude Code asks for. An entry a request wrote lives by the lifetime of the
145 // generation the request started in, so a hit on it says nothing about a later one
146 ttlGen: number
147 // what the clock last drew, so it redraws only on a change
148 lastKey: string
149 // the countdown toasts: the request whose cache entry they count down, and the mark last fired
150 toast: { startedAt: number; level: number }
151 // the tools the main loop ran, and the skills it loaded by name, with how often
152 tally: ToolTally
153 skills: Map<string, number>
154 // what the conversation has cost so far, and the figures at the start of the current turn, so the turn's share shows
155 costUsd: number | undefined
156 turnBase: { usd?: number; five?: number; week?: number } | undefined
157}
158
159const startConversation = (): Conversation => ({
160 samples: [],
161 totals: emptyTotals(),
162 turnNo: new Map(),
163 ttl: baseTtl,
164 ttlSource: envSource,
165 observed: undefined,
166 ttlGen: 0,
167 lastKey: '',
168 toast: { startedAt: 0, level: Infinity },
169 tally: new Map(),
170 skills: new Map(),
171 costUsd: undefined,
172 turnBase: undefined,
173})
174
175let conv = startConversation()
176
177/**
178 * Re-reads the session's figures (git, usage, the model), re-picks the cache
179 * lifetime from them and redraws. No hook awaits it: the HUD draws the latest
180 * reading and redraws when a new one lands. git status runs apart, one at a
181 * time, so a slow or locked repo holds back neither a hook nor the other figures.
182 */
183async function rereadAndRedraw($: EngineInterface, gitNow = false): Promise<void> {
184 readGitStatus($, gitNow)
185 await readUsageFigures($)
186 model = prettyModel(await $.session.model().catch(() => model))
187 recomputeTtl()
188 $.ui.invalidate('ui.render')
189}
190
191/** Cost, context and the plan windows, off the engine (no git): quick enough for turn.start to wait on. */
192async function readUsageFigures($: EngineInterface) {
193 const n = ++usageReads.asked
194 const u = await $.session.usage().catch(() => undefined)
195 if (u && n > usageReads.applied) {
196 usageReads.applied = n
197 ctxPct = u.context.percent
198 conv.costUsd = u.cost?.usd
199 const win = (kind: string): Window | undefined => {
200 const r = u.rateLimits.find(l => l.kind === kind)
201 return r ? { pct: r.percentUsed, resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : undefined } : undefined
202 }
203 five = win('five_hour')
204 week = win('seven_day')
205 // the account can change under a session: a subscription running out of plan usage moves to usage credits
206 account = accountOf(u.rateLimits)
207 }
208}
209
210/** git status in the background, one at a time and at most every 30 s unless `force`d. */
211function readGitStatus($: EngineInterface, force: boolean) {
212 if (gitReads.busy || (!force && Date.now() - gitReads.startedAt <= 30_000)) return
213 gitReads.busy = true
214 gitReads.startedAt = Date.now()
215 // --no-optional-locks: a status run in the background must not take index.lock from the agent's own git commands
216 void $.process
217 .run(['git', '--no-optional-locks', 'status', '--porcelain', '-b'], { timeoutMs: 10_000 })
218 .then(
219 r => {
220 git = r.exitCode === 0 ? parseGitStatus(r.stdout) : undefined
221 $.ui.invalidate('ui.render')
222 },
223 // timed out or could not start: the branch last read stays
224 () => undefined,
225 )
226 .finally(() => {
227 gitReads.busy = false
228 })
229}
230
231/**
232 * Which lifetime Claude Code asks for now, from the option, the environment, the setting and the account.
233 * A change of it (a subscription out of plan usage moving to usage credits) outdates what the traffic showed.
234 */
235function recomputeTtl() {
236 const choice = decideTtl(ttlOption, env, setting, account)
237 if (choice.ttl !== baseTtl || choice.source !== envSource) {
238 conv.observed = undefined
239 conv.ttlGen += 1
240 }
241 baseTtl = choice.ttl
242 envSource = choice.source
243 inferable = isAccountDefault(choice)
244 if (conv.observed === undefined) {
245 conv.ttl = baseTtl
246 conv.ttlSource = envSource
247 }
248}
249
250/** A conversation of its own: its cache, its requests, its turns and its activity start over. */
251function newConversation() {
252 conv = startConversation()
253 // a usage reading still on its way is the old conversation's
254 usageReads.applied = usageReads.asked
255}
256
257/** The account's GitHub REST quota, through gh: found once on the usual paths, then asked each time. */
258async function readGhQuota($: EngineInterface) {
259 if (ghBin === undefined) {
260 ghBin = null
261 for (const bin of GH_PATHS) {
262 const ran = await $.process.run([bin, '--version'], { timeoutMs: 5_000 }).catch(() => undefined)
263 if (ran?.exitCode === 0) {
264 ghBin = bin
265 break
266 }
267 }
268 }
269 if (ghBin === null) return
270 const ran = await $.process.run([ghBin, 'api', 'rate_limit', '--jq', GH_JQ], { timeoutMs: 15_000 }).catch(() => undefined)
271 // exit 4, or gh asking for `gh auth login`: no token at all, which the gauge says as it does for the anonymous 60
272 const noLogin = ran !== undefined && (ran.exitCode === 4 || /gh auth login/.test(`${ran.stdout}\n${ran.stderr}`))
273 const quota: GhQuota | undefined = ran?.exitCode === 0 ? parseRateLimit(ran.stdout) : noLogin ? { anon: true } : undefined
274 const old = ghReading.quota
275 if (quota) ghReading = { quota, stale: false }
276 else if (old) {
277 // a failed or timed-out check keeps the last reading, marked as old, until its window resets
278 ghReading = { quota: !old.anon && Date.now() >= old.resetsAt ? undefined : old, stale: true }
279 }
280}
281
282/** Appends `event` to hud.log when it differs from the last line written; never throws. */
283async function note($: EngineInterface, event: string) {
284 if (event === lastNote || home === undefined) return
285 lastNote = event
286 const path = `${home}/${HUD_LOG}`
287 const before = await $.fs.read(path).catch(() => '')
288 const kept = [...before.split('\n').filter(l => l !== ''), `${new Date().toISOString()}\t${event}`].slice(-HUD_LOG_MAX)
289 await $.fs.write(path, kept.join('\n') + '\n').catch(() => undefined)
290}
291
292type Policy = { warnMs: number; compactAtTokens: number }
293
294// cells a group of parts takes, GAP between them
295const groupCells = (parts: readonly BandPart[]) => parts.reduce((n, p) => n + cells(p.text), 0) + Math.max(0, parts.length - 1) * GAP
296
297/** Whether a `[left, right]` row fits in `width`, its two groups at least GAP apart. */
298function fitsRow([l, r]: [BandPart[], BandPart[]], width: number): boolean {
299 return groupCells(l) + (r.length ? GAP + groupCells(r) : 0) <= width
300}
301
302// stacked rows pad their labels to the longest, so the gauges line up
303const LABEL_PAD = 'Context'.length
304
305function current(policy: Policy, now: number) {
306 const last = conv.samples[conv.samples.length - 1]
307 const prev = conv.samples[conv.samples.length - 2]
308 const disabled = last ? isCachingDisabled(last.model, env) : isCachingDisabled('', env)
309 const advice: Advice = advise(last, prev, { ttl: conv.ttl, ...policy }, now, disabled)
310 const left = last ? remainingMs(last, conv.ttl, now) : 0
311 return { last, advice, left }
312}
313
314const COLOR: Record<Advice['kind'], string | undefined> = {
315 warm: 'green',
316 soon: 'yellow',
317 expired: 'red',
318 miss: 'red',
319 off: undefined,
320 cold: undefined,
321 uncached: undefined,
322}
323
324function shortLine(policy: Policy, now: number): string {
325 const { last, advice, left } = current(policy, now)
326 if (!last || advice.kind === 'off') return `cache: ${advice.text}`
327 const clock = left > 0 ? ` · ${fmtClock(left)}` : ''
328 return `cache ${Math.round(hitRatio(last) * 100)}%${clock}`
329}
330
331// the promptCacheTtl setting as the engine merges it (policy, --settings, CLAUDE_CONFIG_DIR's user file, project, local)
332async function readSetting($: EngineInterface): Promise<Ttl | undefined> {
333 const value = await $.settings.read({ key: 'promptCacheTtl' }).catch(() => undefined)
334 return value === '5m' || value === '1h' ? value : undefined
335}
336
337// The HUD's rows for a site `columns` wide, built from that site's element table.
338function drawHud($: EngineInterface, e: ResolveInput, columns: number, policy: Policy) {
339 const now = Date.now()
340 const { last, advice, left } = current(policy, now)
341 const { Box, Text } = $.ui.resolve(e)
342 const part = (key: string, segs: BandSeg[]): BandPart => ({ key, text: segs.map(g => g.text).join(''), segs })
343 const label = (text: string, pad = 0): BandSeg => ({ text: `${text.padEnd(pad)} `, color: 'gray' })
344
345 // row one, left: who and where
346 const who = part('model', [{ text: model || 'Claude', color: modelColor(model), bold: true }])
347 const where = (levels?: number) => part('path', [{ text: shortPath(cwd, home, levels), color: 'blue' }])
348 const branch: BandPart[] = git
349 ? [
350 part('git', [
351 { text: 'git:(', color: 'gray' },
352 { text: git.branch, color: 'cyan' },
353 ...(git.dirty ? [{ text: '*', color: 'yellow', bold: true }] : []),
354 { text: ')', color: 'gray' },
355 ...(git.ahead ? [{ text: ` ↑${git.ahead}`, color: 'green' }] : []),
356 ...(git.behind ? [{ text: ` ↓${git.behind}`, color: 'red' }] : []),
357 ]),
358 ]
359 : []
360
361 // row one, right: the cache, its hit rate and how long it stays warm
362 const state = shortAdvice(advice, last ? fmtTokens(promptTokens(last)) : undefined)
363 // the session's hit rate and misses: the last request alone sits near 100% in any long conversation
364 const sessionHitRatio = totalsHit(conv.totals)
365 const misses = conv.totals.misses
366 const ratio = sessionHitRatio
367 const counting = !!last && advice.kind !== 'uncached' && advice.kind !== 'off'
368 const lifeC = counting ? (left > 0 ? lifeColor(left, conv.ttl, policy.warnMs) : 'red') : undefined
369 const hitC = ratio >= 0.8 ? 'green' : ratio >= 0.4 ? 'yellow' : 'red'
370 // ● Cache 1h ████████████ 59m hit 76% misses 1 (the dot and the bar drain green, yellow, red; ○ cold once lapsed)
371 const cache = (width: number, words = true, pad = 0): BandPart[] => {
372 const lapsed = advice.kind === 'expired'
373 const dot: BandSeg = counting && !lapsed ? { text: '● ', color: lifeC } : { text: '○ ', color: lapsed ? 'red' : 'gray' }
374 const segs: BandSeg[] = [dot, label('Cache', pad)]
375 if (lapsed) segs.push({ text: 'cold', color: 'red', bold: true })
376 else if (counting) {
377 segs.push({ text: `${conv.ttl} `, color: lifeC })
378 if (width > 0) segs.push(...lineGauge(lifeRatio(left, conv.ttl), width, lifeC), { text: ' ' })
379 segs.push({ text: fmtShort(left), color: lifeC, bold: true })
380 }
381 if (last) {
382 segs.push({ text: ' hit ', color: 'gray' }, { text: fmtPct(sessionHitRatio), color: hitC, bold: true })
383 if (misses > 0) segs.push({ text: ' misses ', color: 'gray' }, { text: String(misses), color: misses > 2 ? 'red' : 'yellow', bold: true })
384 }
385 if (state.text && (words || !last)) segs.push({ text: ` ${state.text}`, color: COLOR[advice.kind] ?? 'gray', bold: advice.kind !== 'cold' })
386 return [part('cache', segs)]
387 }
388
389 // row two, left: the context window and the plan windows
390 const meter = (key: string, name: string, pct: number | undefined, width: number, scale: readonly number[], resetsAt?: number, pad = 0): BandPart[] => {
391 if (pct === undefined) return []
392 const color = heat(pct, scale)
393 const reset = pct >= LOUD && resetsAt ? [{ text: ` ↻${fmtShort(resetsAt - now)}`, color: 'gray' }] : []
394 return [part(key, [label(name, pad), ...heatGauge(pct, width, scale), { text: ` ${Math.round(pct)}%`, color, bold: pct >= (scale[2] ?? Infinity) }, ...reset])]
395 }
396 // GitHub's REST quota used, like the plan windows; no token says so instead of showing the anonymous 60
397 // an old reading (the last check failed) carries a grey ?, and says nothing once its window has reset
398 const gh = (width: number, pad = 0): BandPart[] => {
399 const { quota, stale } = ghReading
400 if (!quota) return []
401 if (quota.anon) return [part('gh', [label('GH', pad), { text: '未登入', color: 'gray' }])]
402 if (!stale) return meter('gh', 'GH', quota.pct, width, SCALE.window, quota.resetsAt, pad)
403 if (now >= quota.resetsAt) return []
404 return meter('gh', 'GH', quota.pct, width, SCALE.window, undefined, pad).map(p => part(p.key, [...(p.segs ?? []), { text: ' ?', color: 'gray' }]))
405 }
406 const gauges = (width: number, withWeek = true) => [
407 ...meter('ctx', 'Context', ctxPct, width, SCALE.context),
408 ...meter('5h', '5h', five?.pct, width, SCALE.window, five?.resetsAt),
409 ...(withWeek ? meter('7d', 'Week', week?.pct, width, SCALE.window, week?.resetsAt) : []),
410 ...gh(width),
411 ]
412
413 // row two, right: the tools the main loop ran
414 // the activity: tools by use (skills apart), then the skills by name
415 const toolsOnly: ToolTally = new Map([...conv.tally].filter(([name]) => name !== 'Skill'))
416 const tools = (n: number, pad = 0): BandPart[] => {
417 const list = topTools(toolsOnly, n)
418 if (list.length === 0) return []
419 const failed = [...toolsOnly.values()].reduce((sum, c) => sum + c.failed, 0)
420 return [
421 part('tools', [
422 label('Tools', pad),
423 ...list.flatMap(([name, ok, bad], i) => [{ text: `${i ? ' ' : ''}${name}` }, { text: `×${ok + bad}`, color: 'gray' }]),
424 ...(failed > 0 ? [{ text: ` ✗${failed}`, color: 'red', bold: true }] : []),
425 ]),
426 ]
427 }
428 const skillList = (n: number, pad = 0): BandPart[] => {
429 const list = [...conv.skills].sort((a, b) => b[1] - a[1]).slice(0, n)
430 if (list.length === 0) return []
431 return [
432 part('skills', [
433 label('Skills', pad),
434 ...list.flatMap(([name, count], i) => [{ text: `${i ? ' ' : ''}${name}`, color: 'magenta' }, ...(count > 1 ? [{ text: `×${count}`, color: 'gray' }] : [])]),
435 ...(conv.skills.size > n ? [{ text: ` +${conv.skills.size - n}`, color: 'gray' }] : []),
436 ]),
437 ]
438 }
439 // as many tools and skills as fit, each on a row of its own
440 const activityRows = (pad: number): [BandPart[], BandPart[]][] => {
441 const out: [BandPart[], BandPart[]][] = []
442 for (const make of [tools, skillList]) {
443 const fit = [6, 4, 3, 2, 1].map(n => make(n, pad)).find(p => p.length === 0 || fitsRow([p, []], columns))
444 if (fit && fit.length) out.push([fit, []])
445 }
446 return out
447 }
448
449 // what the current (or last) turn spent: dollars, and its share of the 5-hour window; the session's total beside it
450 const spent = turnSpend(conv.turnBase, conv.costUsd, five?.pct, week?.pct)
451 const spend = (withSession: boolean, pad = 0): BandPart[] => {
452 if (!spent) return []
453 const segs: BandSeg[] = [label('Turn', pad)]
454 if (spent.usd !== undefined) segs.push({ text: fmtUsd(spent.usd), color: '#c0caf5', bold: true })
455 if (spent.five !== undefined) segs.push({ text: `${segs.length > 1 ? ' ' : ''}+${spent.five.toFixed(1)}%`, color: deltaColor(spent.five) })
456 if (segs.length === 1) return []
457 if (withSession && conv.costUsd !== undefined) segs.push({ text: ' ' }, label('Session'), { text: fmtUsd(conv.costUsd), color: 'gray' })
458 return [part('spend', segs)]
459 }
460
461 // wide: two rows, a left and a right group each, when nothing has to go
462 const wideTop: [BandPart[], BandPart[]][] = [
463 [[who, where(), ...branch], [...spend(true), ...cache(12)]],
464 [[who, where(3), ...branch], [...spend(true), ...cache(12)]],
465 [[who, where(1), ...branch], [...spend(false), ...cache(12)]],
466 [[who, where(1), ...branch], [...spend(false), ...cache(10)]],
467 [[who, where(1), ...branch], cache(10)],
468 ]
469 // the activity rides at the right of the gauges when it fits there whole, else takes rows of its own
470 const activity = [...tools(4), ...skillList(2)]
471 const wideBottom: [BandPart[], BandPart[]][] = [
472 [gauges(12), activity],
473 [gauges(12), []],
474 [gauges(10), activity],
475 [gauges(10), []],
476 ]
477 let rows: [BandPart[], BandPart[]][]
478 const top = wideTop.find(v => fitsRow(v, columns))
479 const bottom = wideBottom.find(v => fitsRow(v, columns))
480 if (top && bottom) {
481 rows = [top, bottom]
482 if (bottom[1].length === 0) rows.push(...activityRows(0))
483 } else {
484 // narrow: stack instead of dropping, labels padded so the gauges line up
485 rows = []
486 const head = [[who, where(), ...branch], [who, where(3), ...branch], [who, where(1), ...branch]].find(l => fitsRow([l, []], columns))
487 if (head) rows.push([head, []])
488 else rows.push([[who], []], [[where(1), ...branch], []])
489 const pad = LABEL_PAD
490 const cacheRow = [cache(12, true, pad), cache(10, true, pad), cache(8, true, pad), cache(6, false, pad)].find(c => fitsRow([c, []], columns)) ?? cache(0, false, pad)
491 rows.push([cacheRow, []])
492 const spendRow = [spend(true, pad), spend(false, pad)].find(p => p.length && fitsRow([p, []], columns))
493 if (spendRow) rows.push([spendRow, []])
494 const width = [12, 10, 8].find(w => fitsRow([meter('ctx', 'Context', 100, w, SCALE.context, undefined, pad), []], columns)) ?? 6
495 let line: BandPart[] = []
496 for (const g of [
497 ...meter('ctx', 'Context', ctxPct, width, SCALE.context, undefined, pad),
498 ...meter('5h', '5h', five?.pct, width, SCALE.window, five?.resetsAt, pad),
499 ...meter('7d', 'Week', week?.pct, width, SCALE.window, week?.resetsAt, pad),
500 ...gh(width, pad),
501 ]) {
502 if (line.length && !fitsRow([[...line, g], []], columns)) {
503 rows.push([line, []])
504 line = []
505 }
506 line.push(g)
507 }
508 if (line.length) rows.push([line, []])
509 // too narrow for the activity: the essentials only, with room around them
510 }
511
512 const group = (key: string, parts: BandPart[]) => (
513 <Box key={key} flexDirection="row" columnGap={GAP} flexShrink={0}>
514 {parts.map(p => (
515 <Box key={p.key} flexDirection="row" flexShrink={0}>
516 {(p.segs ?? [{ text: p.text, color: p.color }]).map((g, j) => (
517 <Text key={`${p.key}:${j}`} color={g.color} backgroundColor={g.backgroundColor} bold={g.bold} dimColor={g.dim}>{g.text}</Text>
518 ))}
519 </Box>
520 ))}
521 </Box>
522 )
523 const row = (key: string, [l, r]: [BandPart[], BandPart[]]) => (
524 <Box key={key} flexDirection="row" flexWrap="nowrap">
525 {group(`${key}:l`, l)}
526 {r.length ? <Box key={`${key}:gap`} flexGrow={1} minWidth={GAP} /> : null}
527 {r.length ? group(`${key}:r`, r) : null}
528 </Box>
529 )
530 return { Box, Text, rows: rows.map((r, i) => row(`row${i}`, r)) }
531}
532
533export const register: Register = (on, options) => {
534 const policy: Policy = {
535 warnMs: positive(options.warnSeconds, 60) * 1000,
536 compactAtTokens: positive(options.compactAtTokens, 100_000),
537 }
538 const showBand = options.band !== false
539 const showStatus = options.status === true
540 const wantToast = options.toast !== false
541 // above the prompt unless asked for under it: the hint line sits beside the mode pill, so the HUD lands to its right
542 const below = options.position === 'below'
543
544 ttlOption = options.ttl
545
546 on('session.start', async ($, e, next) => {
547 const r = await next(e)
548 newConversation()
549 // a hot reload (/reload-plugins, a config change) starts the module over with the /cache pane still up:
550 // the engine's record says whether it is, so the band keeps giving way to it
551 const panes = await $.ui.panes().catch(() => undefined)
552 if (panes) isPaneOpen = panes.some(p => p.id === PANE)
553 const none = () => undefined
554 env = {
555 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
556 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
557 ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
558 disableAll: await $.env.get('DISABLE_PROMPT_CACHING').catch(none),
559 disableHaiku: await $.env.get('DISABLE_PROMPT_CACHING_HAIKU').catch(none),
560 disableSonnet: await $.env.get('DISABLE_PROMPT_CACHING_SONNET').catch(none),
561 disableOpus: await $.env.get('DISABLE_PROMPT_CACHING_OPUS').catch(none),
562 }
563 setting = await readSetting($)
564 recomputeTtl()
565
566 await $.command
567 .register({
568 name: COMMAND,
569 description: 'Prompt-cache usage per turn and the time left before it lapses (stop closes)',
570 argumentHint: '[stop]',
571 immediate: true,
572 })
573 .catch(err => $.ui.log(`prompt-glance: /${COMMAND} not registered: ${err}`))
574
575 cwd = e.cwd
576 home = await $.env.get('HOME').catch(() => undefined)
577 // the account (and with it the lifetime) is known once the first reading lands
578 void rereadAndRedraw($, true).then(() => $.ui.log(`prompt-glance loaded: ${conv.ttl} cache (${conv.ttlSource}), /${COMMAND} opens the table`, { to: 'debug' }))
579 void note($, `start ${e.isInteractive ? 'interactive' : 'headless'} ${below ? 'below' : 'above'}`)
580 if (e.isInteractive) void readGhQuota($).then(() => $.ui.invalidate('ui.render'))
581
582 let ticks = 0
583 timer?.cancel()
584 timer = $.clock.every(1000, () => {
585 if (e.isInteractive && ticks % GH_EVERY_TICKS === GH_EVERY_TICKS - 1) void readGhQuota($).then(() => $.ui.invalidate('ui.render'))
586 if (++ticks % 5 === 0) void rereadAndRedraw($)
587 // the model every second: a /model switch shows at once, not on the next request
588 else
589 void $.session
590 .model()
591 .then(m => {
592 const pretty = prettyModel(m)
593 if (pretty !== model) {
594 model = pretty
595 $.ui.invalidate('ui.render')
596 }
597 })
598 .catch(() => undefined)
599 const now = Date.now()
600 const { last, advice, left } = current(policy, now)
601 const key = `${advice.kind}|${advice.text}|${left > 0 ? fmtClock(left) : ''}`
602 if (key !== conv.lastKey) {
603 conv.lastKey = key
604 if (showStatus) $.ui.status(shortLine(policy, now))
605 $.ui.invalidate('ui.render')
606 }
607 if (wantToast && last && left > 0 && promptTokens(last) >= TOAST_MIN_TOKENS) {
608 if (conv.toast.startedAt !== last.startedAt) {
609 conv.toast.startedAt = last.startedAt
610 conv.toast.level = Infinity
611 }
612 // the first toast comes at warnSeconds, then 10, 3, 2 and 1 seconds; a late tick skips to the newest one
613 const secs = Math.ceil(left / 1000)
614 const mark = nextToastMark(secs, policy.warnMs / 1000, conv.toast.level)
615 if (mark !== undefined) {
616 conv.toast.level = mark
617 const tail = secs <= COUNTDOWN_MARKS[0] ? 'send a message now' : `send a message to keep ${fmtTokens(promptTokens(last))} tokens warm`
618 $.ui.toast(`cache expires in ${secs >= 60 ? fmtClock(left) : `${secs}s`}: ${tail}`)
619 }
620 }
621 })
622 return r
623 })
624
625 on('session.end', async ($, e, next) => {
626 void note($, `end ${e.reason}`)
627 // /clear and a resume carry on in this process with another conversation, and another cache: start over, and
628 // keep the clock (no session.start follows). An exit, a logout or the end of a -p run takes the clock with the process.
629 const processGoesOn = e.reason !== 'prompt_input_exit' && e.reason !== 'logout' && e.reason !== 'other'
630 if (processGoesOn) {
631 newConversation()
632 $.ui.invalidate('ui.render')
633 }
634 const r = await next(e)
635 // read once the engine is done with the old conversation
636 if (processGoesOn) void rereadAndRedraw($)
637 return r
638 })
639
640 // each main-loop request: what the cache did with it
641 // a new turn: remember where cost and the plan windows stand, so the HUD can show what this turn spends
642 on('turn.start', async ($, e, next) => {
643 // a subagent's turn runs inside the main one, whose baseline stays
644 if (e.agentId !== undefined) return next(e)
645 // a reading taken now, not the cached one: that may predate the last turn's spending or a /clear.
646 // Only the engine's usage call is awaited; git stays in the background
647 await readUsageFigures($)
648 const r = await next(e)
649 conv.turnBase = { usd: conv.costUsd, five: five?.pct, week: week?.pct }
650 void rereadAndRedraw($)
651 $.ui.invalidate('ui.render')
652 return r
653 })
654
655 on('turn.step', async function* ($, e, next) {
656 if (e.agentId) return yield* next(e)
657 const startedAt = await $.clock.now().catch(() => Date.now())
658 // the lifetime generation the request starts in: one still streaming when the lifetime changes wrote by the old one
659 const startConv = conv
660 const startGen = conv.ttlGen
661 const r = yield* next(e)
662 if (r.usage) {
663 const prev = conv.samples[conv.samples.length - 1]
664 const cur: Request = {
665 // a request that started in another conversation (/clear mid-stream) is no one's evidence
666 ttlGen: conv === startConv ? startGen : -1,
667 turnId: e.turnId,
668 index: e.index,
669 model: r.usage.model || e.model,
670 startedAt,
671 read: r.usage.cache_read_input_tokens,
672 write: r.usage.cache_creation_input_tokens,
673 fresh: r.usage.input_tokens,
674 output: r.usage.output_tokens,
675 }
676 conv.totals = addToTotals(conv.totals, prev, cur, conv.ttl)
677 conv.samples.push(cur)
678 if (conv.samples.length > KEEP) conv.samples = conv.samples.slice(-KEEP)
679 if (!conv.turnNo.has(e.turnId)) conv.turnNo.set(e.turnId, conv.turnNo.size + 1)
680 // the account as last read; the re-read below reads it again and recomputes
681 recomputeTtl()
682 // a lifetime pinned by the option, the environment or the setting is what Claude Code asks for: no guessing over it.
683 // Over the account's default, only two requests that both started under the lifetime in force now are evidence
684 if (inferable && prev?.ttlGen === conv.ttlGen && cur.ttlGen === conv.ttlGen) {
685 const seen = observeTtl(prev, cur, conv.observed)
686 if (seen !== conv.observed) {
687 conv.observed = seen
688 conv.ttl = seen ?? baseTtl
689 conv.ttlSource = `observed from request timing; ${envSource} said ${baseTtl}`
690 $.ui.log(`prompt-glance: cache lifetime is ${conv.ttl} (${conv.ttlSource})`, { to: 'debug' })
691 }
692 }
693 conv.lastKey = ''
694 void rereadAndRedraw($)
695 if (showStatus) $.ui.status(shortLine(policy, Date.now()))
696 $.ui.invalidate('ui.render')
697 }
698 return r
699 })
700
701 // every tool the main loop ran, and whether it failed, for the HUD's activity
702 on('tool.call', async ($, e, next) => {
703 const r = await next(e)
704 if (!e.agentId) {
705 const c = conv.tally.get(e.tool) ?? { ok: 0, failed: 0 }
706 if ('deny' in r && r.deny !== undefined) c.failed += 1
707 else if (r.isError) c.failed += 1
708 else c.ok += 1
709 conv.tally.set(e.tool, c)
710 const name = e.tool === 'Skill' ? (e as { skill?: unknown }).skill : undefined
711 if (typeof name === 'string' && name) conv.skills.set(name, (conv.skills.get(name) ?? 0) + 1)
712 $.ui.invalidate('ui.render')
713 }
714 return r
715 })
716
717 on('command.run', { command: COMMAND }, async ($, e) => {
718 if (e.args.trim().toLowerCase() === 'stop') {
719 await $.ui.close({ id: PANE }).catch(() => undefined)
720 isPaneOpen = false
721 return { text: 'cache table closed' }
722 }
723 isPaneOpen = true
724 await $.ui.open({ id: PANE, title: 'cache', focus: true })
725 $.ui.invalidate('ui.render')
726 const { advice } = current(policy, Date.now())
727 return { text: `${conv.ttl} cache (${conv.ttlSource}) · ${advice.text} · /${COMMAND} stop closes` }
728 })
729
730 on('ui.close', async ($, e, next) => {
731 if (e.id !== PANE) return next(e)
732 isPaneOpen = false
733 return next(e)
734 })
735
736 on('ui.press', async ($, e, next) => {
737 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
738 if (e.element === 'close') await $.ui.close({ id: PANE }).catch(() => undefined)
739 return next(e)
740 })
741
742 // Two rows above the prompt, each a left group and a right group pushed to the edges:
743 // Opus 5.5 ~/Project/mod git:(main*) Turn $0.42 +0.8% ● Cache 1h ████████████ 58m hit 99.6%
744 // Context ███▌████████ 22% 5h █▎██████████ 10% Week ▌███████████ 4% Tools Bash×3 Edit×1
745 // Labels are plain words; each gauge is a solid bar that warms from green to red along its length.
746 // When the row is short, the richest variant that fits wins, so it never wraps.
747 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
748 const yieldTo = !showBand ? 'off' : below ? undefined : e.props.hasSurvey ? 'survey' : isPaneOpen ? 'pane' : undefined
749 if (!showBand || below || e.props.hasSurvey || isPaneOpen) {
750 if (yieldTo) void note($, `above yields: ${yieldTo}`)
751 return next(e)
752 }
753 try {
754 // the band's own width, not the terminal's: the engine keeps five cells at the right end
755 const { Box, rows } = drawHud($, e, e.props.bodyColumns ?? (e.viewport?.columns ?? 100) - 5, policy)
756 void note($, 'above drawn')
757 return <Box flexDirection="column">{rows}</Box>
758 } catch (err) {
759 void note($, `above error: ${String(err).slice(0, 200)}`)
760 return next(e)
761 }
762 })
763
764 // Under the prompt: the HUD, then the engine's own hint line (bypass mode, esc to interrupt) as dim text.
765 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
766 if (!showBand || !below || isPaneOpen) {
767 if (below && showBand) void note($, 'below yields: pane')
768 return next(e)
769 }
770 try {
771 const { Box, Text, rows } = drawHud($, e, (e.viewport?.columns ?? 100) - 4, policy)
772 const hint = [e.props.hint, e.props.tail].filter(Boolean).join(' ')
773 void note($, 'below drawn')
774 return (
775 <Box flexDirection="column">
776 {rows}
777 {hint ? <Text key="hint" dimColor>{hint}</Text> : null}
778 </Box>
779 )
780 } catch (err) {
781 void note($, `below error: ${String(err).slice(0, 200)}`)
782 return next(e)
783 }
784 })
785
786 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
787 if (e.requestId !== PANE) return next(e)
788 const { Box, Text, Button } = $.ui.resolve(e)
789 const width = Math.max(30, e.props.bodyColumns - 1)
790 // HTML collapses runs of spaces and trims a text's ends; a no-break space keeps them
791 const sp = (t: string) => (e.surface === 'terminal' ? t : t.replace(/ /g, ' '))
792 const now = Date.now()
793 const { last, advice, left } = current(policy, now)
794 const all = byTurn(conv.samples)
795 const counting = !!last && advice.kind !== 'uncached' && advice.kind !== 'off'
796 // the countdown goes green, then yellow, then red as the cache runs out
797 const clockColor = counting ? lifeColor(left, conv.ttl, policy.warnMs) : undefined
798 const stateColor = advice.kind === 'expired' || advice.kind === 'miss' ? 'red' : (clockColor ?? COLOR[advice.kind])
799 const hitColor = (pct: number) => (pct >= 80 ? 'green' : pct >= 40 ? 'yellow' : 'red')
800 // solid bars are filled Boxes, not block characters, so HTML draws no seams between cells
801 const solid = (key: string, parts: [number, string | undefined][]) => (
802 <Box key={key} flexDirection="row" height={1} flexShrink={0}>
803 {parts.map(([w, c], i) => (w > 0 ? <Box key={`${key}:${i}`} width={w} height={1} flexShrink={0} backgroundColor={c} /> : null))}
804 </Box>
805 )
806 const cell = (key: string, w: number, text: string, c?: string, bold = false) => (
807 <Box key={key} width={w} flexShrink={0} justifyContent="flex-end">
808 <Text color={c} bold={bold} dimColor={!c}>{sp(text)}</Text>
809 </Box>
810 )
811
812 const barW = Math.min(width, 40)
813 const life = lifeRatio(left, conv.ttl)
814 const lifeFilled = Math.round(life * barW)
815 const [sr, sw, sn] = last ? segments(last.read, last.write, last.fresh, barW) : [0, 0, 0]
816 // the latest turns that fit in the pane's own room, after the rows drawn around the table
817 const around = PANE_FIXED_ROWS + (counting ? PANE_LIFE_BAR_ROWS : 0) + (last ? PANE_LAST_REQUEST_ROWS : 0)
818 const rows = all.slice(-Math.max(PANE_MIN_TABLE_ROWS, e.props.scroll.bodyRows - around))
819 const icon = advice.kind === 'warm' ? '●' : advice.kind === 'soon' ? '▲' : advice.kind === 'expired' || advice.kind === 'miss' ? '✖' : '○'
820
821 return (
822 <Box flexDirection="column">
823 <Box key="title" flexDirection="row" columnGap={1}>
824 <Text bold color="cyan">{sp('⚡ PROMPT CACHE')}</Text>
825 <Text dimColor>{sp(`· ${conv.ttl} lifetime (${conv.ttlSource})`)}</Text>
826 </Box>
827
828 <Box key="clock" flexDirection="column" marginTop={1}>
829 <Text bold color={clockColor}>{sp(counting ? `⏱ ${left > 0 ? fmtClock(left) : '0:00'}` : '⏱ --:--')}</Text>
830 {counting ? (
831 <Box flexDirection="row" columnGap={1}>
832 {solid('life', [[lifeFilled, clockColor], [barW - lifeFilled, 'gray']])}
833 <Text dimColor>{sp(`${Math.round(life * 100)}%`)}</Text>
834 </Box>
835 ) : null}
836 </Box>
837
838 <Box key="advice" marginTop={1} flexDirection="column">
839 <Text bold color={stateColor}>{sp(`${icon} ${advice.text}`)}</Text>
840 {last ? <Text dimColor>{sp(fit(`${last.model} · prompt ${fmtTokens(promptTokens(last))} tokens`, width))}</Text> : null}
841 </Box>
842
843 {last ? (
844 <Box key="stack" flexDirection="column" marginTop={1}>
845 <Box flexDirection="row" columnGap={1}>
846 {solid('stack', [[sr, 'green'], [sw, 'yellow'], [sn, 'cyan']])}
847 <Text bold color={hitColor(Math.round(hitRatio(last) * 100))}>{sp(`${Math.round(hitRatio(last) * 100)}% hit`)}</Text>
848 </Box>
849 <Box flexDirection="row" columnGap={2}>
850 <Text color="green">{sp(`■ read ${fmtTokens(last.read)}`)}</Text>
851 <Text color="yellow">{sp(`■ wrote ${fmtTokens(last.write)}`)}</Text>
852 <Text color="cyan">{sp(`■ new ${fmtTokens(last.fresh)}`)}</Text>
853 </Box>
854 </Box>
855 ) : null}
856
857 <Box key="table" flexDirection="column" marginTop={1}>
858 <Box key="head" flexDirection="row" columnGap={1}>
859 {cell('h:turn', 4, 'turn', 'cyan', true)}
860 {cell('h:steps', 5, 'steps', 'cyan', true)}
861 {cell('h:read', 6, 'read', 'green', true)}
862 {cell('h:wrote', 6, 'wrote', 'yellow', true)}
863 {cell('h:new', 5, 'new', 'cyan', true)}
864 {cell('h:hit', 4, 'hit', 'magenta', true)}
865 </Box>
866 {rows.length === 0 ? <Text dimColor>{sp('no requests yet')}</Text> : null}
867 {rows.map((row, i) => {
868 const n = conv.turnNo.get(row.turnId) ?? all.length - rows.length + i + 1
869 const pct = Math.round(rowRatio(row) * 100)
870 return (
871 <Box key={`t:${row.turnId}`} flexDirection="row" columnGap={1}>
872 {cell(`c:turn:${row.turnId}`, 4, String(n))}
873 {cell(`c:steps:${row.turnId}`, 5, String(row.steps))}
874 {cell(`c:read:${row.turnId}`, 6, fmtTokens(row.read), 'green')}
875 {cell(`c:wrote:${row.turnId}`, 6, fmtTokens(row.write), 'yellow')}
876 {cell(`c:new:${row.turnId}`, 5, fmtTokens(row.fresh), 'cyan')}
877 {cell(`c:hit:${row.turnId}`, 4, `${pct}%`, hitColor(pct), true)}
878 </Box>
879 )
880 })}
881 </Box>
882
883 <Box key="foot" marginTop={1} flexDirection="column">
884 <Button key="close" label="close" onPress={() => {}} />
885 <Box key="legend" marginTop={1} flexDirection="column">
886 <Text color="green">{sp('■ read: served by the cache')}</Text>
887 <Text color="yellow">{sp('■ wrote: new cache entry')}</Text>
888 <Text color="cyan">{sp('■ new: sent uncached')}</Text>
889 </Box>
890 </Box>
891 </Box>
892 )
893 })
894}
895hooks/cache.ts 416 lines1/**
2 * cache.ts — the pure half of prompt-glance: no `$`, no engine.
3 *
4 * What it models, from Anthropic's prompt-caching documentation:
5 * - the cache lives 5 minutes by default, 1 hour when asked for; a read
6 * refreshes the entry at no extra cost, and the lifetime is measured from
7 * the START of the request that wrote or read it
8 * - a request's prompt is `input_tokens` (uncached remainder) +
9 * `cache_read_input_tokens` + `cache_creation_input_tokens`
10 * - writes cost 1.25x base input for 5m and 2x for 1h; reads about 0.1x
11 * (less on some models), so an expired cache on a large context is the
12 * expensive moment
13 * - a prefix change (model, effort/thinking settings, tool set, system
14 * prompt) makes the next request write instead of read
15 *
16 * Claude Code's own switches (read from the environment):
17 * ENABLE_PROMPT_CACHING_1H=1 ask for the 1-hour TTL
18 * FORCE_PROMPT_CACHING_5M=1 force the 5-minute TTL, beating the above
19 * DISABLE_PROMPT_CACHING=1 no caching; DISABLE_PROMPT_CACHING_{HAIKU,SONNET,OPUS}
20 * turn it off for that model family only
21 */
22
23export type Ttl = '5m' | '1h'
24
25export type CacheEnv = {
26 enable1h?: string
27 force5m?: string
28 /** CLAUDE_CODE_PROMPT_CACHE_TTL: "5m" or "1h" for the main conversation */
29 ttlVar?: string
30 disableAll?: string
31 disableHaiku?: string
32 disableSonnet?: string
33 disableOpus?: string
34}
35
36/** One main-loop request, as the API reported it. */
37export type Sample = {
38 turnId: string
39 index: number
40 model: string
41 /** ms since the epoch when the request started: the cache's lifetime is counted from here */
42 startedAt: number
43 read: number
44 write: number
45 fresh: number
46 output: number
47}
48
49export type AdviceKind = 'off' | 'cold' | 'uncached' | 'warm' | 'soon' | 'expired' | 'miss'
50
51export type Advice = {
52 kind: AdviceKind
53 /** one sentence for the band */
54 text: string
55}
56
57export type Policy = {
58 ttl: Ttl
59 warnMs: number
60 compactAtTokens: number
61}
62
63export const isOn = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
64
65/** What the account is billed as, as far as the mod can tell. */
66export type Account = 'subscription' | 'credits' | 'other'
67
68export type TtlChoice = { ttl: Ttl; source: string }
69
70const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
71
72/**
73 * Which lifetime Claude Code asks for on the main conversation, in the order
74 * its documentation gives (https://code.claude.com/docs/en/prompt-caching,
75 * "Choose the TTL yourself"), after the mod's own `ttl` option:
76 *
77 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl
78 * setting, ENABLE_PROMPT_CACHING_1H, then the default of the account: one
79 * hour on a Claude subscription within its plan usage, five minutes on usage
80 * credits, an API key or a cloud provider.
81 */
82export function decideTtl(option: unknown, env: CacheEnv, setting?: unknown, account?: Account): TtlChoice {
83 const pinned = asTtl(option)
84 if (pinned) return { ttl: pinned, source: 'the ttl option' }
85 if (isOn(env.force5m)) return { ttl: '5m', source: 'FORCE_PROMPT_CACHING_5M' }
86 const fromVar = asTtl(env.ttlVar)
87 if (fromVar) return { ttl: fromVar, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
88 const fromSetting = asTtl(setting)
89 if (fromSetting) return { ttl: fromSetting, source: 'the promptCacheTtl setting' }
90 if (isOn(env.enable1h)) return { ttl: '1h', source: 'ENABLE_PROMPT_CACHING_1H' }
91 if (account === 'subscription') return { ttl: '1h', source: 'Claude subscription default' }
92 if (account === 'credits') return { ttl: '5m', source: 'usage credits default' }
93 return { ttl: '5m', source: 'default' }
94}
95
96// the sources decideTtl names when nothing pinned the lifetime: the account's own default
97const ACCOUNT_DEFAULTS: readonly string[] = ['Claude subscription default', 'usage credits default', 'default']
98
99/**
100 * Whether the lifetime came from the account's default rather than from the
101 * ttl option, an environment variable or the promptCacheTtl setting: only then
102 * may the requests' timing say otherwise (see observeTtl).
103 */
104export const isAccountDefault = (choice: TtlChoice) => ACCOUNT_DEFAULTS.includes(choice.source)
105
106export const resolveTtl = (option: unknown, env: CacheEnv, setting?: unknown, account?: Account): Ttl =>
107 decideTtl(option, env, setting, account).ttl
108
109/**
110 * The account, from the rate-limit windows the last response reported: a
111 * five-hour or seven-day window means a Claude subscription, and one that is
112 * full means the next requests draw on usage credits. No window (an API key,
113 * a cloud provider, or no response yet) says nothing.
114 */
115export function accountOf(windows: readonly { kind: string; percentUsed: number }[]): Account {
116 const plan = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
117 if (plan.length === 0) return 'other'
118 return plan.some(w => w.percentUsed >= 100) ? 'credits' : 'subscription'
119}
120
121export function ttlMs(ttl: Ttl): number {
122 return ttl === '1h' ? 3_600_000 : 300_000
123}
124
125/** Caching switched off for this model by the environment. */
126export function isCachingDisabled(model: string, env: CacheEnv): boolean {
127 if (isOn(env.disableAll)) return true
128 const name = model.toLowerCase()
129 if (name.includes('haiku')) return isOn(env.disableHaiku)
130 if (name.includes('sonnet')) return isOn(env.disableSonnet)
131 if (name.includes('opus')) return isOn(env.disableOpus)
132 return false
133}
134
135export const promptTokens = (s: Sample) => s.read + s.write + s.fresh
136
137/** Share of the prompt the cache served, 0 to 1; 0 for an empty prompt. */
138export function hitRatio(s: Sample): number {
139 const total = promptTokens(s)
140 return total === 0 ? 0 : s.read / total
141}
142
143/** When the cache entry the sample touched lapses, ms since the epoch. */
144export const expiresAt = (s: Sample, ttl: Ttl) => s.startedAt + ttlMs(ttl)
145
146/** Zero for a request that read and wrote nothing: it created or refreshed no entry, so there is nothing to count down. */
147export function remainingMs(s: Sample, ttl: Ttl, now: number): number {
148 if (s.read + s.write === 0) return 0
149 return Math.max(0, expiresAt(s, ttl) - now)
150}
151
152/**
153 * Why a request that should have read the cache wrote it instead; undefined
154 * when it did not miss. A prompt that shrank is a /compact or /clear, not a
155 * miss, and the first request of a session has nothing to read.
156 */
157export function missReason(prev: Sample | undefined, cur: Sample, ttl: Ttl): string | undefined {
158 if (!prev) return undefined
159 const before = promptTokens(prev)
160 if (before === 0 || promptTokens(cur) < before * 0.7) return undefined
161 if (cur.read >= before * 0.5 || cur.write === 0) return undefined
162 if (cur.model !== prev.model) return `model changed (${prev.model} to ${cur.model})`
163 if (cur.startedAt - prev.startedAt > ttlMs(ttl)) return `the ${ttl} cache had lapsed`
164 return 'the prompt prefix changed (effort, tools, system prompt or CLAUDE.md)'
165}
166
167export function advise(last: Sample | undefined, prev: Sample | undefined, policy: Policy, now: number, disabled: boolean): Advice {
168 if (disabled) return { kind: 'off', text: 'prompt caching is off for this model (DISABLE_PROMPT_CACHING*)' }
169 if (!last) return { kind: 'cold', text: 'no request yet: the first one writes the cache' }
170 if (last.read + last.write === 0) {
171 return { kind: 'uncached', text: 'this request was not cached (prompt under the model minimum, or caching off)' }
172 }
173 const miss = missReason(prev, last, policy.ttl)
174 const left = remainingMs(last, policy.ttl, now)
175 const size = promptTokens(last)
176 if (left <= 0) {
177 const big = size >= policy.compactAtTokens
178 return {
179 kind: 'expired',
180 text: big
181 ? `expired: the next message rewrites ${fmtTokens(size)} tokens. /compact first, or /clear if the task is done`
182 : `expired: only ${fmtTokens(size)} tokens to rebuild, just keep going`,
183 }
184 }
185 if (left <= policy.warnMs) {
186 return { kind: 'soon', text: 'expires soon: any message refreshes it for free' }
187 }
188 if (miss) return { kind: 'miss', text: `cache missed: ${miss}` }
189 return { kind: 'warm', text: 'warm: keep going' }
190}
191
192export function fmtTokens(n: number): string {
193 if (n < 1000) return String(n)
194 if (n < 100_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
195 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
196 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
197}
198
199/** m:ss, or h:mm:ss from an hour up. */
200export function fmtClock(ms: number): string {
201 const total = Math.max(0, Math.ceil(ms / 1000))
202 const h = Math.floor(total / 3600)
203 const m = Math.floor((total % 3600) / 60)
204 const s = total % 60
205 const pad = (n: number) => String(n).padStart(2, '0')
206 return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
207}
208
209export function bar(ratio: number, width: number): string {
210 const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
211 return '█'.repeat(filled) + '░'.repeat(width - filled)
212}
213
214export type TurnRow = {
215 turnId: string
216 steps: number
217 read: number
218 write: number
219 fresh: number
220 output: number
221}
222
223/** Samples grouped by turn, oldest first, each turn's requests summed. */
224export function byTurn(samples: readonly Sample[]): TurnRow[] {
225 const rows: TurnRow[] = []
226 for (const s of samples) {
227 let row = rows[rows.length - 1]
228 if (!row || row.turnId !== s.turnId) {
229 row = { turnId: s.turnId, steps: 0, read: 0, write: 0, fresh: 0, output: 0 }
230 rows.push(row)
231 }
232 row.steps += 1
233 row.read += s.read
234 row.write += s.write
235 row.fresh += s.fresh
236 row.output += s.output
237 }
238 return rows
239}
240
241export const rowRatio = (r: TurnRow) => {
242 const total = r.read + r.write + r.fresh
243 return total === 0 ? 0 : r.read / total
244}
245
246export function fit(text: string, width: number): string {
247 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
248}
249
250export function positive(v: unknown, fallback: number): number {
251 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback
252}
253
254/** Share of the cache lifetime left, 0 to 1. */
255export function lifeRatio(leftMs: number, ttl: Ttl): number {
256 return Math.min(1, Math.max(0, leftMs / ttlMs(ttl)))
257}
258
259/**
260 * Widths of the three stacked-bar segments (read, wrote, new) over `width`
261 * cells: proportional, each non-empty part at least one cell, summing to width.
262 */
263export function segments(read: number, write: number, fresh: number, width: number): [number, number, number] {
264 const total = read + write + fresh
265 if (total === 0 || width <= 0) return [0, 0, 0]
266 const parts = [read, write, fresh]
267 const cells = parts.map(p => (p > 0 ? Math.max(1, Math.round((p / total) * width)) : 0))
268 let over = cells.reduce((a, b) => a + b, 0) - width
269 while (over !== 0) {
270 const i = over > 0 ? cells.indexOf(Math.max(...cells)) : parts.indexOf(Math.max(...parts))
271 const c = cells[i]
272 if (c === undefined) break
273 cells[i] = c + (over > 0 ? -1 : 1)
274 over += over > 0 ? -1 : 1
275 }
276 const [r = 0, w = 0, n = 0] = cells
277 return [r, w, n]
278}
279
280/** Seconds left at which a toast counts down after the one at the warning threshold. */
281export const COUNTDOWN_MARKS = [10, 3, 2, 1] as const
282
283/**
284 * The toast mark to fire now, or undefined. `level` is the mark last fired for
285 * this cache entry (Infinity before any); a late tick skips straight to the
286 * newest mark crossed, so a stalled clock never replays old ones.
287 */
288export function nextToastMark(secsLeft: number, warnSecs: number, level: number): number | undefined {
289 const marks = [warnSecs, ...COUNTDOWN_MARKS].filter(m => m <= warnSecs)
290 const due = marks.filter(m => secsLeft <= m && m < level)
291 return due.length ? Math.min(...due) : undefined
292}
293
294export type LifeColor = 'green' | 'yellow' | 'red'
295
296/** Countdown colour: green while there is plenty, yellow below 40% of the lifetime, red from the warning threshold down. */
297export function lifeColor(leftMs: number, ttl: Ttl, warnMs: number): LifeColor {
298 if (leftMs <= warnMs) return 'red'
299 return leftMs / ttlMs(ttl) <= 0.4 ? 'yellow' : 'green'
300}
301
302// requests are timed from their start, so a little slack keeps a hit that
303// landed just inside the lifetime from reading as proof of the longer one
304const SLACK_MS = 10_000
305
306/**
307 * What the traffic says about the cache lifetime, given the request before and
308 * `known`, what earlier requests already showed. Asked only while the lifetime
309 * is the account's default (isAccountDefault), and what it said is dropped when
310 * that default changes (a subscription running out of plan usage moves to usage
311 * credits and their 5 minutes).
312 *
313 * - a hit (the cache served at least half of the previous prompt) more than
314 * 5 minutes after the previous request began proves the 1-hour lifetime,
315 * and no later request undoes it: a miss afterwards is more likely a changed
316 * prefix than a lapse
317 * - a miss with the same model and a prompt that did not shrink, 5 minutes to
318 * an hour after the previous request, says the entry lapsed: 5 minutes
319 * (weaker: a changed prefix looks the same, so a later hit overrules it)
320 *
321 * Needed because the API names the TTL of a write (`cache_creation.ephemeral_*`)
322 * but Claude Code's mod API passes on only the four token counts.
323 */
324export function observeTtl(prev: Sample | undefined, cur: Sample, known: Ttl | undefined): Ttl | undefined {
325 if (!prev || prev.read + prev.write === 0 || cur.model !== prev.model) return known
326 const gap = cur.startedAt - prev.startedAt
327 const before = promptTokens(prev)
328 if (gap <= ttlMs('5m') + SLACK_MS) return known
329 if (cur.read >= before * 0.5) return '1h'
330 if (known === '1h') return known
331 const lapsed = cur.write > 0 && promptTokens(cur) >= before * 0.7 && gap < ttlMs('1h') + SLACK_MS
332 return lapsed ? '5m' : known
333}
334
335/** One piece of the one-line band: its text, and the colour it is drawn in. */
336export type BandSeg = { text: string; color?: string; backgroundColor?: string; bold?: boolean; dim?: boolean }
337/** `segs`, when present, draw the part in pieces of their own colours; `text` is still the whole, for measuring. */
338export type BandPart = { key: string; text: string; color?: string; bold?: boolean; dim?: boolean; segs?: BandSeg[] }
339
340/** Cells a string takes in a terminal: emoji, CJK and ⏱ are drawn two wide. */
341export function cells(text: string): number {
342 let n = 0
343 for (const ch of text) {
344 const c = ch.codePointAt(0) ?? 0
345 if (c === 0xfe0f || c === 0x200d) continue
346 const wide =
347 c === 0x23f1 || c === 0x23f3 || c === 0x231b ||
348 (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
349 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
350 (c >= 0x1f300 && c <= 0x1faff)
351 n += wide ? 2 : 1
352 }
353 return n
354}
355
356// the tail (TTL and advice) is shown only with at least this many cells, truncated at its end
357const TAIL_MIN = 8
358
359/**
360 * The richest layout that fits on one line of `width` cells, `gap` cells between parts.
361 * `layouts` go from richest to poorest; `tail` (the TTL and advice) takes what
362 * is left, truncated at its end, and is dropped under TAIL_MIN cells.
363 */
364export function fitBand(layouts: readonly BandPart[][], tail: BandPart | undefined, width: number, gap = 1): BandPart[] {
365 const used = (parts: readonly BandPart[]) => parts.reduce((n, p) => n + cells(p.text), 0) + Math.max(0, parts.length - 1) * gap
366 const parts = layouts.find(l => used(l) <= width) ?? layouts[layouts.length - 1] ?? []
367 const room = width - used(parts) - gap
368 return tail && room >= Math.min(TAIL_MIN, cells(tail.text)) ? [...parts, { ...tail, text: fit(tail.text, room) }] : parts
369}
370
371/** The session's hit rate: every main-loop request's reads over all its prompt tokens. */
372export function sessionHit(samples: readonly Sample[]): number {
373 let read = 0
374 let total = 0
375 for (const s of samples) {
376 read += s.read
377 total += s.read + s.write + s.fresh
378 }
379 return total === 0 ? 0 : read / total
380}
381
382/** How many requests wrote the prefix again instead of reading it (missReason names a cause). */
383export function countMisses(samples: readonly Sample[], ttl: Ttl): number {
384 let n = 0
385 for (let i = 1; i < samples.length; i++) {
386 const prev = samples[i - 1]
387 const cur = samples[i]
388 if (cur && missReason(prev, cur, ttl)) n++
389 }
390 return n
391}
392
393/**
394 * A conversation's running figures, every main-loop request counted: the
395 * samples keep only the latest few hundred, these keep the whole conversation.
396 */
397export type SessionTotals = { read: number; write: number; fresh: number; misses: number }
398
399export const emptyTotals = (): SessionTotals => ({ read: 0, write: 0, fresh: 0, misses: 0 })
400
401/** `totals` with `cur` added; `prev` is the request before it, to tell a miss. */
402export function addToTotals(totals: SessionTotals, prev: Sample | undefined, cur: Sample, ttl: Ttl): SessionTotals {
403 return {
404 read: totals.read + cur.read,
405 write: totals.write + cur.write,
406 fresh: totals.fresh + cur.fresh,
407 misses: totals.misses + (missReason(prev, cur, ttl) ? 1 : 0),
408 }
409}
410
411/** The share of all the conversation's prompt tokens the cache served, 0 to 1; 0 before any. */
412export function totalsHit(t: SessionTotals): number {
413 const total = t.read + t.write + t.fresh
414 return total === 0 ? 0 : t.read / total
415}
416hooks/hud.ts 219 lines1// The HUD's pure helpers: gauges, short times and labels, all testable without the engine.
2import type { Advice, BandSeg } from './cache.ts'
3
4/** A gauge of `width` cells: █ filled, ░ empty, at least one █ above zero. */
5export function gauge(pct: number, width: number): string {
6 const p = Math.min(100, Math.max(0, pct))
7 const filled = p > 0 ? Math.max(1, Math.round((p / 100) * width)) : 0
8 return '█'.repeat(filled) + '░'.repeat(width - filled)
9}
10
11// five steps from calm to alarm: green, lime, yellow, orange, red
12export const HEAT = ['#22c55e', '#a3e635', '#facc15', '#fb923c', '#ef4444'] as const
13
14/** Where each step starts: the context window degrades sooner than a plan window runs out. */
15export const SCALE = {
16 context: [30, 50, 65, 80],
17 window: [40, 60, 75, 90],
18} as const
19
20/** The step's colour for `pct` on `scale`. */
21export function heat(pct: number, scale: readonly number[]): string {
22 const i = scale.filter(step => pct >= step).length
23 return HEAT[Math.min(i, HEAT.length - 1)] ?? HEAT[0]
24}
25
26/** The empty part of every track. */
27export const TRACK = '#3a3f4b'
28
29// a cell filled by eighths, left to right
30const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
31
32/**
33 * A solid bar: full cells of █, the last one filled by eighths over the track's
34 * colour, so it moves smoothly instead of a cell at a time. `colorAt(p)` colours
35 * the cell standing for p% (a heat strip warms along its length).
36 */
37function solidGauge(ratio: number, width: number, colorAt: (p: number) => string | undefined): BandSeg[] {
38 const exact = Math.min(1, Math.max(0, ratio)) * width
39 let full = Math.floor(exact)
40 let frac = Math.round((exact - full) * 8)
41 if (frac === 8) {
42 full += 1
43 frac = 0
44 }
45 if (ratio > 0 && full === 0 && frac === 0) frac = 1
46 const segs: BandSeg[] = []
47 for (let i = 0; i < width; i++) {
48 const color = colorAt(((i + 1) / width) * 100)
49 if (i < full) segs.push({ text: '█', color })
50 else if (i === full && frac > 0) segs.push({ text: EIGHTHS[frac] ?? '', color, backgroundColor: TRACK })
51 else segs.push({ text: '█', color: TRACK })
52 }
53 return merge(segs)
54}
55
56/** A heat strip: each filled cell takes the colour of the level it stands for, so a fuller bar reads warmer. */
57export function heatGauge(pct: number, width: number, scale: readonly number[]): BandSeg[] {
58 return solidGauge(pct / 100, width, p => heat(p, scale))
59}
60
61/** A bar of `width` cells filled to `ratio` in one colour. */
62export function lineGauge(ratio: number, width: number, color: string | undefined): BandSeg[] {
63 return solidGauge(ratio, width, () => color)
64}
65
66// neighbours of one style draw as one piece
67function merge(segs: BandSeg[]): BandSeg[] {
68 return segs.reduce<BandSeg[]>((out, seg) => {
69 const prev = out[out.length - 1]
70 if (prev && prev.color === seg.color && prev.backgroundColor === seg.backgroundColor && prev.dim === seg.dim && prev.bold === seg.bold) prev.text += seg.text
71 else out.push({ ...seg })
72 return out
73 }, [])
74}
75
76/** `git status --porcelain -b`: the branch, whether the tree is dirty, and how far it is from upstream. */
77export function parseGitStatus(stdout: string): { branch: string; dirty: boolean; ahead: number; behind: number } {
78 const lines = stdout.split('\n').filter(Boolean)
79 const first = lines[0] ?? ''
80 const head = first.startsWith('## ') ? first.slice(3) : ''
81 const name = head.startsWith('No commits yet on ') ? head.slice(18) : (head.split('...')[0] ?? '').split(' ')[0] ?? ''
82 return {
83 branch: name === 'HEAD' ? 'detached' : name,
84 dirty: lines.length > 1,
85 ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
86 behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
87 }
88}
89
90/** The working directory with `~` for home, keeping its last `levels` folders (all when undefined). */
91export function shortPath(cwd: string, home: string | undefined, levels?: number): string {
92 const tilde = home && (cwd === home || cwd.startsWith(home + '/')) ? '~' + cwd.slice(home.length) : cwd
93 if (levels === undefined) return tilde
94 const parts = tilde.split('/').filter(Boolean)
95 return parts.length <= levels ? tilde : parts.slice(-levels).join('/')
96}
97
98/** How far into a plan window we are, 0-100, from its reset time and length. */
99export function elapsedPct(resetsAt: number | undefined, windowMs: number, now: number): number | undefined {
100 if (resetsAt === undefined || !Number.isFinite(resetsAt)) return undefined
101 return Math.min(100, Math.max(0, (1 - (resetsAt - now) / windowMs) * 100))
102}
103
104/** The largest unit only: 45s, 59m, 3h, 6d. */
105export function fmtShort(ms: number): string {
106 const s = Math.max(0, Math.ceil(ms / 1000))
107 if (s < 60) return `${s}s`
108 const m = Math.round(s / 60)
109 if (m < 90) return `${m}m`
110 const h = Math.round(m / 60)
111 if (h < 48) return `${h}h`
112 return `${Math.round(h / 24)}d`
113}
114
115/** `claude-opus-5-5` (or `claude-opus-5-5[1m]`) as `Opus 5.5`; anything else unchanged. */
116export function prettyModel(id: string): string {
117 const [, family, major, minor] = /claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(id) ?? []
118 if (!family || !major) return id
119 const name = family.charAt(0).toUpperCase() + family.slice(1)
120 return minor && minor.length <= 2 ? `${name} ${major}.${minor}` : `${name} ${major}`
121}
122
123/** Each model family in a calm colour of its own, so a switch shows at a glance. */
124export function modelColor(pretty: string): string {
125 const family = (pretty.split(' ')[0] ?? '').toLowerCase()
126 return ({ opus: '#e0af68', sonnet: '#7aa2f7', haiku: '#9ece6a', fable: '#bb9af7' } as Record<string, string>)[family] ?? '#c0caf5'
127}
128
129/**
130 * Words for the cache's state, only when there is something to do: warm says
131 * nothing, the bar says it. A lapsed cache names what the next message costs.
132 */
133export function shortAdvice(advice: Advice, recache?: string): { text: string } {
134 switch (advice.kind) {
135 case 'warm':
136 return { text: '' }
137 case 'soon':
138 return { text: 'expiring · send a message' }
139 case 'expired': {
140 const next = recache ? `next message re-caches ${recache} tokens` : 'next message re-caches'
141 return { text: advice.text.includes('/compact') ? `${next} · /compact first` : next }
142 }
143 case 'miss':
144 return { text: 'missed' }
145 case 'uncached':
146 return { text: 'not cached' }
147 case 'off':
148 return { text: 'caching off' }
149 case 'cold':
150 return { text: 'waiting for first request' }
151 }
152}
153
154/** A hit rate that never rounds up to a 100% it is not: 99.5% stays 99.5%. */
155export function fmtPct(ratio: number): string {
156 const p = Math.min(1, Math.max(0, ratio)) * 100
157 if (p >= 100) return '100%'
158 if (p >= 99) return `${(Math.floor(p * 10) / 10).toFixed(1)}%`
159 return `${Math.floor(p)}%`
160}
161
162/** Tool counts, most used first: `[name, ok, failed]`. */
163export type ToolTally = Map<string, { ok: number; failed: number }>
164
165export function topTools(tally: ToolTally, n: number): [string, number, number][] {
166 return [...tally.entries()]
167 .map(([name, c]) => [name.split('__').pop() ?? name, c.ok, c.failed] as [string, number, number])
168 .sort((a, b) => b[1] + b[2] - (a[1] + a[2]))
169 .slice(0, n)
170}
171
172/** Dollars: cents under $100, whole dollars from there, "<$0.01" for a crumb. */
173export function fmtUsd(usd: number): string {
174 if (usd > 0 && usd < 0.01) return '<$0.01'
175 return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
176}
177
178/** A turn's share of the 5-hour window: quiet under 1%, yellow to 3%, red past it. */
179export function deltaColor(pct: number): string {
180 return pct > 3 ? 'red' : pct > 1 ? 'yellow' : 'gray'
181}
182
183/**
184 * What a turn spent, from the figures at its start to now: dollars and the
185 * plan windows' growth. A window that reset during the turn counts from zero.
186 * Undefined before any turn has started.
187 */
188export function turnSpend(
189 base: { usd?: number; five?: number; week?: number } | undefined,
190 usd: number | undefined,
191 five: number | undefined,
192 week: number | undefined,
193): { usd?: number; five?: number; week?: number } | undefined {
194 if (!base) return undefined
195 const grow = (from: number | undefined, to: number | undefined) =>
196 to === undefined ? undefined : from === undefined || to < from ? to : to - from
197 return {
198 usd: usd === undefined ? undefined : Math.max(0, usd - (base.usd ?? 0)),
199 five: base.five === undefined && five === undefined ? undefined : grow(base.five, five),
200 week: base.week === undefined && week === undefined ? undefined : grow(base.week, week),
201 }
202}
203
204/** The REST quota as the HUD shows it: the share used, or anon when gh has no usable token. */
205export type GhQuota = { anon: false; pct: number; resetsAt: number } | { anon: true }
206
207/** Without a token (an SSH session can't reach the macOS keychain) the API hands out the anonymous 60. */
208const ANON_LIMIT = 60
209
210/** Parses `gh api rate_limit --jq` output of `remaining<TAB>limit<TAB>reset`; undefined when it is not that. */
211export function parseRateLimit(stdout: string): GhQuota | undefined {
212 const fields = stdout.trim().split('\t')
213 if (fields.length !== 3 || fields.includes('')) return undefined
214 const [remaining, limit, reset] = fields.map(Number)
215 if (remaining === undefined || limit === undefined || reset === undefined || [remaining, limit, reset].some(Number.isNaN) || limit <= 0) return undefined
216 if (limit <= ANON_LIMIT) return { anon: true }
217 return { anon: false, pct: ((limit - remaining) / limit) * 100, resetsAt: reset * 1000 }
218}
219