SLOPSHOPPER

prompt-glance

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…

newpanebandspinnerguardcommand
v0.3.3MITupdated 2026-10-25audichuang/mods/prompt-glance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-glance
│ ┃ cache ✕ › fix the failing auth test and add an audit log call │ ┃ ⚡ PROMPT CACHE · 1h lifetime (Claude subscri │ ┃ ● prompt-glance: prompt-glance loaded: 1h cache (Claude subscription │ ┃ ⏱ --:-- ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ○ no request yet: the first one writes the ⏺ Update(src/auth.ts) │ ┃ cache ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ turn steps read wrote new hit ⎿ 3 pass, 1 fail │ ┃ no requests yet │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ close ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ■ read: served by the cache │ ┃ ■ wrote: new cache entry › /cache │ ┃ ■ new: sent uncached ⎿ prompt-glance: 1h cache (Claude subscription default) · no reque │ │ Opus 5.5 /work/app git:()Turn $0.00 +0.0% Session $0.42 ○ Cache waiting for first request Context █████▉██████ 49% 5h ███▊████████ 31%Tools Bash×4 Write×2 Read×1 Grep×1 ✗1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 /work/app git:()Turn $0.00 +0.0% Session $0.42 ○ Cache waiting for first request Context █████▉██████ 49% 5h ███▊████████ 31%Tools Bash×4 Write×2 Read×1 Grep×1 ✗1
Pane · cache
⚡ PROMPT CACHE · 1h lifetime (Claude subscription default) ⏱ --:-- ○ no request yet: the first one writes the cache turn steps read wrote new hit no requests yet [ close ] ■ read: served by the cache ■ wrote: new cache entry ■ new: sent uncached
README

prompt-glance

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.

What you see

Row one: who and where, then what it costs and the cache

  • Model, coloured by family (Opus amber, Sonnet blue, Haiku green, Fable lavender).
  • Path, with ~ for home, shortened from the left when the row is tight.
  • Git, as git:(branch): a yellow * when the tree is dirty, ↑n / ↓n against upstream.
  • Turn and Session: what the current turn has spent so far (from the prompt you sent to the end of the answer, every request and tool call in between) and its share of the 5-hour window (+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.
  • Cache: a dot, the lifetime in use (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

  • Context, 5h, Week and GH as solid bars that warm from green to red along their length; the last cell fills by eighths, so the bar moves smoothly. The percentage takes the colour of its level, and a plan window past 50% also shows when it resets (↻3h).
Levelgreenlimeyelloworangered
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 is the share of the account's GitHub REST quota used (5000 an hour), read with 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.
  • Tools the main loop ran, most used first (MCP tools by their last name segment), with a red ✗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%

What the dollars count

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:

  • On a Claude subscription the dollars are what the same work would cost at API prices, not a bill; the window share (+0.8%) is what actually counts against you.
  • The dollars include subagents (the ledger is the session's); the cache meter does not (subagents have prefixes of their own). A turn that fans out to subagents can cost more while the cache figure stays high.
  • The plan windows report one decimal after each response, so a short turn can read +0.0%.

/cache opens a pane with a per-turn table of cache reads, writes and uncached tokens.

How the countdown works

From Anthropic's prompt caching documentation:

  • The cache lives 5 minutes by default, 1 hour when asked for.
  • Every request that reads the cache refreshes it at no extra cost, so a conversation that keeps talking keeps the 5-minute cache warm.
  • The lifetime is counted from the start of the request that wrote or read the entry; generation time counts against it.
  • A prompt is input_tokens (uncached remainder) + cache_read_input_tokens + cache_creation_input_tokens.
  • Writes cost 1.25x base input for 5 minutes and 2x for 1 hour; reads cost about 0.1x (less on some models). The expensive moment is an expired cache on a large context, which is when this mod suggests /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.

Which lifetime your account gets

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:

#SourceResult
1the mod's ttl option (5m / 1h)what you set
2FORCE_PROMPT_CACHING_5M=15 minutes
3CLAUDE_CODE_PROMPT_CACHE_TTL5m or 1h
4the promptCacheTtl setting (as Claude Code merges it: managed, --settings, local, project, user)5m or 1h
5ENABLE_PROMPT_CACHING_1H=11 hour
6the account1 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:

VariableEffect on the meter
DISABLE_PROMPT_CACHING=1 (and _HAIKU, _SONNET, _OPUS)the band says caching is off for that model

What it hooks

  • 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 failed
  • session.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 lands
  • ui.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 more

Install

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

Options

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)

Credits

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.

License

MIT

Source 3 files
hooks/glance.tsx 895 lines
1/**
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}
895
hooks/cache.ts 416 lines
1/**
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}
416
hooks/hud.ts 219 lines
1// 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