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…

![]()
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.
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.
| Figure | Source | Process |
|---|---|---|
| model | $.session.model() | none |
| effort | the main loop's last request; before the first one, the level this model ran at last time, or the effortLevel setting | none |
| context, 5h, wk | $.session.usage() at the start, then session.measure | none |
| session length | usage().startedAt | none |
| 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 turn | one per turn; none with gitChanges: false |
| cache | the token counts of each main-loop request | none |
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.
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..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..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.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, DISABLE_PROMPT_CACHING*, DISABLE_COMPACT, DISABLE_AUTO_COMPACT, CLAUDE_CONFIG_DIR and HOME.{"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.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.
| Hook | What it does |
|---|---|
session.start, session.end | registers /cache, reads the settings above, starts and stops the one-second timer; /clear resets the meter |
session.measure | takes the context fill and the limit windows |
turn.step | takes each main-loop request's effort and its four token counts; the request goes on as it was |
turn.complete | marks model, effort and git for a refresh after a turn |
prompt.submit | marks the line for a refresh after a prompt (a /model, /effort or ! command may change it); the prompt goes on as typed |
session.compact | notes a compaction, so the meter stops counting down the old cache |
command.run (/cache), ui.close, ui.press | open and close the /cache pane |
ui.render on PromptHint, AbovePrompt, Pane | draws the two rows, the band and the pane |
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): 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./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.
Set them in the /config menu, or under pluginConfigs["session-line@claude-session-line"].options in ~/.claude/settings.json:
| Option | Default | |
|---|---|---|
place | hint | hint: two rows under the prompt; status: one plain status entry, which Claude Code labels ⚠ session-line: |
ttl | auto | auto, 5m or 1h |
warnSeconds | 60 | when the countdown turns to seconds, the band asks for a message and toasts start |
compactAtTokens | 100000 | prompt size at which a lapsed cache is worth a /compact |
band | action | action, always or off |
toast | true | toasts near expiry |
idleCompact | true | idle-compaction countdown and notice |
gitChanges | true | (+ins,-del) from one git diff per turn |
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.
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/.
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.
hooks/register.tsx 794 lines1/**
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}
794hooks/cache.ts 401 lines1/**
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}
401hooks/line.ts 202 lines1/**
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