A footer showing the model and effort, how full the context window is, and the prompt cache, with one-click compact and clear

A footer for Claude Code. In the slot right of the hint line under the prompt, it shows:

99% ~58m, then cold once it has lapsed or after a compaction.◆ Opus 5.5 (1M) ▆ high, colored by model family and by effort level. Click either one to get a row of chips and pick another. This runs /model or /effort for you./clear, and in a fresh session, Claude Code has no measured figure until the next response. Meanwhile the bar shows Claude Code's own local estimate (the one /context makes), marked with ~.⇊ and clear ⌫: one click asks first (clear context? yes no). The question goes away by itself after five seconds, so a stray click does nothing.On a narrow terminal the labels shorten, and when one row can't hold everything the blocks stack.
What other mods put in the same slot, such as warm-compact's on/off chip, goes at the right end of the row, along with the engine's own mode labels.
claude plugin marketplace add ziedgithub/claude-code-mods && claude plugin install context-bar@zied-mods
Then restart Claude Code.
See the repository README for running it from a clone.
hooks/register.tsx 618 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CacheInfo, CacheTtl, ContextAction, ContextFill, ModelInfo, Question, Setting } from '../types'
5import { COLD_TEXT, DEFAULT_TTL, formatRemaining, hitColor, hitPercent, inferTtl, isCacheTtl, remainingMs } from './cache'
6
7const BAR_MIN = 3
8const BAR_MAX = 8
9const BAR_SHARE = 0.05
10const COMPACT_BELOW = 80
11const DEFAULT_COLUMNS = 120
12const BLOCK_GAP = 2
13const EFFORT_GAP = 2
14const GREEN_BELOW = 30
15const ORANGE_BELOW = 60
16const MODEL_POLL_MS = 1000
17const FILL_POLL_MS = 1000
18const CACHE_TICK_MS = 1000
19const CACHE_TTL_KEY = 'cacheTtl'
20const RENEW_TTL_KEY = 'renewTtl'
21const COLD_COLOR = '#58a6ff'
22const CONFIRM_MS = 5000
23const CHOOSE_MS = 10000
24const ACTION_GAP = 2
25const MODEL_ICON = '◆'
26const NEUTRAL = '#8b949e'
27// One glyph each, one cell wide, drawn on a chip of the action's color: a Button takes no
28// color of its own at rest, so the color is the Box behind it
29const ACTIONS: Record<ContextAction, { icon: string; question: string; color: string }> = {
30 compact: { icon: '⇊', question: 'compact context?', color: '#1f6feb' },
31 clear: { icon: '⌫', question: 'clear context?', color: '#da3633' },
32}
33const NO_COLOR = '#6e7681'
34const CONTEXT_ACTIONS: readonly ContextAction[] = ['compact', 'clear']
35// `/effort`'s answer as the transcript keeps it: `Set effort level to high (this session only): …`
36const EFFORT_SET = /<local-command-stdout>Set effort level to ([a-z]+)\b/
37// The same answer as `$.command.run` returns it, before the transcript wraps it
38const EFFORT_ANSWER = /^Set effort level to ([a-z]+)\b/
39
40const MODEL_COLORS: Record<string, string> = {
41 opus: '#bc8cff',
42 sonnet: '#58a6ff',
43 haiku: '#7ee787',
44 fable: '#f778ba',
45}
46
47const EFFORT_STYLES: Record<string, { icon: string; color: string }> = {
48 low: { icon: '▂', color: '#8b949e' },
49 medium: { icon: '▄', color: '#39c5bb' },
50 high: { icon: '▆', color: '#e3b341' },
51 xhigh: { icon: '▇', color: '#ffa657' },
52 max: { icon: '█', color: '#ff7b72' },
53}
54
55// The aliases `/model` takes, each switching to the latest model of its family
56const MODEL_CHOICES = ['opus', 'sonnet', 'haiku', 'fable']
57// The chips' backgrounds are darker than the text colors above, so the terminal's own text
58// color, the only one a Button takes at rest, stays readable on them
59const MODEL_CHIPS: Record<string, string> = {
60 opus: '#8957e5',
61 sonnet: '#1f6feb',
62 haiku: '#238636',
63 fable: '#bf4b8a',
64}
65const EFFORT_CHIPS: Record<string, string> = {
66 low: '#6e7681',
67 medium: '#1b7c83',
68 high: '#9e6a03',
69 xhigh: '#bd561d',
70 max: '#da3633',
71}
72const CURRENT_MARK = '✔'
73const CLOSE_ICON = '✕'
74
75const fill = atom({ plugin: 'context-bar', key: 'fill' } as const, null)
76const model = atom({ plugin: 'context-bar', key: 'model' } as const, null)
77const asking = atom({ plugin: 'context-bar', key: 'asking' } as const, null)
78const cache = atom({ plugin: 'context-bar', key: 'cache' } as const, null)
79
80// The bar takes a twentieth of the terminal width, so it follows resizes
81export const barWidthFor = (columns: number): number =>
82 Math.min(BAR_MAX, Math.max(BAR_MIN, Math.round(columns * BAR_SHARE)))
83
84// The blocks stack, right-aligned, when one row cannot hold them
85export const fitsInRow = (columns: number, rightLength: number): boolean => rightLength <= columns
86
87export const colorFor = (percent: number): string => {
88 if (percent < GREEN_BELOW) return '#3fb950'
89 if (percent < ORANGE_BELOW) return '#ff9500'
90 return '#f85149'
91}
92
93const capitalize = (word: string): string => word.charAt(0).toUpperCase() + word.slice(1)
94
95// `claude-sonnet-5-5` -> `Sonnet 5.5`, `claude-haiku-4-5-20251001` -> `Haiku 4.5`,
96// `claude-3-5-sonnet-20241022` -> `Sonnet 3.5`; anything else is left as received
97export const displayModelName = (id: string): string => {
98 const isLongContext = id.endsWith('[1m]')
99 const bare = id.replace(/\[1m\]$/, '')
100 const suffix = isLongContext ? ' (1M)' : ''
101 const modern = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(bare)
102
103 if (modern !== null) {
104 const [, family = '', major, minor] = modern
105 return `${capitalize(family)} ${minor === undefined ? major : `${major}.${minor}`}${suffix}`
106 }
107
108 const legacy = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)(?:-\d{8})?$/.exec(bare)
109
110 if (legacy !== null) {
111 const [, major, minor, family = ''] = legacy
112 return `${capitalize(family)} ${minor === undefined ? major : `${major}.${minor}`}${suffix}`
113 }
114
115 return id
116}
117
118export type ModelParts = {
119 name: string
120 family: string
121 modelColor: string
122 effort: { icon: string; color: string; label: string } | null
123 length: number
124}
125
126// An effort neither the settings nor a request has told yet is shown as `…`
127export const modelParts = (info: ModelInfo): ModelParts => {
128 const name = displayModelName(info.name)
129 const family = (name.split(' ')[0] ?? '').toLowerCase()
130 const modelColor = MODEL_COLORS[family] ?? NEUTRAL
131
132 let effort: ModelParts['effort'] = null
133
134 if (!info.isEffortKnown) {
135 effort = { icon: '', color: NEUTRAL, label: '…' }
136 } else if (info.effort !== null) {
137 const style = EFFORT_STYLES[info.effort] ?? { icon: '●', color: NEUTRAL }
138 effort = { ...style, label: info.effort }
139 }
140
141 const effortLength =
142 effort === null
143 ? 0
144 : EFFORT_GAP + (effort.icon === '' ? 0 : effort.icon.length + 1) + effort.label.length
145
146 return { name, family, modelColor, effort, length: MODEL_ICON.length + 1 + name.length + effortLength }
147}
148
149const isRecord = (value: unknown): value is Record<string, unknown> =>
150 typeof value === 'object' && value !== null
151
152// What the settings ask of a model until a request reports it: the model's own
153// `modelSettings` row, else the global `effortLevel`. A level the model does not take is
154// only corrected by the next request
155export const configuredEffort = (settings: Readonly<Record<string, unknown>>, modelId: string): string | null => {
156 const rows = settings.modelSettings
157 const row = isRecord(rows) ? rows[modelId.replace(/\[1m\]$/, '')] : undefined
158 const own = isRecord(row) ? row.effortLevel : undefined
159 const level = typeof own === 'string' ? own : settings.effortLevel
160 return typeof level === 'string' && level in EFFORT_STYLES ? level : null
161}
162
163export const withEffort = (name: string, effort: string | null): ModelInfo => ({
164 name,
165 effort,
166 isEffortKnown: effort !== null,
167 isEffortPinned: false,
168})
169
170export const pinEffort = (prev: ModelInfo | null, effort: string): ModelInfo | null =>
171 prev === null ? prev : { ...prev, effort, isEffortKnown: true, isEffortPinned: true }
172
173// `/effort` sets the level for the whole session, so a model switch keeps it; otherwise the
174// new model starts at what the settings ask of it
175export const switchedModel = (prev: ModelInfo | null, name: string, configured: string | null): ModelInfo =>
176 prev !== null && prev.isEffortPinned ? { ...prev, name } : withEffort(name, configured)
177
178const knownLevel = (level: string | undefined): string | null =>
179 level !== undefined && level in EFFORT_STYLES ? level : null
180
181// The level `/effort` set, read from its answer row; null for any other row
182export const effortFromRow = (blocks: readonly { type: string }[]): string | null => {
183 for (const block of blocks) {
184 const text = 'text' in block && typeof block.text === 'string' ? block.text : ''
185 const level = knownLevel(EFFORT_SET.exec(text)?.[1])
186 if (level !== null) return level
187 }
188 return null
189}
190
191// The level `/effort` set, read from the answer `$.command.run` returns; null for a refusal
192export const effortFromAnswer = (text: string | undefined): string | null =>
193 text === undefined ? null : knownLevel(EFFORT_ANSWER.exec(text)?.[1])
194
195// /clear ends the session and the next one starts with empty state, while the engine keeps
196// the model and its effort: they are carried over from the end to the next refresh
197let carried: ModelInfo | null = null
198
199const refreshModel = async ($: EngineInterface): Promise<void> => {
200 const name = await $.session.model()
201 const prev = await read($, model)
202 if (prev !== null && prev.name === name) return
203 const kept = prev === null && carried?.name === name ? carried : null
204 carried = null
205 const effort = configuredEffort(await $.settings.read(), name)
206 await update($, model, () => kept ?? switchedModel(prev, name, effort))
207}
208
209// A turn with no response, an interrupted one, leaves the last fill as it was
210const refreshFill = async ($: EngineInterface): Promise<void> => {
211 const { context } = await $.session.usage()
212 await update($, fill, prev => toFill(context) ?? prev)
213}
214
215// The engine measures the window on each response, and a compaction or a /clear leaves its
216// last measure behind: until the next response the bar shows the engine's own estimate, the
217// one /context makes, counted locally with no request
218const estimateFill = async ($: EngineInterface): Promise<void> => {
219 const { context } = await $.session.usage({ breakdown: 'summary' })
220 const tokens = context.breakdown?.totalTokens
221 if (tokens !== undefined) await update($, fill, () => estimatedFill(tokens, context.window))
222}
223
224// A fresh session, and one `/clear` started, has no fill until its first response
225const estimateMissingFill = async ($: EngineInterface): Promise<void> => {
226 if ((await read($, fill)) === null) await estimateFill($)
227}
228
229export const isAction = (question: Question): question is ContextAction => question in ACTIONS
230
231// A click only asks: the question that replaces the icons runs the command on `yes`, and
232// drops back to the icons after CONFIRM_MS, so a stray click never clears or compacts. The
233// choices that replace the model or the effort close likewise after CHOOSE_MS
234let askTimer: Timer | null = null
235
236// The cache's countdown is drawn from the clock's last reading, and the footer drawn again
237// only when its text changes: once a minute, then once a second for the last one
238let cacheTtl: CacheTtl = DEFAULT_TTL
239// How long a fork's entry lasts, once a request after one has told; until then, a request's
240let renewTtl: CacheTtl | null = null
241let cacheNow = 0
242let shownRemaining = ''
243
244const ttlOf = (info: CacheInfo): CacheTtl => (info.isFork === true ? (renewTtl ?? cacheTtl) : cacheTtl)
245
246const tickCache = async ($: EngineInterface): Promise<void> => {
247 cacheNow = await $.clock.now()
248 const info = await read($, cache)
249 // A model switch makes no request, so the clock sees it too
250 const name = (await read($, model))?.name ?? null
251 let text = ''
252 if (info !== null) text = name !== null && name !== info.model ? COLD_TEXT : formatRemaining(remainingMs(info, ttlOf(info), cacheNow))
253 if (text === shownRemaining) return
254 shownRemaining = text
255 $.ui.invalidate('ui.render')
256}
257
258// The engine keeps its TTL to itself: a request after a long enough pause tells it, and it
259// is kept between sessions since it holds for the account
260const learnTtl = async ($: EngineInterface, ttl: CacheTtl | null): Promise<void> => {
261 if (ttl === null || ttl === cacheTtl) return
262 cacheTtl = ttl
263 await $.store.set(CACHE_TTL_KEY, ttl)
264}
265
266// A fork's entry is told apart the same way, by the first request after a long enough pause.
267// On an account that caches for five minutes it can last no less
268const learnRenewTtl = async ($: EngineInterface, ttl: CacheTtl | null): Promise<void> => {
269 if (ttl === null || ttl === renewTtl || cacheTtl !== '1h') return
270 renewTtl = ttl
271 await $.store.set(RENEW_TTL_KEY, ttl)
272}
273
274// Passes the response on as it streams, seeing each chunk on the way
275async function* tap<T, R>(stream: AsyncGenerator<T, R>, see: (chunk: T) => Promise<void>): AsyncGenerator<T, R> {
276 for (;;) {
277 const step = await stream.next()
278 if (step.done === true) return step.value
279 await see(step.value)
280 yield step.value
281 }
282}
283
284const ask = async ($: EngineInterface, question: Question): Promise<void> => {
285 askTimer?.cancel()
286 await update($, asking, () => question)
287 askTimer = $.clock.after(isAction(question) ? CONFIRM_MS : CHOOSE_MS, () => void update($, asking, () => null))
288}
289
290const settle = async ($: EngineInterface): Promise<void> => {
291 askTimer?.cancel()
292 askTimer = null
293 await update($, asking, () => null)
294}
295
296const confirm = async ($: EngineInterface, action: ContextAction): Promise<void> => {
297 await settle($)
298 await $.command.run({ command: action })
299}
300
301// `/model <alias>` and `/effort <level>` save the pick as the default, as typed. A plugin's
302// run skips its own `command.run` hooks, so the footer is updated from the answer here
303const choose = async ($: EngineInterface, setting: Setting, choice: string): Promise<void> => {
304 await settle($)
305 const { text } = await $.command.run({ command: setting, args: choice })
306
307 if (setting === 'model') {
308 await refreshModel($)
309 return
310 }
311
312 const effort = effortFromAnswer(text)
313 if (effort !== null) await update($, model, prev => pinEffort(prev, effort))
314}
315
316export const choicesFor = (setting: Setting): readonly string[] =>
317 setting === 'model' ? MODEL_CHOICES : Object.keys(EFFORT_STYLES)
318
319const choiceLabel = (choice: string, current: string | null): string =>
320 choice === current ? `${choice} ${CURRENT_MARK}` : choice
321
322// The label pads the glyph so the whole chip, not its one middle cell, takes the click
323export const chipLabel = (text: string): string => ` ${text} `
324
325export const chooserLength = (setting: Setting, current: string | null): number =>
326 `${setting}:`.length +
327 choicesFor(setting).reduce((sum, choice) => sum + 1 + chipLabel(choiceLabel(choice, current)).length, 0) +
328 1 +
329 chipLabel(CLOSE_ICON).length
330
331export const actionsLength = (pending: ContextAction | null): number =>
332 pending === null
333 ? CONTEXT_ACTIONS.reduce((sum, action) => sum + chipLabel(ACTIONS[action].icon).length, 0) +
334 ACTION_GAP * (CONTEXT_ACTIONS.length - 1)
335 : ACTIONS[pending].question.length + ` ${chipLabel('yes')} ${chipLabel('no')}`.length
336
337// `percent` is absent until the first response of a fresh or just-compacted window
338export const toFill = (context: {
339 percent?: number
340 tokens?: number
341 window: number
342}): ContextFill | null =>
343 context.percent === undefined || context.tokens === undefined
344 ? null
345 : { percent: context.percent, tokens: context.tokens, window: context.window, isEstimate: false }
346
347export const estimatedFill = (tokens: number, window: number): ContextFill => ({
348 percent: Math.min(100, Math.round((tokens / window) * 100)),
349 tokens,
350 window,
351 isEstimate: true,
352})
353
354export const register: Register = on => {
355 on('session.start', async ($, e, next) => {
356 const { context } = await $.session.usage()
357 // A reload fires session.start before any new response: keep the last known fill
358 await update($, fill, prev => toFill(context) ?? prev)
359 const name = await $.session.model()
360 const effort = configuredEffort(await $.settings.read(), name)
361 // A reload keeps what a request or `/effort` told about the same model
362 await update($, model, prev =>
363 prev !== null && prev.name === name && prev.isEffortKnown ? prev : withEffort(name, effort),
364 )
365 // `/model`, its picker and alt+p switch the model without any event or request
366 $.clock.every(MODEL_POLL_MS, () => void refreshModel($))
367 $.clock.every(FILL_POLL_MS, () => void estimateMissingFill($))
368 const stored = await $.store.get(CACHE_TTL_KEY)
369 if (isCacheTtl(stored)) cacheTtl = stored
370 const storedRenewTtl = await $.store.get(RENEW_TTL_KEY)
371 if (isCacheTtl(storedRenewTtl)) renewTtl = storedRenewTtl
372 cacheNow = await $.clock.now()
373 $.clock.every(CACHE_TICK_MS, () => void tickCache($))
374 return next(e)
375 })
376
377 on('turn.complete', async ($, e, next) => {
378 await refreshFill($)
379 await refreshModel($)
380 return next(e)
381 })
382
383 // The effort is only known on a request, after any downgrade for the selected model
384 // The cache is read on each main-thread request; the first after a message says how much
385 // of the conversation was still cached when it was sent
386 on('turn.step', async function* ($, e, next) {
387 if (e.agentId !== undefined) return yield* next(e)
388 const effort = e.effort === undefined ? null : String(e.effort)
389 const name = await $.session.model()
390 await update($, model, prev => ({ name, effort, isEffortKnown: true, isEffortPinned: prev?.isEffortPinned ?? false }))
391 const requestAt = await $.clock.now()
392 return yield* tap(next(e), async chunk => {
393 if (chunk.kind !== 'stop' || chunk.usage === null) return
394 const hit = hitPercent(chunk.usage)
395 const prev = await read($, cache)
396 if (e.index === 0 && hit !== null && prev !== null) {
397 const ttl = inferTtl(prev, name, hit, requestAt)
398 await (prev.isFork === true ? learnRenewTtl($, ttl) : learnTtl($, ttl))
399 }
400 const hitNow = e.index === 0 || prev === null ? (hit ?? prev?.hitPercent ?? 0) : prev.hitPercent
401 await update($, cache, () => ({ hitPercent: hitNow, requestAt, model: name }))
402 })
403 })
404
405 // A plugin's fork re-sends the main thread's last request with one more message after it,
406 // whichever plugin forks: it reads the conversation from the cache, or writes it there once
407 // the entry lapsed, and either way the entry starts over. Keep warm renews the cache so
408 on('model.fork', async ($, e, next) => {
409 const requestAt = await $.clock.now()
410 const result = await next(e)
411 const usage = result.value !== undefined && 'usage' in result.value ? result.value.usage : null
412 const hit = usage === null ? null : hitPercent(usage)
413 if (hit === null) return result
414 const name = await $.session.model()
415 await update($, cache, () => ({ hitPercent: hit, requestAt, model: name, isFork: true }))
416 return result
417 })
418
419 // `/effort` changes the level without a request: its answer row says to what
420 on('session.append', { door: 'command' }, async ($, e, next) => {
421 const effort = e.agentId === undefined ? effortFromRow(e.message.content) : null
422 if (effort !== null) await update($, model, prev => pinEffort(prev, effort))
423 return next(e)
424 })
425
426 // The cleared conversation is estimated once the engine has emptied it
427 on('session.end', async ($, e, next) => {
428 if (e.reason !== 'clear') return next(e)
429 carried = await read($, model)
430 const result = await next(e)
431 $.clock.after(0, () => void estimateFill($))
432 return result
433 })
434
435 // The engine takes the compacted conversation once the hooks have returned, so its estimate
436 // waits for that. A compaction computed ahead or vetoed leaves the conversation as it was,
437 // and a subagent's leaves the main one. A compacted conversation is a new prefix, which the
438 // cache has none of yet
439 on('session.compact', async ($, e, next) => {
440 const result = await next(e)
441 if (e.trigger === 'precompute' || e.agentId !== undefined || result.skip !== undefined) return result
442 $.clock.after(0, () => void estimateFill($))
443 await update($, cache, prev => (prev === null ? prev : { ...prev, requestAt: null }))
444 return result
445 })
446
447 // The footer's right-hand slot, which the engine lays out after the hint line. A tree
448 // drawn for `PromptHint` instead shares a row with the mode label the engine puts ahead
449 // of it, and any width it claims squeezes that label until its ` · ` wraps
450 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
451 const current = await read($, fill)
452 const info = await read($, model)
453
454 if (current === null && info === null) {
455 return next(e)
456 }
457
458 const { Box, Text, Button } = $.ui.resolve(e)
459 const columns = e.viewport?.columns ?? DEFAULT_COLUMNS
460 // The labels already in the slot (`focus`, `memory paused`) keep the engine's drawing, and
461 // what other mods beneath draw there goes with them, at the right end
462 const modesText = e.props.modes.join(' & ')
463 const beneath = await next(e)
464
465 const parts = info === null ? null : modelParts(info)
466 const pending = await read($, asking)
467 const confirming = pending !== null && isAction(pending) ? pending : null
468 // An effort pick is moot once the model in use takes none
469 const choosing =
470 pending === null || isAction(pending) || parts === null || (pending === 'effort' && parts.effort === null)
471 ? null
472 : pending
473 const selected =
474 choosing === 'model' ? (parts?.family ?? null) : info !== null && info.isEffortKnown ? info.effort : null
475
476 let modelBlock = null
477 let modelLength = 0
478
479 if (parts !== null && choosing !== null) {
480 const chips = choosing === 'model' ? MODEL_CHIPS : EFFORT_CHIPS
481 modelLength = chooserLength(choosing, selected)
482 modelBlock = (
483 <Box flexShrink={0} gap={1}>
484 <Text dimColor>{`${choosing}:`}</Text>
485 {choicesFor(choosing).map(choice => (
486 <Box backgroundColor={chips[choice] ?? NO_COLOR}>
487 <Button
488 key={`${choosing}:${choice}`}
489 label={chipLabel(choiceLabel(choice, selected))}
490 plain
491 onPress={() => void (choice === selected ? settle($) : choose($, choosing, choice))}
492 />
493 </Box>
494 ))}
495 <Box backgroundColor={NO_COLOR}>
496 <Button key="close" label={chipLabel(CLOSE_ICON)} plain onPress={() => void settle($)} />
497 </Box>
498 </Box>
499 )
500 } else if (parts !== null) {
501 modelLength = parts.length
502 // A Button takes no color at rest: the icons keep the model's and the effort's
503 modelBlock = (
504 <Box flexShrink={0}>
505 <Text color={parts.modelColor}>{`${MODEL_ICON} `}</Text>
506 <Button key="model" label={parts.name} plain onPress={() => void ask($, 'model')} />
507 {parts.effort !== null && (
508 <Text color={parts.effort.color}>
509 {`${' '.repeat(EFFORT_GAP)}${parts.effort.icon === '' ? '' : `${parts.effort.icon} `}`}
510 </Text>
511 )}
512 {parts.effort !== null && (
513 <Button key="effort" label={parts.effort.label} plain onPress={() => void ask($, 'effort')} />
514 )}
515 </Box>
516 )
517 }
518
519 let bar = null
520 let barLength = 0
521
522 if (current !== null) {
523 const percent = Math.min(100, Math.max(0, current.percent))
524 const isCompact = columns < COMPACT_BELOW
525 const barWidth = barWidthFor(columns)
526 const filled = Math.round((percent / 100) * barWidth)
527 const color = colorFor(percent)
528 const label = isCompact ? '' : 'Context '
529 const percentText = ` ${current.isEstimate ? '~' : ''}${percent}%`
530 barLength = label.length + barWidth + percentText.length
531
532 bar = (
533 <Box flexShrink={0}>
534 {label !== '' && <Text dimColor>{label}</Text>}
535 <Text color={color}>{'█'.repeat(filled)}</Text>
536 <Text dimColor>{'░'.repeat(barWidth - filled)}</Text>
537 <Text color={color} bold>
538 {percentText}
539 </Text>
540 </Box>
541 )
542 }
543
544 let cacheBlock = null
545 let cacheLength = 0
546 const cached = await read($, cache)
547
548 if (cached !== null) {
549 const label = columns < COMPACT_BELOW ? '' : 'Cache '
550 const percentText = `${cached.hitPercent}%`
551 // The cache is per model: one switched to has none of the conversation yet
552 const isSameModel = info === null || info.name === cached.model
553 const remaining = isSameModel ? formatRemaining(remainingMs(cached, ttlOf(cached), cacheNow)) : COLD_TEXT
554 cacheLength = label.length + percentText.length + 1 + remaining.length
555
556 cacheBlock = (
557 <Box flexShrink={0}>
558 {label !== '' && <Text dimColor>{label}</Text>}
559 <Text color={hitColor(cached.hitPercent)} bold>
560 {percentText}
561 </Text>
562 {remaining === COLD_TEXT ? <Text color={COLD_COLOR}>{` ${remaining}`}</Text> : <Text dimColor>{` ${remaining}`}</Text>}
563 </Box>
564 )
565 }
566
567 const actions =
568 confirming === null ? (
569 <Box flexShrink={0} gap={ACTION_GAP}>
570 {CONTEXT_ACTIONS.map(action => (
571 <Box backgroundColor={ACTIONS[action].color}>
572 <Button key={action} label={chipLabel(ACTIONS[action].icon)} plain onPress={() => void ask($, action)} />
573 </Box>
574 ))}
575 </Box>
576 ) : (
577 <Box flexShrink={0} gap={1}>
578 <Text color={ACTIONS[confirming].color} bold>
579 {ACTIONS[confirming].question}
580 </Text>
581 <Box backgroundColor={ACTIONS[confirming].color}>
582 <Button key="yes" label={chipLabel('yes')} plain onPress={() => void confirm($, confirming)} />
583 </Box>
584 <Box backgroundColor={NO_COLOR}>
585 <Button key="no" label={chipLabel('no')} plain onPress={() => void settle($)} />
586 </Box>
587 </Box>
588 )
589
590 const blocks = [cacheLength, modelLength, barLength, actionsLength(confirming), modesText.length].filter(
591 n => n > 0,
592 )
593 const rightLength = blocks.reduce((sum, n) => sum + n, 0) + BLOCK_GAP * (blocks.length - 1)
594
595 if (fitsInRow(columns, rightLength)) {
596 return (
597 <Box flexShrink={0} gap={BLOCK_GAP}>
598 {cacheBlock}
599 {modelBlock}
600 {bar}
601 {actions}
602 {beneath}
603 </Box>
604 )
605 }
606
607 return (
608 <Box flexShrink={0} flexDirection="column" alignItems="flex-end">
609 {cacheBlock}
610 {modelBlock}
611 {bar}
612 {actions}
613 {beneath}
614 </Box>
615 )
616 })
617}
618hooks/cache.ts 58 lines1import type { CacheInfo, CacheTtl } from '../types'
2
3const GOOD_FROM = 80
4const FAIR_FROM = 40
5const MINUTE_MS = 60_000
6const SECOND_MS = 1000
7
8export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * MINUTE_MS, '1h': 60 * MINUTE_MS }
9// What the engine picks for a main thread that may cache for an hour, which a session on a
10// Claude subscription does, until a request shows otherwise
11export const DEFAULT_TTL: CacheTtl = '1h'
12// Past a TTL by this much, a request's hit or miss is the entry's lapse and not a race
13const TTL_MARGIN_MS = 30_000
14const WARM_FROM = 50
15const COLD_BELOW = 10
16export const COLD_TEXT = 'cold'
17
18type RequestUsage = { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
19
20// The share of the prompt the cache served, out of all the input the request was answered
21// over: what was read, what was written, and what went uncached
22export const hitPercent = (usage: RequestUsage): number | null => {
23 const total = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
24 return total === 0 ? null : Math.round((usage.cache_read_input_tokens / total) * 100)
25}
26
27export const isCacheTtl = (value: unknown): value is CacheTtl => value === '5m' || value === '1h'
28
29// Each request that reads the cache renews its entry, so it lapses one TTL after the last.
30// The clock is read once a second, so a request just made may postdate its last reading
31export const remainingMs = (info: CacheInfo, ttl: CacheTtl, now: number): number =>
32 info.requestAt === null ? 0 : Math.min(TTL_MS[ttl], Math.max(0, info.requestAt + TTL_MS[ttl] - now))
33
34// Rounded up, so `~1m` still has the cache warm and nothing shows `~0m`
35export const formatRemaining = (ms: number): string => {
36 if (ms <= 0) return COLD_TEXT
37 if (ms < MINUTE_MS) return `~${Math.ceil(ms / SECOND_MS)}s`
38 return `~${Math.ceil(ms / MINUTE_MS)}m`
39}
40
41export const hitColor = (percent: number): string => {
42 if (percent >= GOOD_FROM) return '#3fb950'
43 if (percent >= FAIR_FROM) return '#ff9500'
44 return '#f85149'
45}
46
47// A pause longer than five minutes and shorter than an hour tells the TTL apart: a cache
48// still read means an hour, one gone means five minutes. A miss can also come from a
49// prompt that changed meanwhile (an edited CLAUDE.md, a server connected), which the
50// next long pause with a hit corrects
51export const inferTtl = (prev: CacheInfo, model: string, hit: number, requestAt: number): CacheTtl | null => {
52 if (prev.requestAt === null || prev.model !== model) return null
53 const pause = requestAt - prev.requestAt
54 if (pause < TTL_MS['5m'] + TTL_MARGIN_MS || pause > TTL_MS['1h'] - TTL_MARGIN_MS) return null
55 if (hit >= WARM_FROM) return '1h'
56 return hit < COLD_BELOW ? '5m' : null
57}
58types/index.d.ts 29 lines1// `isEstimate`: the engine's own estimate, as /context makes it, while no response has measured
2// the window yet: in a fresh or cleared session, or just after a compaction
3export type ContextFill = { percent: number; tokens: number; window: number; isEstimate: boolean }
4export type ContextAction = 'compact' | 'clear'
5export type Setting = 'model' | 'effort'
6// What the footer asks in place of its blocks: to confirm an action, or to pick a setting
7export type Question = ContextAction | Setting
8// `isEffortPinned`: `/effort` set the level, which then holds for the session across models
9export type ModelInfo = { name: string; effort: string | null; isEffortKnown: boolean; isEffortPinned: boolean }
10
11export type CacheTtl = '5m' | '1h'
12// `hitPercent`: what the cache served of the first request after the last message.
13// `requestAt`: the last main-thread request, null once a compaction changed the prefix.
14// `model`: the session's model then, the one the cache entry belongs to.
15// `isFork`: that request was a plugin's fork of the conversation (keep warm's renewal), whose
16// entry may not last as long as one a request of the conversation leaves
17export type CacheInfo = { hitPercent: number; requestAt: number | null; model: string; isFork?: boolean }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'context-bar': {
22 fill: ContextFill | null
23 model: ModelInfo | null
24 asking: Question | null
25 cache: CacheInfo | null
26 }
27 }
28}
29