SLOPSHOPPER

context-bar

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

newspinnertimer
v0.2.3MITupdated 2026-10-06ziedgithub/claude-code-mods/context-bar
A shopper browsing a rack in a slop shop
README

context-bar

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

context-bar, a rendered preview

  • Cache: how much of the last prompt the cache served, and roughly how long until it expires: 99% ~58m, then cold once it has lapsed or after a compaction.
  • Whether your account caches for 5 minutes or an hour is worked out from the requests themselves, and remembered between sessions.
  • When another mod renews the cache in the background (keep warm in warm-compact, or any mod that does the same), the countdown starts over too. The bar sees the renewal itself, so it does not need that mod installed. If the account keeps those renewals for only 5 minutes, the bar works that out the first time the cache turns out to be gone, and counts down 5 minutes after each renewal from then on.
  • Model and effort: ◆ 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.
  • Context: how full the context window is, in a bar that is green under 30%, orange under 60% and red after that.
  • After a compaction or a /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 ~.
  • Compact ⇊ 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.

Install

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.

Source 3 files
hooks/register.tsx 618 lines
1import { 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}
618
hooks/cache.ts 58 lines
1import 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}
58
types/index.d.ts 29 lines
1// `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