SLOPSHOPPER

session-line

The Claude Code status line without a process per refresh: model and effort, context bar, git branch and changes, session length, 5-hour and weekly limits with…

newpanebandspinnercommandtoast
★ 1v0.1.3MITupdated 2026-10-22sfrangulov/claude-session-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-line
│ ┃ cache ✕ › fix the failing auth test and add an audit log call │ ┃ ⚡ PROMPT CACHE · 1h lifetime (Claude subscri │ ┃ ● session-line: session-line loaded: 1h cache (Claude subscription de │ ┃ ⏱ --:-- ● session-line: session-line: idle compaction off, rollout flag not f │ ┃ ⏺ Read(src/auth.ts) │ ┃ ○ no request yet: the first one writes the ⎿ Read 6 lines │ ┃ cache ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⇣ idle compaction off ⏺ Bash(bun test) │ ┃ rollout flag not found in Claude Code's ⎿ 3 pass, 1 fail │ ┃ global config │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ turn steps read wrote new hit │ ┃ no requests yet ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ close ] › /cache │ ┃ ⎿ session-line: 1h cache (Claude subscription default) · no reques │ ┃ ■ read: served by the cache │ ┃ ■ wrote: new cache entry │ ┃ ■ new: sent uncached │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 · default │ ▓▓▓▓▓░░░░░ 48.7% │ 9176hr 51m 5h ▓▓▓░░░░░░░ 31.0% ⟨Claude Code's own drawing⟩

Draws

Pane · cache
⚡ PROMPT CACHE · 1h lifetime (Claude subscription default) ⏱ --:-- ○ no request yet: the first one writes the cache ⇣ idle compaction off rollout flag not found in Claude Code's global config turn steps read wrote new hit no requests yet [ close ] ■ read: served by the cache ■ wrote: new cache entry ■ new: sent uncached
Prompt hint
Opus 5.5 · default │ ▓▓▓▓▓░░░░░ 48.7% │ 9176hr 51m 5h ▓▓▓░░░░░░░ 31.0% ⟨Claude Code's own drawing⟩
README

session-line

session-line

A Claude Code mod that draws the status line itself. A statusLine command starts a process on every refresh; this mod starts none, and adds a prompt-cache meter.

Opus 5.5 · xhigh │ ▓▓░░░░░░░░ 21.0% │ ⎇ main (+12,-3) │ 1hr 5m
5h ▓░░░░░░░░░ 3.0% (2h33m) │ wk ▓▓░░░░░░░░ 19.0% (5d33m) │ cache 92% · 41m

The first row has the model and effort, the context fill, the git branch with uncommitted insertions and deletions, and the session length. The second has the 5-hour and weekly limits with the time to their reset, and the prompt cache: the share of the last prompt served from it and the time left before it lapses. In a narrow terminal each row wraps instead of being cut off.

Install

At the prompt of a Claude Code terminal session:

/plugin install session-line --marketplace sfrangulov/claude-session-line

From a shell:

claude plugin install session-line --marketplace sfrangulov/claude-session-line

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with sfrangulov/claude-session-line, then Add plugin.

Claude Code first asks to add the marketplace (y), then for a scope. If you have a statusLine command in settings.json, remove it, or the old line shows as well.

Where the figures come from

FigureSourceProcess
model$.session.model()none
effortthe main loop's last request; before the first one, the level this model ran at last time, or the effortLevel settingnone
context, 5h, wk$.session.usage() at the start, then session.measurenone
session lengthusage().startedAtnone
branch.git/HEAD, read as a file (a worktree's .git file is followed)none
(+ins,-del)git diff HEAD --shortstat at the start and after each turnone per turn; none with gitChanges: false
cachethe token counts of each main-loop requestnone

A one-second timer redraws, and only when a text changed. Model, effort and HEAD are read again once a minute and after each prompt and turn.

What it reads, runs and stores

  • Sends: nothing, anywhere. The mod makes no network requests and no model requests, and passes nothing it reads to another program or service. What it reads is only drawn in the line.
  • Starts: one program, git, with fixed arguments: git --no-optional-locks diff HEAD --shortstat, run in the session's work tree at the start and after each turn. It counts the uncommitted insertions and deletions shown as (+ins,-del). Its output is read and drawn, nothing more. With gitChanges: false the mod starts no program at all.
  • Reads:
  • .git/HEAD of the work tree above the session's folder, and a worktree's .git file, for the branch name.
  • promptCacheTtl, idleCompaction and autoCompactEnabled from the project's .claude/settings.local.json and .claude/settings.json and from the user's .claude/settings.json, plus the effortLevel setting.
  • Two keys of Claude Code's global config file (.claude.json in the home folder, or in CLAUDE_CONFIG_DIR): autoCompactEnabled and the cached flag saying whether idle compaction is rolled out to the account. Nothing else in that file is kept.
  • The environment variables listed under Cache meter, CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, DISABLE_PROMPT_CACHING*, DISABLE_COMPACT, DISABLE_AUTO_COMPACT, CLAUDE_CONFIG_DIR and HOME.
  • Stores: the effort each model last ran at ({"Opus 5.5": "xhigh"}), in the mod's own store on this machine, so the line can show it before a session's first request.

Hooks

Apart from drawing its own rows and answering its own /cache command, every hook only observes: it passes the event on unchanged and blocks or rewrites nothing.

HookWhat it does
session.start, session.endregisters /cache, reads the settings above, starts and stops the one-second timer; /clear resets the meter
session.measuretakes the context fill and the limit windows
turn.steptakes each main-loop request's effort and its four token counts; the request goes on as it was
turn.completemarks model, effort and git for a refresh after a turn
prompt.submitmarks the line for a refresh after a prompt (a /model, /effort or ! command may change it); the prompt goes on as typed
session.compactnotes a compaction, so the meter stops counting down the old cache
command.run (/cache), ui.close, ui.pressopen and close the /cache pane
ui.render on PromptHint, AbovePrompt, Panedraws the two rows, the band and the pane

Cache meter

  • In the line: cache 92% · 41m, with seconds inside the warning (0:45), then expired. miss marks a request that wrote the cache where it should have read it. ⇣ 35m counts down while Claude Code is set to compact the idle conversation by itself.
  • Band above the prompt (band): with action, the default, it shows one line only when the cache needs you. That is when the cache is about to lapse on a prompt of 20k tokens or more, or has lapsed on one of compactAtTokens or more. always shows the full meter row; off shows nothing.
  • Toasts come at the warning threshold, then at 10, 3, 2 and 1 seconds.
  • /cache opens a pane with the countdown, the read, written and new tokens of the last request, and one row per turn. /cache stop closes it.

The cache lifetime follows Claude Code's rules. FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H apply in that order. Otherwise it is 1 hour on a Claude subscription and 5 minutes elsewhere. It is also corrected from the timing between requests.

The meter only watches the cache. It never sends a request to keep the cache warm.

Options

Set them in the /config menu, or under pluginConfigs["session-line@claude-session-line"].options in ~/.claude/settings.json:

OptionDefault
placehinthint: two rows under the prompt; status: one plain status entry, which Claude Code labels ⚠ session-line:
ttlautoauto, 5m or 1h
warnSeconds60when the countdown turns to seconds, the band asks for a message and toasts start
compactAtTokens100000prompt size at which a lapsed cache is worth a /compact
bandactionaction, always or off
toasttruetoasts near expiry
idleCompacttrueidle-compaction countdown and notice
gitChangestrue(+ins,-del) from one git diff per turn

Reading the line off the screen

A tool that scrapes the terminal for these figures can take the first ▓░ bar for the context and the bars labelled 5h and wk for the limits. Those bars and labels stay. The band and the pane draw no bar glyph (▓░█▒▌▏) of their own.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

tsc -p . type-checks once Claude Code has loaded the mod from this folder, since that is when it writes the API types to .claude-plugin/types/.

Credits

The cache meter comes from prompt-cache-control 0.1.0 by Daniel (San) Ávila, under the MIT licence. hooks/cache.ts and tests/cache.test.ts are taken from it, and its hooks are merged into hooks/register.tsx. Both licences are in LICENSE.

Source 3 files
hooks/register.tsx 794 lines
1/**
2 * session-line — Claude Code mod
3 *
4 * The status line drawn by a mod instead of a statusLine command, which starts
5 * a process on every refresh, with a prompt-cache meter in it. Two rows under
6 * the prompt:
7 *
8 *   Opus 5.5 · xhigh │ ▓▓░░░░░░░░ 21.0% │ ⎇ main (+12,-3) │ 1hr 5m
9 *   5h ▓░░░░░░░░░ 3.0% (2h33m) │ wk ▓▓░░░░░░░░ 19.0% (5d33m) │ cache 92% · 41m
10 *
11 * Where each figure comes from; none of it starts a process:
12 *   - model: $.session.model(); effort: the main loop's last turn.step, or the
13 *     effortLevel setting when that changed later
14 *   - context and the 5h / weekly windows: $.session.usage() at the start, then
15 *     what session.measure pushes; the session length from usage().startedAt
16 *   - branch: .git/HEAD read through $.fs (a worktree's .git file followed)
17 *   - (+ins,-del): `git diff HEAD --shortstat`, the one process, run at the
18 *     start and once after each main-loop turn (gitChanges: false drops it)
19 *   - cache: the four token counts of each main-loop request (turn.step)
20 * A one-second $.clock.every redraws, and only when a text changed; model,
21 * effort and HEAD are read again once a minute and after each prompt and turn.
22 *
23 * Tools that read the figures off the screen take the first ▓░ bar for the
24 * context and the `5h` / `wk` bars for the limits, so the band and the pane
25 * draw no bar glyph (▓░█▒▌▏) of their own.
26 *
27 * The cache meter is prompt-cache-control 0.1.0 by Daniel (San) Ávila, part of
28 * claude-code-templates, MIT licence (see LICENSE), merged in:
29 * its pure half is ./cache.ts; its hooks (turn.step samples, the TTL rules and
30 * the TTL observed from request timing, idle compaction, the countdown
31 * toasts, the band and the /cache pane) are carried over below. Changed: the
32 * band shows only when the cache needs you unless `band: "always"`, and the
33 * cache figures sit in the status line.
34 *
35 * Options (the /config menu, or pluginConfigs["session-line@<marketplace>"].options):
36 *   place: "hint" | "status"              two rows under the prompt, or one $.ui.status entry (default hint)
37 *   ttl: "auto" | "5m" | "1h"            cache lifetime (default auto)
38 *   warnSeconds: number                   warning threshold (default 60)
39 *   compactAtTokens: number               prompt size that makes a lapsed cache worth a /compact (default 100000)
40 *   band: "action" | "always" | "off"     row above the prompt (default action)
41 *   toast: boolean                        toasts near expiry (default true)
42 *   idleCompact: boolean                  idle compaction countdown and notice (default true)
43 *   gitChanges: boolean                   (+ins,-del) from one git diff per turn (default true)
44 */
45import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
46import {
47  advise,
48  COUNTDOWN_MARKS,
49  byTurn,
50  fit,
51  fmtClock,
52  fmtCount,
53  hitRatio,
54  idleCompactAt,
55  idlePolicy,
56  isCachingDisabled,
57  accountOf,
58  decideTtl,
59  missReason,
60  observeTtl,
61  lifeColor,
62  lifeRatio,
63  nextToastMark,
64  positive,
65  promptTokens,
66  remainingMs,
67  rowRatio,
68  segments,
69} from './cache.ts'
70import type { Account, Advice, CacheEnv, IdleCompact, Sample, Ttl } from './cache.ts'
71import { cacheText, lineSegments, parseGitFile, parseHead, parseShortstat, plain, prettyModel } from './line.ts'
72import type { CacheView, Group } from './line.ts'
73
74const PANE = 'cache'
75const COMMAND = 'cache'
76const KEEP = 200
77// below this a lapsed cache costs too little to interrupt anyone about
78const TOAST_MIN_TOKENS = 20_000
79// how often model, effort and HEAD are read again with nothing else asking
80const SLOW_MS = 60_000
81const EFFORT_KEY = 'effort'
82
83// the cache meter (prompt-cache-control's state)
84let samples: Sample[] = []
85let ttl: Ttl = '5m'
86let baseTtl: Ttl = '5m'
87let pinned = false
88let observed: Ttl | undefined
89let setting: unknown
90let account: Account = 'other'
91let ttlSource = 'default'
92let envSource = 'default'
93let env: CacheEnv = {}
94let lastKey = ''
95let toastedFor = 0
96let toastLevel = Infinity
97let isPaneOpen = false
98let idle: IdleCompact = idlePolicy({})
99// the last compaction of the main conversation: a cache entry older than it is gone
100let compacted: { at: number; trigger: string; before?: number; after?: number } | undefined
101
102// the status line
103let model = ''
104let effort = 'default'
105let settingEffort: unknown
106// whether a main-loop request of this session has said its effort yet
107let isEffortSeen = false
108let startedAt: number | undefined
109let context: SessionContextUsage = { window: 0 }
110let rateLimits: SessionRateLimit[] = []
111let repo: { cwd: string; dir: string; gitdir: string } | undefined
112let branch: string | undefined
113let changes: { ins: number; del: number } | undefined
114let lastLine = ''
115let lastSlow = 0
116let isSlowDue = true
117let isGitDue = true
118let isBusy = false
119let stopTimer: { cancel: () => void } | undefined
120
121type Policy = { warnMs: number; compactAtTokens: number }
122
123function current(policy: Policy, now: number) {
124  const last = samples[samples.length - 1]
125  const prev = samples[samples.length - 2]
126  const disabled = last ? isCachingDisabled(last.model, env) : isCachingDisabled('', env)
127  if (last && compacted && compacted.at > last.startedAt) {
128    // the conversation the last request cached is gone: the next one starts from the summary
129    const sizes = compacted.before && compacted.after ? ` (${fmtCount(compacted.before)} → ${fmtCount(compacted.after)})` : ''
130    const how = compacted.trigger === 'idle' ? 'compacted while idle' : 'compacted'
131    const advice: Advice = { kind: 'cold', text: `${how}${sizes}: the next message starts from the summary` }
132    return { last, advice, left: 0, idleAt: undefined, isCompacted: true }
133  }
134  const advice: Advice = advise(last, prev, { ttl, ...policy }, now, disabled)
135  const left = last ? remainingMs(last, ttl, now) : 0
136  const idleAt = disabled ? undefined : idleCompactAt(last, ttl, idle, now)
137  return { last, advice, left, idleAt, isCompacted: false }
138}
139
140// "⇣ 41:12" until Claude Code compacts on its own, "⇣ now" once that moment has come
141const idleClock = (idleAt: number, now: number) => (idleAt > now ? `⇣ ${fmtClock(idleAt - now)}` : '⇣ now')
142
143const COLOR: Record<Advice['kind'], string | undefined> = {
144  warm: 'green',
145  soon: 'yellow',
146  expired: 'red',
147  miss: 'red',
148  off: undefined,
149  cold: undefined,
150  uncached: undefined,
151}
152
153const ICON: Record<Advice['kind'], string> = {
154  warm: '●',
155  soon: '▲',
156  expired: '✖',
157  miss: '✖',
158  off: '○',
159  cold: '○',
160  uncached: '○',
161}
162
163function cacheView(policy: Policy, now: number, showIdle: boolean): CacheView {
164  const { last, advice, left, idleAt, isCompacted } = current(policy, now)
165  if (advice.kind === 'off') return { state: 'off' }
166  if (!last) return { state: 'none' }
167  if (isCompacted) return { state: 'compacted' }
168  if (advice.kind === 'uncached') return { state: 'uncached' }
169  const isMiss = missReason(samples[samples.length - 2], last, ttl) !== undefined
170  const idleInMs = showIdle && idleAt !== undefined ? idleAt - now : undefined
171  return { state: 'live', ratio: hitRatio(last), leftMs: left, warnMs: policy.warnMs, isMiss, idleInMs }
172}
173
174// the band's one line when the cache needs the person; undefined when it does not
175function actionLine(policy: Policy, now: number): { text: string; color: string } | undefined {
176  const { last, advice, left } = current(policy, now)
177  if (!last) return undefined
178  const size = promptTokens(last)
179  if (advice.kind === 'soon' && size >= TOAST_MIN_TOKENS) {
180    return { color: 'yellow', text: `▲ cache lapses in ${fmtClock(left)}: send a message to keep ${fmtCount(size)} tokens warm` }
181  }
182  if (advice.kind === 'expired' && size >= policy.compactAtTokens) {
183    return { color: 'red', text: `✖ cache ${advice.text}` }
184  }
185  return undefined
186}
187
188type Settings = { promptCacheTtl?: unknown; idleCompaction?: unknown; autoCompactEnabled?: unknown }
189
190// the settings the mod follows, each from the first settings file that sets it (local over project over user)
191async function readSettings($: EngineInterface): Promise<Settings> {
192  const home = await $.env.get('HOME').catch(() => undefined)
193  const cwd = await $.session.cwd().catch(() => undefined)
194  const files = [cwd && `${cwd}/.claude/settings.local.json`, cwd && `${cwd}/.claude/settings.json`, home && `${home}/.claude/settings.json`]
195  const out: Settings = {}
196  for (const file of files) {
197    if (!file) continue
198    try {
199      const json = JSON.parse(await $.fs.read(file))
200      if (out.promptCacheTtl === undefined && (json.promptCacheTtl === '5m' || json.promptCacheTtl === '1h')) out.promptCacheTtl = json.promptCacheTtl
201      if (out.idleCompaction === undefined && typeof json.idleCompaction === 'boolean') out.idleCompaction = json.idleCompaction
202      if (out.autoCompactEnabled === undefined && typeof json.autoCompactEnabled === 'boolean') out.autoCompactEnabled = json.autoCompactEnabled
203    } catch {
204      // missing or unreadable: the next file
205    }
206  }
207  return out
208}
209
210// Claude Code's global config: the cached idle-compaction rollout flag, and autoCompactEnabled as /config saves it.
211// Only those two keys are kept; nothing else in the file is held by the mod.
212async function readGlobalConfig($: EngineInterface): Promise<{ flag?: unknown; autoCompact?: unknown }> {
213  const dir = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
214  const home = await $.env.get('HOME').catch(() => undefined)
215  const file = dir ? `${dir}/.claude.json` : home ? `${home}/.claude.json` : undefined
216  if (!file) return {}
217  try {
218    const json = JSON.parse(await $.fs.read(file))
219    const flag = json?.cachedGrowthBookFeatures?.tengu_sunny_locket
220    return { flag: flag && typeof flag === 'object' ? flag : undefined, autoCompact: json?.autoCompactEnabled }
221  } catch {
222    return {}
223  }
224}
225
226// the work tree holding `cwd`, walked up as git does; its .git a folder, or a worktree's file naming one
227async function findRepo($: EngineInterface, cwd: string): Promise<typeof repo> {
228  let dir = cwd.replace(/\/+$/, '') || '/'
229  for (;;) {
230    const dotGit = `${dir === '/' ? '' : dir}/.git`
231    const stat = await $.fs.stat(dotGit).catch(() => undefined)
232    if (stat?.kind === 'dir') return { cwd, dir, gitdir: dotGit }
233    if (stat?.kind === 'file') {
234      const gitdir = parseGitFile(await $.fs.read(dotGit).catch(() => ''), dir)
235      if (gitdir) return { cwd, dir, gitdir }
236    }
237    if (dir === '/') return undefined
238    dir = dir.slice(0, dir.lastIndexOf('/')) || '/'
239  }
240}
241
242// model, effort setting and branch: engine calls and two small file reads
243async function refreshSlow($: EngineInterface) {
244  model = prettyModel(await $.session.model().catch(() => model))
245  const level = await $.settings.read({ key: 'effortLevel' }).catch(() => settingEffort)
246  // the setting wins only when it changed since the last read (/effort); else the last request's effort stands
247  if (level !== settingEffort) {
248    settingEffort = level
249    if (typeof level === 'string' && level !== '') effort = level
250  }
251  // before the first request nothing says the effort: the one this model ran at last time stands in
252  if (!isEffortSeen && !(typeof settingEffort === 'string' && settingEffort !== '')) {
253    const known = await $.store.get(EFFORT_KEY).catch(() => undefined)
254    const last = known && typeof known === 'object' ? (known as Record<string, unknown>)[model] : undefined
255    if (typeof last === 'string') effort = last
256  }
257  if (startedAt === undefined) startedAt = (await $.session.usage().catch(() => undefined))?.startedAt
258  const cwd = await $.session.cwd().catch(() => undefined)
259  if (cwd === undefined) return
260  if (repo?.cwd !== cwd) repo = await findRepo($, cwd)
261  const head = repo ? await $.fs.read(`${repo.gitdir}/HEAD`).catch(() => undefined) : undefined
262  branch = head === undefined ? undefined : parseHead(head)
263  if (!repo) changes = undefined
264}
265
266// the effort each model last ran at, kept across sessions: { "Opus 5.5": "xhigh" }
267async function rememberEffort($: EngineInterface, name: string, level: string) {
268  const known = await $.store.get(EFFORT_KEY).catch(() => undefined)
269  const all = known && typeof known === 'object' ? (known as Record<string, unknown>) : {}
270  if (all[name] !== level) await $.store.set(EFFORT_KEY, { ...all, [name]: level }).catch(() => undefined)
271}
272
273// the one process: `git diff HEAD --shortstat` in the work tree
274async function refreshChanges($: EngineInterface) {
275  if (!repo) return
276  try {
277    const r = await $.process.run(['git', '--no-optional-locks', 'diff', 'HEAD', '--shortstat'], { cwd: repo.dir, timeoutMs: 10_000 })
278    changes = parseShortstat(r.exitCode === 0 ? r.stdout : '')
279  } catch {
280    changes = undefined
281  }
282}
283
284type Cfg = {
285  policy: Policy
286  band: 'action' | 'always' | 'off'
287  place: 'hint' | 'status'
288  wantToast: boolean
289  showIdle: boolean
290  countChanges: boolean
291}
292
293let cfg: Cfg = {
294  policy: { warnMs: 60_000, compactAtTokens: 100_000 },
295  band: 'action',
296  place: 'hint',
297  wantToast: true,
298  showIdle: true,
299  countChanges: true,
300}
301
302function line(now: number): { top: Group[]; bottom: Group[]; text: string } {
303  const { top, bottom } = lineSegments({
304    model: model || '?',
305    effort,
306    context,
307    branch,
308    changes: cfg.countChanges ? changes : undefined,
309    durationMs: startedAt === undefined ? undefined : now - startedAt,
310    limits: rateLimits,
311    now,
312    cache: cacheText(cacheView(cfg.policy, now, cfg.showIdle)),
313    cacheColor: COLOR[current(cfg.policy, now).advice.kind] ?? 'gray',
314  })
315  return { top, bottom, text: bottom.length === 0 ? plain(top) : `${plain(top)} │ ${plain(bottom)}` }
316}
317
318// what the band and the pane draw, as one key: a change redraws them
319function drawKey(now: number): string {
320  if (cfg.band === 'off' && !isPaneOpen) return ''
321  if (cfg.band === 'action' && !isPaneOpen) return actionLine(cfg.policy, now)?.text ?? ''
322  const { advice, left, idleAt } = current(cfg.policy, now)
323  return `${advice.kind}|${advice.text}|${left > 0 ? fmtClock(left) : ''}|${cfg.showIdle && idleAt ? idleClock(idleAt, now) : ''}`
324}
325
326// the line goes under the prompt as the PromptHint row (place "hint"), or as this plugin's $.ui.status entry
327function redraw($: EngineInterface, now = Date.now()) {
328  const { text } = line(now)
329  const key = drawKey(now)
330  const isLineNew = text !== lastLine
331  if (isLineNew) {
332    lastLine = text
333    if (cfg.place === 'status') $.ui.status(text)
334  }
335  if (key !== lastKey || (isLineNew && cfg.place === 'hint')) {
336    lastKey = key
337    $.ui.invalidate('ui.render')
338  }
339}
340
341function toastNearExpiry($: EngineInterface, now: number) {
342  const { last, left } = current(cfg.policy, now)
343  if (!cfg.wantToast || !last || left <= 0 || promptTokens(last) < TOAST_MIN_TOKENS) return
344  if (toastedFor !== last.startedAt) {
345    toastedFor = last.startedAt
346    toastLevel = Infinity
347  }
348  // the first toast comes at warnSeconds, then 10, 3, 2 and 1 seconds; a late tick skips to the newest one
349  const secs = Math.ceil(left / 1000)
350  const mark = nextToastMark(secs, cfg.policy.warnMs / 1000, toastLevel)
351  if (mark === undefined) return
352  toastLevel = mark
353  const tail = secs <= (COUNTDOWN_MARKS[0] ?? 10) ? 'send a message now' : `send a message to keep ${fmtCount(promptTokens(last))} tokens warm`
354  $.ui.toast(`cache expires in ${secs >= 60 ? fmtClock(left) : `${secs}s`}: ${tail}`)
355}
356
357async function tick($: EngineInterface) {
358  const now = Date.now()
359  if (!isBusy && (isSlowDue || isGitDue || now - lastSlow >= SLOW_MS)) {
360    isBusy = true
361    try {
362      const isGit = isGitDue
363      isSlowDue = false
364      isGitDue = false
365      lastSlow = now
366      await refreshSlow($)
367      if (isGit && cfg.countChanges) await refreshChanges($)
368    } finally {
369      isBusy = false
370    }
371  }
372  redraw($)
373  toastNearExpiry($, Date.now())
374}
375
376function resetCache() {
377  samples = []
378  lastKey = ''
379  toastedFor = 0
380  compacted = undefined
381  observed = undefined
382}
383
384export const register: Register = (on, options) => {
385  cfg = {
386    policy: {
387      warnMs: positive(options.warnSeconds, 60) * 1000,
388      compactAtTokens: positive(options.compactAtTokens, 100_000),
389    },
390    band: options.band === 'always' || options.band === 'off' ? options.band : 'action',
391    place: options.place === 'status' ? 'status' : 'hint',
392    wantToast: options.toast !== false,
393    showIdle: options.idleCompact !== false,
394    countChanges: options.gitChanges !== false,
395  }
396  const { policy, band, wantToast, showIdle } = cfg
397
398  on('session.start', async ($, e, next) => {
399    const r = await next(e)
400    resetCache()
401    lastLine = ''
402    const none = () => undefined
403    env = {
404      enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
405      force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
406      ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
407      disableAll: await $.env.get('DISABLE_PROMPT_CACHING').catch(none),
408      disableHaiku: await $.env.get('DISABLE_PROMPT_CACHING_HAIKU').catch(none),
409      disableSonnet: await $.env.get('DISABLE_PROMPT_CACHING_SONNET').catch(none),
410      disableOpus: await $.env.get('DISABLE_PROMPT_CACHING_OPUS').catch(none),
411    }
412    pinned = options.ttl === '5m' || options.ttl === '1h'
413    const settings = await readSettings($)
414    setting = settings.promptCacheTtl
415    const global = await readGlobalConfig($)
416    idle = idlePolicy({
417      minTokensVar: await $.env.get('CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS').catch(none),
418      disableAuto: await $.env.get('DISABLE_AUTO_COMPACT').catch(none),
419      disableAll: await $.env.get('DISABLE_COMPACT').catch(none),
420      setting: settings.idleCompaction,
421      autoCompact: settings.autoCompactEnabled ?? global.autoCompact,
422      flag: global.flag,
423    })
424    const usage = await $.session.usage().catch(none)
425    startedAt = usage?.startedAt
426    context = usage?.context ?? { window: 0 }
427    rateLimits = usage?.rateLimits ?? []
428    account = accountOf(rateLimits)
429    const choice = decideTtl(options.ttl, env, setting, account)
430    baseTtl = choice.ttl
431    ttl = baseTtl
432    envSource = choice.source
433    ttlSource = envSource
434
435    await $.command
436      .register({
437        name: COMMAND,
438        description: 'Prompt-cache usage per turn and the time left before it lapses (stop closes)',
439        argumentHint: '[stop]',
440        immediate: true,
441      })
442      .catch(err => $.ui.log(`session-line: /${COMMAND} not registered: ${err}`))
443    $.ui.log(`session-line loaded: ${ttl} cache (${ttlSource}), /${COMMAND} opens the table`, { to: 'debug' })
444    $.ui.log(`session-line: idle compaction ${idle.on ? 'on' : 'off'}, ${idle.why}`, { to: 'debug' })
445
446    // a previous version, or place "status" before, may have left an entry
447    if (cfg.place === 'hint') $.ui.status(undefined)
448    isSlowDue = true
449    isGitDue = true
450    await tick($)
451    stopTimer?.cancel()
452    stopTimer = $.clock.every(1000, () => void tick($))
453    return r
454  })
455
456  on('session.end', async ($, e, next) => {
457    // /clear starts a new conversation in the same process: its cache is a new one, its length counts from now
458    if (e.reason === 'clear') {
459      resetCache()
460      ttl = baseTtl
461      ttlSource = envSource
462      startedAt = undefined
463      context = { window: context.window }
464      isSlowDue = true
465      $.ui.invalidate('ui.render')
466      return next(e)
467    }
468    stopTimer?.cancel()
469    stopTimer = undefined
470    return next(e)
471  })
472
473  // the status line's figures, pushed after each main-loop turn and as a window moves
474  on('session.measure', async ($, e, next) => {
475    context = e.context
476    rateLimits = e.rateLimits
477    redraw($)
478    return next(e)
479  })
480
481  // a slash command (/model, /effort) or a `!` command may have changed what the line shows
482  on('prompt.submit', async ($, e, next) => {
483    const r = await next(e)
484    isSlowDue = true
485    if (e.text.trimStart().startsWith('!')) isGitDue = true
486    return r
487  })
488
489  on('turn.complete', async ($, e, next) => {
490    const r = await next(e)
491    if (e.agentId === undefined) {
492      isSlowDue = true
493      isGitDue = true
494    }
495    return r
496  })
497
498  // a compaction of the main conversation replaces what the cache held; "idle" is Claude Code compacting on its own (2.1.290+)
499  on('session.compact', async ($, e, next) => {
500    const r = await next(e)
501    // a precompute only prepares a summary; the conversation is unchanged until it is used
502    const trigger = String(e.trigger)
503    if (e.agentId || trigger === 'precompute' || 'skip' in r) return r
504    compacted = { at: Date.now(), trigger, before: r.tokensBefore, after: r.tokensAfter }
505    if (trigger === 'idle') {
506      const sizes = r.tokensBefore && r.tokensAfter ? ` ${fmtCount(r.tokensBefore)} → ${fmtCount(r.tokensAfter)} tokens` : ''
507      $.ui.log(`session-line: compacted while idle${sizes}, before the ${ttl} cache lapsed`)
508      if (showIdle && wantToast) $.ui.toast(`compacted while idle${sizes}: your next message starts from the summary`)
509    }
510    lastKey = ''
511    redraw($)
512    return r
513  })
514
515  // each main-loop request: its effort, and what the cache did with it
516  on('turn.step', async function* ($, e, next) {
517    if (e.agentId) return yield* next(e)
518    effort = e.effort === undefined ? 'default' : String(e.effort)
519    isEffortSeen = true
520    await rememberEffort($, prettyModel(e.model), effort)
521    const startedAt = Date.now()
522    const r = yield* next(e)
523    if (r.usage) {
524      samples.push({
525        turnId: e.turnId,
526        index: e.index,
527        model: r.usage.model || e.model,
528        startedAt,
529        read: r.usage.cache_read_input_tokens,
530        write: r.usage.cache_creation_input_tokens,
531        fresh: r.usage.input_tokens,
532        output: r.usage.output_tokens,
533      })
534      if (samples.length > KEEP) samples = samples.slice(-KEEP)
535      if (!pinned) {
536        // the account can change under a session: a subscription running out of plan usage moves to usage credits
537        account = accountOf(rateLimits)
538        const choice = decideTtl(options.ttl, env, setting, account)
539        baseTtl = choice.ttl
540        envSource = choice.source
541        if (observed === undefined) {
542          ttl = baseTtl
543          ttlSource = envSource
544        }
545        const seen = observeTtl(samples[samples.length - 2], samples[samples.length - 1]!, observed)
546        if (seen !== observed) {
547          observed = seen
548          ttl = seen ?? baseTtl
549          ttlSource = `observed from request timing; ${envSource} said ${baseTtl}`
550          $.ui.log(`session-line: cache lifetime is ${ttl} (${ttlSource})`, { to: 'debug' })
551        }
552      }
553    }
554    redraw($)
555    return r
556  })
557
558  on('command.run', { command: COMMAND }, async ($, e) => {
559    if (e.args.trim().toLowerCase() === 'stop') {
560      await $.ui.close({ id: PANE }).catch(() => undefined)
561      isPaneOpen = false
562      return { text: 'cache table closed' }
563    }
564    isPaneOpen = true
565    await $.ui.open({ id: PANE, title: 'cache', focus: true })
566    lastKey = ''
567    $.ui.invalidate('ui.render')
568    const { advice } = current(policy, Date.now())
569    return { text: `${ttl} cache (${ttlSource}) · ${advice.text} · /${COMMAND} stop closes` }
570  })
571
572  on('ui.close', async ($, e, next) => {
573    if (e.id !== PANE) return next(e)
574    isPaneOpen = false
575    lastKey = ''
576    return next(e)
577  })
578
579  on('ui.press', async ($, e, next) => {
580    if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
581    if (e.element === 'close') await $.ui.close({ id: PANE }).catch(() => undefined)
582    return next(e)
583  })
584
585  // the status line itself: two rows above the engine's hint line, each wrapping in a narrow terminal rather than cut off
586  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
587    if (cfg.place !== 'hint') return next(e)
588    const { Box, Text } = $.ui.resolve(e)
589    const { top, bottom } = line(Date.now())
590    const below = await next(e)
591    const row = (groups: Group[], key: string) => (
592      <Box key={key} flexDirection="row" flexWrap="wrap">
593        {groups.map((g, n) => (
594          <Box key={`${key}:${n}`} flexDirection="row">
595            {g.map((s, i) => (
596              <Text key={`${key}:${n}:${i}`} color={s.color} bold={s.bold}>
597                {s.text}
598              </Text>
599            ))}
600          </Box>
601        ))}
602      </Box>
603    )
604    return (
605      <Box flexDirection="column">
606        {row(top, 'top')}
607        {bottom.length > 0 ? row(bottom, 'bottom') : null}
608        {below}
609      </Box>
610    )
611  })
612
613  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
614    if (band === 'off' || e.props.hasSurvey || isPaneOpen) return next(e)
615    const now = Date.now()
616    const { Box, Text } = $.ui.resolve(e)
617    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
618
619    if (band === 'action') {
620      const act = actionLine(policy, now)
621      if (!act) return next(e)
622      const below = await next(e)
623      return (
624        <Box flexDirection="column">
625          <Text bold color={act.color} wrap="truncate-end">
626            {act.text}
627          </Text>
628          {below}
629        </Box>
630      )
631    }
632
633    const { last, advice, left, idleAt } = current(policy, now)
634    if (!last && advice.kind !== 'off') return next(e)
635    const color = COLOR[advice.kind]
636    // The band is one instance: whatever the plugins beneath draw (another mod's band) stays, under this line.
637    const below = await next(e)
638    const stack = (own: ReturnType<typeof Text>) => (
639      <Box flexDirection="column">
640        {own}
641        {below}
642      </Box>
643    )
644
645    if (!last) return stack(<Text dimColor>{fit(`cache: ${advice.text}`, columns)}</Text>)
646
647    const wide = columns >= 90
648    return stack(
649      <Box flexDirection="row" columnGap={1}>
650        <Text bold color={color}>{ICON[advice.kind]}</Text>
651        <Text bold color="cyan">cache</Text>
652        <Text bold>{`${Math.round(hitRatio(last) * 100)}%`}</Text>
653        {wide ? (
654          <>
655            <Text color="green">{`read ${fmtCount(last.read)}`}</Text>
656            <Text color="yellow">{`wrote ${fmtCount(last.write)}`}</Text>
657            <Text color="cyan">{`new ${fmtCount(last.fresh)}`}</Text>
658          </>
659        ) : (
660          <Text dimColor>{`${fmtCount(promptTokens(last))} tok`}</Text>
661        )}
662        {advice.kind !== 'uncached' && advice.kind !== 'off' && advice.kind !== 'cold' && (
663          <Text bold color={left > 0 ? lifeColor(left, ttl, policy.warnMs) : 'red'}>{left > 0 ? `⏱ ${fmtClock(left)}` : '⏱ 0:00'}</Text>
664        )}
665        {showIdle && idleAt !== undefined && <Text bold color="magenta">{idleClock(idleAt, now)}</Text>}
666        <Text dimColor wrap="truncate-end">{`${ttl} · ${showIdle && idleAt !== undefined ? (idleAt > now ? 'left idle, Claude Code compacts it before the cache lapses' : 'compacting while idle') : advice.text}`}</Text>
667      </Box>,
668    )
669  })
670
671  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
672    if (e.requestId !== PANE) return next(e)
673    const { Box, Text, Button } = $.ui.resolve(e)
674    const width = Math.max(30, e.props.bodyColumns - 1)
675    // HTML collapses runs of spaces and trims a text's ends; a no-break space keeps them
676    const sp = (t: string) => (e.surface === 'terminal' ? t : t.replace(/ /g, ' '))
677    const now = Date.now()
678    const { last, advice, left, idleAt } = current(policy, now)
679    const all = byTurn(samples)
680    const counting = !!last && advice.kind !== 'uncached' && advice.kind !== 'off' && advice.kind !== 'cold'
681    // the countdown goes green, then yellow, then red as the cache runs out
682    const clockColor = counting ? lifeColor(left, ttl, policy.warnMs) : undefined
683    const stateColor = advice.kind === 'expired' || advice.kind === 'miss' ? 'red' : (clockColor ?? COLOR[advice.kind])
684    const hitColor = (pct: number) => (pct >= 80 ? 'green' : pct >= 40 ? 'yellow' : 'red')
685    // solid bars are filled Boxes, not block characters: no seams in HTML, and no glyph that reads as a status-line bar
686    const solid = (key: string, parts: [number, string | undefined][]) => (
687      <Box key={key} flexDirection="row" height={1} flexShrink={0}>
688        {parts.map(([w, c], i) => (w > 0 ? <Box key={`${key}:${i}`} width={w} height={1} flexShrink={0} backgroundColor={c} /> : null))}
689      </Box>
690    )
691    const cell = (key: string, w: number, text: string, c?: string, bold = false) => (
692      <Box key={key} width={w} flexShrink={0} justifyContent="flex-end">
693        <Text color={c} bold={bold} dimColor={!c}>{sp(text)}</Text>
694      </Box>
695    )
696
697    const barW = Math.min(width, 40)
698    const life = lifeRatio(left, ttl)
699    const lifeFilled = Math.round(life * barW)
700    const [sr, sw, sn] = last ? segments(last.read, last.write, last.fresh, barW) : [0, 0, 0]
701    const rows = all.slice(-Math.max(3, (e.viewport?.rows ?? 24) - 16))
702    // why the idle-compaction line says what it says
703    const idleDetail = (at: number | undefined) => {
704      if (!idle.on) return `${idle.why}`
705      if (at !== undefined) return `if nobody types, Claude Code compacts at ${Math.round(idle.fraction * 100)}% of the 1h cache, ${idle.why}`
706      if (ttl !== '1h') return `needs the 1h cache; this session's is ${ttl}`
707      if (last && promptTokens(last) < idle.minTokens) return `waits for ${fmtCount(idle.minTokens)} tokens; the prompt is ${fmtCount(promptTokens(last))}`
708      return `armed while the cache is warm, ${idle.why}`
709    }
710
711    return (
712      <Box flexDirection="column">
713        <Box key="title" flexDirection="row" columnGap={1}>
714          <Text bold color="cyan">{sp('⚡ PROMPT CACHE')}</Text>
715          <Text dimColor>{sp(`· ${ttl} lifetime (${ttlSource})`)}</Text>
716        </Box>
717
718        <Box key="clock" flexDirection="column" marginTop={1}>
719          <Text bold color={clockColor}>{sp(counting ? `⏱ ${left > 0 ? fmtClock(left) : '0:00'}` : '⏱ --:--')}</Text>
720          {counting ? (
721            <Box flexDirection="row" columnGap={1}>
722              {solid('life', [[lifeFilled, clockColor], [barW - lifeFilled, 'gray']])}
723              <Text dimColor>{sp(`${Math.round(life * 100)}%`)}</Text>
724            </Box>
725          ) : null}
726        </Box>
727
728        <Box key="advice" marginTop={1} flexDirection="column">
729          <Text bold color={stateColor}>{sp(`${ICON[advice.kind]} ${advice.text}`)}</Text>
730          {last ? <Text dimColor>{sp(fit(`${last.model} · prompt ${fmtCount(promptTokens(last))} tokens`, width))}</Text> : null}
731        </Box>
732
733        {showIdle ? (
734          <Box key="idle" marginTop={1} flexDirection="column">
735            <Text bold color={idleAt !== undefined ? 'magenta' : undefined} dimColor={idleAt === undefined}>
736              {sp(idleAt !== undefined ? `${idleClock(idleAt, now)} idle compaction` : `⇣ idle compaction ${idle.on ? 'on' : 'off'}`)}
737            </Text>
738            <Text dimColor>{sp(fit(idleDetail(idleAt), width))}</Text>
739          </Box>
740        ) : null}
741
742        {last ? (
743          <Box key="stack" flexDirection="column" marginTop={1}>
744            <Box flexDirection="row" columnGap={1}>
745              {solid('stack', [[sr, 'green'], [sw, 'yellow'], [sn, 'cyan']])}
746              <Text bold color={hitColor(Math.round(hitRatio(last) * 100))}>{sp(`${Math.round(hitRatio(last) * 100)}% hit`)}</Text>
747            </Box>
748            <Box flexDirection="row" columnGap={2}>
749              <Text color="green">{sp(`■ read ${fmtCount(last.read)}`)}</Text>
750              <Text color="yellow">{sp(`■ wrote ${fmtCount(last.write)}`)}</Text>
751              <Text color="cyan">{sp(`■ new ${fmtCount(last.fresh)}`)}</Text>
752            </Box>
753          </Box>
754        ) : null}
755
756        <Box key="table" flexDirection="column" marginTop={1}>
757          <Box key="head" flexDirection="row" columnGap={1}>
758            {cell('h:turn', 4, 'turn', 'cyan', true)}
759            {cell('h:steps', 5, 'steps', 'cyan', true)}
760            {cell('h:read', 6, 'read', 'green', true)}
761            {cell('h:wrote', 6, 'wrote', 'yellow', true)}
762            {cell('h:new', 5, 'new', 'cyan', true)}
763            {cell('h:hit', 4, 'hit', 'magenta', true)}
764          </Box>
765          {rows.length === 0 ? <Text dimColor>{sp('no requests yet')}</Text> : null}
766          {rows.map((row, i) => {
767            const n = all.length - rows.length + i + 1
768            const pct = Math.round(rowRatio(row) * 100)
769            return (
770              <Box key={`t:${row.turnId}`} flexDirection="row" columnGap={1}>
771                {cell(`c:turn:${row.turnId}`, 4, String(n))}
772                {cell(`c:steps:${row.turnId}`, 5, String(row.steps))}
773                {cell(`c:read:${row.turnId}`, 6, fmtCount(row.read), 'green')}
774                {cell(`c:wrote:${row.turnId}`, 6, fmtCount(row.write), 'yellow')}
775                {cell(`c:new:${row.turnId}`, 5, fmtCount(row.fresh), 'cyan')}
776                {cell(`c:hit:${row.turnId}`, 4, `${pct}%`, hitColor(pct), true)}
777              </Box>
778            )
779          })}
780        </Box>
781
782        <Box key="foot" marginTop={1} flexDirection="column">
783          <Button key="close" label="close" onPress={() => {}} />
784          <Box key="legend" marginTop={1} flexDirection="column">
785            <Text color="green">{sp('■ read: served by the cache')}</Text>
786            <Text color="yellow">{sp('■ wrote: new cache entry')}</Text>
787            <Text color="cyan">{sp('■ new: sent uncached')}</Text>
788          </Box>
789        </Box>
790      </Box>
791    )
792  })
793}
794
hooks/cache.ts 401 lines
1/**
2 * cache.ts — the pure half of the prompt-cache meter: no `$`, no engine.
3 *
4 * Taken from prompt-cache-control 0.1.0 (hooks/cache.ts) by Daniel (San) Ávila, part of
5 * claude-code-templates, under the MIT licence; see LICENSE.
6 * Changed here: bar() is gone. Tools read the status line's ▓░ bars off the
7 * screen, so the meter draws no bar of its own.
8 *
9 * What it models, from Anthropic's prompt-caching documentation:
10 *   - the cache lives 5 minutes by default, 1 hour when asked for; a read
11 *     refreshes the entry at no extra cost, and the lifetime is measured from
12 *     the START of the request that wrote or read it
13 *   - a request's prompt is `input_tokens` (uncached remainder) +
14 *     `cache_read_input_tokens` + `cache_creation_input_tokens`
15 *   - writes cost 1.25x base input for 5m and 2x for 1h; reads about 0.1x
16 *     (less on some models), so an expired cache on a large context is the
17 *     expensive moment
18 *   - a prefix change (model, effort/thinking settings, tool set, system
19 *     prompt) makes the next request write instead of read
20 *
21 * Claude Code's own switches (read from the environment):
22 *   ENABLE_PROMPT_CACHING_1H=1   ask for the 1-hour TTL
23 *   FORCE_PROMPT_CACHING_5M=1    force the 5-minute TTL, beating the above
24 *   DISABLE_PROMPT_CACHING=1     no caching; DISABLE_PROMPT_CACHING_{HAIKU,SONNET,OPUS}
25 *                                 turn it off for that model family only
26 */
27
28export type Ttl = '5m' | '1h'
29
30export type CacheEnv = {
31  enable1h?: string
32  force5m?: string
33  /** CLAUDE_CODE_PROMPT_CACHE_TTL: "5m" or "1h" for the main conversation */
34  ttlVar?: string
35  disableAll?: string
36  disableHaiku?: string
37  disableSonnet?: string
38  disableOpus?: string
39}
40
41/** One main-loop request, as the API reported it. */
42export type Sample = {
43  turnId: string
44  index: number
45  model: string
46  /** ms since the epoch when the request started: the cache's lifetime is counted from here */
47  startedAt: number
48  read: number
49  write: number
50  fresh: number
51  output: number
52}
53
54export type AdviceKind = 'off' | 'cold' | 'uncached' | 'warm' | 'soon' | 'expired' | 'miss'
55
56export type Advice = {
57  kind: AdviceKind
58  /** one sentence for the band */
59  text: string
60}
61
62export type Policy = {
63  ttl: Ttl
64  warnMs: number
65  compactAtTokens: number
66}
67
68export const isOn = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
69
70/** What the account is billed as, as far as the mod can tell. */
71export type Account = 'subscription' | 'credits' | 'other'
72
73export type TtlChoice = { ttl: Ttl; source: string }
74
75const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
76
77/**
78 * Which lifetime Claude Code asks for on the main conversation, in the order
79 * its documentation gives (the prompt-caching page of the Claude Code docs,
80 * "Choose the TTL yourself"), after the mod's own `ttl` option:
81 *
82 *   FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl
83 *   setting, ENABLE_PROMPT_CACHING_1H, then the default of the account: one
84 *   hour on a Claude subscription within its plan usage, five minutes on usage
85 *   credits, an API key or a cloud provider.
86 */
87export function decideTtl(option: unknown, env: CacheEnv, setting?: unknown, account?: Account): TtlChoice {
88  const pinned = asTtl(option)
89  if (pinned) return { ttl: pinned, source: 'the ttl option' }
90  if (isOn(env.force5m)) return { ttl: '5m', source: 'FORCE_PROMPT_CACHING_5M' }
91  const fromVar = asTtl(env.ttlVar)
92  if (fromVar) return { ttl: fromVar, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
93  const fromSetting = asTtl(setting)
94  if (fromSetting) return { ttl: fromSetting, source: 'the promptCacheTtl setting' }
95  if (isOn(env.enable1h)) return { ttl: '1h', source: 'ENABLE_PROMPT_CACHING_1H' }
96  if (account === 'subscription') return { ttl: '1h', source: 'Claude subscription default' }
97  if (account === 'credits') return { ttl: '5m', source: 'usage credits default' }
98  return { ttl: '5m', source: 'default' }
99}
100
101export const resolveTtl = (option: unknown, env: CacheEnv, setting?: unknown, account?: Account): Ttl =>
102  decideTtl(option, env, setting, account).ttl
103
104/**
105 * The account, from the rate-limit windows the last response reported: a
106 * five-hour or seven-day window means a Claude subscription, and one that is
107 * full means the next requests draw on usage credits. No window (an API key,
108 * a cloud provider, or no response yet) says nothing.
109 */
110export function accountOf(windows: readonly { kind: string; percentUsed: number }[]): Account {
111  const plan = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
112  if (plan.length === 0) return 'other'
113  return plan.some(w => w.percentUsed >= 100) ? 'credits' : 'subscription'
114}
115
116export function ttlMs(ttl: Ttl): number {
117  return ttl === '1h' ? 3_600_000 : 300_000
118}
119
120/** Caching switched off for this model by the environment. */
121export function isCachingDisabled(model: string, env: CacheEnv): boolean {
122  if (isOn(env.disableAll)) return true
123  const name = model.toLowerCase()
124  if (name.includes('haiku')) return isOn(env.disableHaiku)
125  if (name.includes('sonnet')) return isOn(env.disableSonnet)
126  if (name.includes('opus')) return isOn(env.disableOpus)
127  return false
128}
129
130export const promptTokens = (s: Sample) => s.read + s.write + s.fresh
131
132/** Share of the prompt the cache served, 0 to 1; 0 for an empty prompt. */
133export function hitRatio(s: Sample): number {
134  const total = promptTokens(s)
135  return total === 0 ? 0 : s.read / total
136}
137
138/** When the cache entry the sample touched lapses, ms since the epoch. */
139export const expiresAt = (s: Sample, ttl: Ttl) => s.startedAt + ttlMs(ttl)
140
141/** Zero for a request that read and wrote nothing: it created or refreshed no entry, so there is nothing to count down. */
142export function remainingMs(s: Sample, ttl: Ttl, now: number): number {
143  if (s.read + s.write === 0) return 0
144  return Math.max(0, expiresAt(s, ttl) - now)
145}
146
147/**
148 * Why a request that should have read the cache wrote it instead; undefined
149 * when it did not miss. A prompt that shrank is a /compact or /clear, not a
150 * miss, and the first request of a session has nothing to read.
151 */
152export function missReason(prev: Sample | undefined, cur: Sample, ttl: Ttl): string | undefined {
153  if (!prev) return undefined
154  const before = promptTokens(prev)
155  if (before === 0 || promptTokens(cur) < before * 0.7) return undefined
156  if (cur.read >= before * 0.5 || cur.write === 0) return undefined
157  if (cur.model !== prev.model) return `model changed (${prev.model} to ${cur.model})`
158  if (cur.startedAt - prev.startedAt > ttlMs(ttl)) return `the ${ttl} cache had lapsed`
159  return 'the prompt prefix changed (effort, tools, system prompt or CLAUDE.md)'
160}
161
162export function advise(last: Sample | undefined, prev: Sample | undefined, policy: Policy, now: number, disabled: boolean): Advice {
163  if (disabled) return { kind: 'off', text: 'prompt caching is off for this model (DISABLE_PROMPT_CACHING*)' }
164  if (!last) return { kind: 'cold', text: 'no request yet: the first one writes the cache' }
165  if (last.read + last.write === 0) {
166    return { kind: 'uncached', text: 'this request was not cached (prompt under the model minimum, or caching off)' }
167  }
168  const miss = missReason(prev, last, policy.ttl)
169  const left = remainingMs(last, policy.ttl, now)
170  const size = promptTokens(last)
171  if (left <= 0) {
172    const big = size >= policy.compactAtTokens
173    return {
174      kind: 'expired',
175      text: big
176        ? `expired: the next message rewrites ${fmtCount(size)} tokens. /compact first, or /clear if the task is done`
177        : `expired: only ${fmtCount(size)} tokens to rebuild, just keep going`,
178    }
179  }
180  if (left <= policy.warnMs) {
181    return { kind: 'soon', text: 'expires soon: any message refreshes it for free' }
182  }
183  if (miss) return { kind: 'miss', text: `cache missed: ${miss}` }
184  return { kind: 'warm', text: 'warm: keep going' }
185}
186
187export function fmtCount(n: number): string {
188  if (n < 1000) return String(n)
189  if (n < 100_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
190  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
191  return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
192}
193
194/** m:ss, or h:mm:ss from an hour up. */
195export function fmtClock(ms: number): string {
196  const total = Math.max(0, Math.ceil(ms / 1000))
197  const h = Math.floor(total / 3600)
198  const m = Math.floor((total % 3600) / 60)
199  const s = total % 60
200  const pad = (n: number) => String(n).padStart(2, '0')
201  return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
202}
203
204export type TurnRow = {
205  turnId: string
206  steps: number
207  read: number
208  write: number
209  fresh: number
210  output: number
211}
212
213/** Samples grouped by turn, oldest first, each turn's requests summed. */
214export function byTurn(samples: readonly Sample[]): TurnRow[] {
215  const rows: TurnRow[] = []
216  for (const s of samples) {
217    let row = rows[rows.length - 1]
218    if (!row || row.turnId !== s.turnId) {
219      row = { turnId: s.turnId, steps: 0, read: 0, write: 0, fresh: 0, output: 0 }
220      rows.push(row)
221    }
222    row.steps += 1
223    row.read += s.read
224    row.write += s.write
225    row.fresh += s.fresh
226    row.output += s.output
227  }
228  return rows
229}
230
231export const rowRatio = (r: TurnRow) => {
232  const total = r.read + r.write + r.fresh
233  return total === 0 ? 0 : r.read / total
234}
235
236export function fit(text: string, width: number): string {
237  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
238}
239
240export function positive(v: unknown, fallback: number): number {
241  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback
242}
243
244/** Share of the cache lifetime left, 0 to 1. */
245export function lifeRatio(leftMs: number, ttl: Ttl): number {
246  return Math.min(1, Math.max(0, leftMs / ttlMs(ttl)))
247}
248
249/**
250 * Widths of the three stacked-bar segments (read, wrote, new) over `width`
251 * cells: proportional, each non-empty part at least one cell, summing to width.
252 */
253export function segments(read: number, write: number, fresh: number, width: number): [number, number, number] {
254  const total = read + write + fresh
255  if (total === 0 || width <= 0) return [0, 0, 0]
256  const parts = [read, write, fresh]
257  const cells = parts.map(p => (p > 0 ? Math.max(1, Math.round((p / total) * width)) : 0))
258  let over = cells.reduce((a, b) => a + b, 0) - width
259  while (over !== 0) {
260    const i = over > 0 ? cells.indexOf(Math.max(...cells)) : parts.indexOf(Math.max(...parts))
261    cells[i] = (cells[i] ?? 0) + (over > 0 ? -1 : 1)
262    over += over > 0 ? -1 : 1
263  }
264  return [cells[0] ?? 0, cells[1] ?? 0, cells[2] ?? 0]
265}
266
267/** Seconds left at which a toast counts down after the one at the warning threshold. */
268export const COUNTDOWN_MARKS = [10, 3, 2, 1]
269
270/**
271 * The toast mark to fire now, or undefined. `level` is the mark last fired for
272 * this cache entry (Infinity before any); a late tick skips straight to the
273 * newest mark crossed, so a stalled clock never replays old ones.
274 */
275export function nextToastMark(secsLeft: number, warnSecs: number, level: number): number | undefined {
276  const marks = [warnSecs, ...COUNTDOWN_MARKS].filter(m => m <= warnSecs)
277  const due = marks.filter(m => secsLeft <= m && m < level)
278  return due.length ? Math.min(...due) : undefined
279}
280
281export type LifeColor = 'green' | 'yellow' | 'red'
282
283/** Countdown colour: green while there is plenty, yellow below 40% of the lifetime, red from the warning threshold down. */
284export function lifeColor(leftMs: number, ttl: Ttl, warnMs: number): LifeColor {
285  if (leftMs <= warnMs) return 'red'
286  return leftMs / ttlMs(ttl) <= 0.4 ? 'yellow' : 'green'
287}
288
289// requests are timed from their start, so a little slack keeps a hit that
290// landed just inside the lifetime from reading as proof of the longer one
291const SLACK_MS = 10_000
292
293/**
294 * What the traffic says about the cache lifetime, given the request before and
295 * `known`, what earlier requests already showed.
296 *
297 *   - a hit (the cache served at least half of the previous prompt) more than
298 *     5 minutes after the previous request began proves the 1-hour lifetime,
299 *     and nothing later undoes it: a miss afterwards is more likely a changed
300 *     prefix than a lapse
301 *   - a miss with the same model and a prompt that did not shrink, 5 minutes to
302 *     an hour after the previous request, says the entry lapsed: 5 minutes
303 *     (weaker: a changed prefix looks the same, so a later hit overrules it)
304 *
305 * Needed because the API names the TTL of a write (`cache_creation.ephemeral_*`)
306 * but Claude Code's mod API passes on only the four token counts.
307 */
308export function observeTtl(prev: Sample | undefined, cur: Sample, known: Ttl | undefined): Ttl | undefined {
309  if (!prev || prev.read + prev.write === 0 || cur.model !== prev.model) return known
310  const gap = cur.startedAt - prev.startedAt
311  const before = promptTokens(prev)
312  if (gap <= ttlMs('5m') + SLACK_MS) return known
313  if (cur.read >= before * 0.5) return '1h'
314  if (known === '1h') return known
315  const lapsed = cur.write > 0 && promptTokens(cur) >= before * 0.7 && gap < ttlMs('1h') + SLACK_MS
316  return lapsed ? '5m' : known
317}
318
319/**
320 * Idle compaction (Claude Code 2.1.290+): a terminal conversation left idle is
321 * compacted shortly before its one-hour cache lapses, so the next message reads
322 * a summary instead of rewriting the whole prefix. Claude Code fires it at
323 * `fraction` of the cache lifetime (0.9 of an hour: about 54 minutes) when all
324 * of these hold, read from the 2.1.299 build:
325 *
326 *   - the rollout flag is on for the account (a server-side flag Claude Code
327 *     caches in its global config; `mode: "off"` there means it never runs, and
328 *     `idleCompaction: true` does not turn it on)
329 *   - auto-compact is on (no DISABLE_COMPACT / DISABLE_AUTO_COMPACT,
330 *     autoCompactEnabled not false) and the idleCompaction setting is not false
331 *   - the main conversation's cache lives one hour and is still warm
332 *   - the prompt is at least minTokens: CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS,
333 *     never below 100K, 200K when unset
334 */
335export const IDLE_MIN_FLOOR = 100_000
336export const IDLE_MIN_DEFAULT = 200_000
337export const IDLE_FRACTION = 0.9
338
339export type IdleInputs = {
340  /** CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS */
341  minTokensVar?: string
342  /** the idleCompaction setting */
343  setting?: unknown
344  /** the autoCompactEnabled setting */
345  autoCompact?: unknown
346  /** DISABLE_AUTO_COMPACT */
347  disableAuto?: string
348  /** DISABLE_COMPACT */
349  disableAll?: string
350  /** the cached rollout flag, `{ mode, minTokens?, fireAtFraction? }`; undefined when not found */
351  flag?: unknown
352}
353
354export type IdleCompact = {
355  /** whether Claude Code will compact this session while idle */
356  on: boolean
357  /** false when the rollout flag could not be read (then `on` is false) */
358  known: boolean
359  minTokens: number
360  fraction: number
361  /** one phrase: why it is off, or where the threshold came from */
362  why: string
363}
364
365const finite = (v: unknown): number | undefined => {
366  const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v
367  return typeof n === 'number' && Number.isFinite(n) ? n : undefined
368}
369
370export function idlePolicy(i: IdleInputs): IdleCompact {
371  const flag = i.flag && typeof i.flag === 'object' ? (i.flag as Record<string, unknown>) : undefined
372  const fromVar = finite(i.minTokensVar)
373  const raw = fromVar ?? finite(flag?.minTokens)
374  const minTokens = raw === undefined ? IDLE_MIN_DEFAULT : Math.max(IDLE_MIN_FLOOR, raw)
375  const f = finite(flag?.fireAtFraction)
376  const fraction = f === undefined ? IDLE_FRACTION : Math.min(0.95, Math.max(0.5, f))
377  const base = { minTokens, fraction, known: flag !== undefined }
378  if (isOn(i.disableAll) || isOn(i.disableAuto)) return { ...base, on: false, why: 'auto-compact is off (DISABLE_*COMPACT)' }
379  if (i.autoCompact === false) return { ...base, on: false, why: 'auto-compact is off (autoCompactEnabled)' }
380  if (i.setting === false) return { ...base, on: false, why: 'turned off (idleCompaction: false)' }
381  // without the cached flag there is no telling: a countdown to something that may never happen is worse than none
382  if (!flag) return { ...base, on: false, why: "rollout flag not found in Claude Code's global config" }
383  const mode = flag.mode
384  if (mode !== 'compact' && mode !== 'compact_on_return') {
385    return { ...base, on: false, why: `not rolled out to this account (mode ${String(mode ?? 'off')})` }
386  }
387  const source = fromVar !== undefined ? 'CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS' : 'default'
388  return { ...base, on: true, why: `from ${fmtCount(minTokens)} tokens (${source})` }
389}
390
391/**
392 * When Claude Code will compact this conversation if nobody types, ms since
393 * the epoch; undefined when it will not (off, a 5-minute cache, a prompt under
394 * the threshold, or a cache already lapsed).
395 */
396export function idleCompactAt(last: Sample | undefined, ttl: Ttl, idle: IdleCompact, now: number): number | undefined {
397  if (!last || !idle.on || ttl !== '1h') return undefined
398  if (promptTokens(last) < idle.minTokens || remainingMs(last, ttl, now) <= 0) return undefined
399  return last.startedAt + ttlMs(ttl) * idle.fraction
400}
401
hooks/line.ts 202 lines
1/**
2 * line.ts — the pure half of the status line: no `$`, no engine.
3 *
4 *   Opus 5.5 · xhigh │ ▓▓░░░░░░░░ 21.0% │ ⎇ main (+12,-3) │ 1hr 5m
5 *   5h ▓░░░░░░░░░ 3.0% (2h33m) │ wk ▓▓░░░░░░░░ 19.0% (5d33m) │ cache 92% · 41m
6 *
7 * Tools that read these figures off the screen take the first ▓░ bar for the
8 * context and the bars labelled `5h` and `wk` for the limits: keep the bars,
9 * the labels and the order.
10 */
11import { fmtClock } from './cache.ts'
12
13export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
14
15export type Context = { tokens?: number; window: number; percent?: number }
16
17export type LineInput = {
18  model: string
19  effort: string
20  context: Context
21  /** undefined outside a git work tree */
22  branch?: string
23  /** undefined when not counted (outside a work tree, or gitChanges off) */
24  changes?: { ins: number; del: number }
25  durationMs?: number
26  limits: readonly Limit[]
27  /** ms since the epoch */
28  now: number
29  /** the cache segment, cacheText's; undefined leaves it out */
30  cache?: string
31}
32
33const SEP = ' │ '
34
35/** 10 cells of ▓ and ░, one per 10%. */
36export function bar10(pct: number): string {
37  const filled = Math.round(Math.min(100, Math.max(0, pct)) / 10)
38  return '▓'.repeat(filled) + '░'.repeat(10 - filled)
39}
40
41/** One decimal, always: 21 → "21.0". */
42export const pct1 = (pct: number) => (Math.round(pct * 10) / 10).toFixed(1)
43
44/** The context fill to one decimal, from the token counts when the engine has them. */
45export function contextPercent(c: Context): number {
46  if (c.tokens !== undefined && c.window > 0) return (c.tokens / c.window) * 100
47  return c.percent ?? 0
48}
49
50/** Session length: "<1m", "42m", "2hr", "1hr 5m". */
51export function fmtDuration(ms: number | undefined): string {
52  if (ms === undefined || !Number.isFinite(ms)) return '0m'
53  const tm = Math.floor(ms / 60_000)
54  if (tm < 1) return '<1m'
55  if (tm < 60) return `${tm}m`
56  if (tm % 60 === 0) return `${tm / 60}hr`
57  return `${Math.floor(tm / 60)}hr ${tm % 60}m`
58}
59
60/** Time until a reset: "2h33m", "5d33m", "0m" once passed. */
61export function fmtLeft(resetsAtMs: number, now: number): string {
62  const tm = Math.max(0, Math.floor((resetsAtMs - now) / 60_000))
63  const d = Math.floor(tm / 1440)
64  const h = Math.floor((tm % 1440) / 60)
65  const m = tm % 60
66  const parts = [d > 0 ? `${d}d` : '', h > 0 ? `${h}h` : '', m > 0 ? `${m}m` : ''].filter(Boolean)
67  return parts.length === 0 ? '0m' : parts.join('')
68}
69
70const FAMILY = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i
71
72/** "claude-opus-5-5[1m]" → "Opus 5.5 (1M context)"; a name already shown that way stays. */
73export function prettyModel(model: string): string {
74  const m = FAMILY.exec(model.trim())
75  if (!m) return model.trim() || '?'
76  const [, name = '', major = '', minor, wide] = m
77  const family = name.charAt(0).toUpperCase() + name.slice(1).toLowerCase()
78  const version = minor === undefined ? major : `${major}.${minor}`
79  return `${family} ${version}${wide ? ' (1M context)' : ''}`
80}
81
82/** The branch from a HEAD file: "ref: refs/heads/x" → "x", a detached sha → its first 7. */
83export function parseHead(head: string): string | undefined {
84  const line = (head.split('\n')[0] ?? '').trim()
85  if (line.startsWith('ref: refs/heads/')) return line.slice('ref: refs/heads/'.length)
86  if (line.startsWith('ref: ')) return line.slice(5)
87  return line === '' ? undefined : line.slice(0, 7)
88}
89
90/** Where a worktree's `.git` file points: "gitdir: ../x/.git/worktrees/y", relative to `dir`. */
91export function parseGitFile(text: string, dir: string): string | undefined {
92  const m = /^gitdir:\s*(.+)$/m.exec(text)
93  if (!m) return undefined
94  const path = (m[1] ?? '').trim()
95  return path.startsWith('/') ? path : `${dir}/${path}`
96}
97
98/** `git diff --shortstat` → insertions and deletions; empty output is (+0,-0). */
99export function parseShortstat(out: string): { ins: number; del: number } {
100  const ins = /(\d+) insertions?\(\+\)/.exec(out)
101  const del = /(\d+) deletions?\(-\)/.exec(out)
102  return { ins: ins ? Number(ins[1]) : 0, del: del ? Number(del[1]) : 0 }
103}
104
105export function fitBranch(branch: string): string {
106  return branch.length > 26 ? `${branch.slice(0, 25)}…` : branch
107}
108
109/** One run of text and its colour (an Ink colour name). */
110export type Seg = { text: string; color?: string; bold?: boolean }
111
112const DIM = 'gray'
113
114function limit(label: string, w: Limit | undefined, now: number): Seg[] {
115  if (!w) return []
116  const reset = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
117  const segs: Seg[] = [
118    { text: `${label} `, color: DIM },
119    { text: `${bar10(w.percentUsed)} ${pct1(w.percentUsed)}%`, color: 'greenBright' },
120  ]
121  if (Number.isFinite(reset)) segs.push({ text: ' (', color: DIM }, { text: fmtLeft(reset, now), color: 'white' }, { text: ')', color: DIM })
122  return segs
123}
124
125const sep = (): Seg => ({ text: SEP, color: DIM })
126
127/** One figure and the separator after it: a row wraps between figures, never inside one. */
128export type Group = Seg[]
129
130const joinGroups = (groups: Seg[][]): Group[] => groups.map((g, n) => (n < groups.length - 1 ? [...g, sep()] : g))
131
132/** The line in two rows of figures: `top` (model to session length) and `bottom` (the windows, then the cache). */
133export function lineSegments(i: LineInput & { cacheColor?: string }): { top: Group[]; bottom: Group[] } {
134  const ctx = contextPercent(i.context)
135  const top: Seg[][] = [
136    [
137      { text: i.model, color: 'cyanBright', bold: true },
138      { text: ' · ', color: DIM },
139      { text: i.effort, color: DIM },
140    ],
141    [{ text: `${bar10(ctx)} ${pct1(ctx)}%`, color: 'blueBright' }],
142  ]
143  if (i.branch !== undefined) {
144    const branch: Seg[] = [{ text: `⎇ ${fitBranch(i.branch)}`, color: 'magentaBright' }]
145    if (i.changes) branch.push({ text: ` (+${i.changes.ins},-${i.changes.del})`, color: 'yellowBright' })
146    top.push(branch)
147  }
148  top.push([{ text: fmtDuration(i.durationMs), color: 'white' }])
149  const bottom = [
150    limit('5h', i.limits.find(w => w.kind === 'five_hour'), i.now),
151    limit('wk', i.limits.find(w => w.kind === 'seven_day'), i.now),
152    i.cache === undefined ? [] : [{ text: i.cache, color: i.cacheColor }],
153  ].filter(g => g.length > 0)
154  return { top: joinGroups(top), bottom: joinGroups(bottom) }
155}
156
157export const plain = (groups: readonly Group[]) =>
158  groups
159    .flat()
160    .map(s => s.text)
161    .join('')
162
163/** The two rows as one line of text, joined by the separator: what the status entry shows. */
164export function composeLine(i: LineInput): string {
165  const { top, bottom } = lineSegments(i)
166  return bottom.length === 0 ? plain(top) : `${plain(top)}${SEP}${plain(bottom)}`
167}
168
169/** What the cache segment says, by the meter's state. */
170export type CacheView =
171  | { state: 'none' }
172  | { state: 'off' }
173  | { state: 'uncached' }
174  | { state: 'compacted' }
175  | { state: 'live'; ratio: number; leftMs: number; warnMs: number; isMiss: boolean; idleInMs?: number }
176
177/**
178 * "cache 92% · 41m", "cache 92% · 0:45" inside the warning, "cache 92% ·
179 * expired", "· miss" after a request that should have read and wrote, and
180 * "· ⇣ 35m" while Claude Code will compact the idle conversation by itself.
181 * No request yet: nothing.
182 */
183export function cacheText(v: CacheView): string | undefined {
184  switch (v.state) {
185    case 'none':
186      return undefined
187    case 'off':
188      return 'cache off'
189    case 'uncached':
190      return 'cache none'
191    case 'compacted':
192      return 'cache compacted'
193    case 'live': {
194      const left = v.leftMs <= 0 ? 'expired' : v.leftMs > v.warnMs ? `${Math.ceil(v.leftMs / 60_000)}m` : fmtClock(v.leftMs)
195      const miss = v.isMiss ? ' · miss' : ''
196      const idle =
197        v.idleInMs === undefined ? '' : ` · ⇣ ${v.idleInMs > 0 ? `${Math.ceil(v.idleInMs / 60_000)}m` : 'now'}`
198      return `cache ${Math.round(v.ratio * 100)}% · ${left}${miss}${idle}`
199    }
200  }
201}
202