SLOPSHOPPER

cache-bar

One colored line above the prompt: the prompt cache countdown, context fill, and the plan's 5-hour and weekly usage.

newbandtoasttimer
v0.1.0no licenseupdated 2026-10-10simplybychris/claude-code-mods/cache-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ctx 97k │ 5h 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97k │ 5h 31% ⟨Claude Code's own drawing⟩
README

Cache Bar

Jedna kolorowa linia nad polem promptu:

  • cache: ile zostało, zanim cache promptu wygaśnie (zielony, potem żółty, na końcu cache cold),
  • ctx: ile tokenów zajmuje kontekst,
  • 5h i week: zużycie limitów planu i godzina resetu.

Liczby bierze z tego, co Claude Code już wie. Nie woła modelu i nie łączy się z siecią.

Przycisk handoff

Gdy kontekst przekroczy połowę okna, po prawej pojawia się przycisk handoff → (skrót h, tylko między turami). Wysyła do Claude'a prośbę o użycie skilla session-handoff, który zapisuje podsumowanie sesji, żeby dało się ją wznowić w czystym oknie.

Ten skill nie jest częścią moda. Jeśli go nie masz, przycisk nic nie wysyła i pokazuje komunikat. Możesz dopisać własny skill o nazwie session-handoff w ~/.claude/skills/session-handoff/SKILL.md.

Ustawienia

W /config, wiersz Prompt cache lifetime: auto (domyślnie), 5m albo 1h. W trybie auto mod czyta CLAUDE_CODE_PROMPT_CACHE_TTL, potem promptCacheTtl z ustawień, a na końcu zakłada 1h przy subskrypcji w ramach limitu i 5 minut w pozostałych przypadkach.

Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop.

Source 2 files
hooks/register.tsx 285 lines
1// cache-bar: one colored line above the prompt. How long the prompt cache stays
2// warm, how full the context window is, and the plan's 5-hour and weekly usage.
3// It only reads figures Claude Code already holds: no model calls, no network.
4// Ideas from token-weather and usage-meter in hamzafer/claude-code-mods (MIT).
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { CacheBarLimit, CacheBarUsage, CacheTtl } from '../types'
8
9const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
10const TICK_MS = 15_000
11const WARN_SHARE = 0.2 // amber under this share of the cache lifetime
12const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week' }
13const HANDOFF_PERCENT = 50 // the handoff button shows from this share of the context window
14const HANDOFF_LABEL = 'handoff →'
15const HANDOFF_SKILL = 'session-handoff'
16const HANDOFF_PROMPT = `Użyj skilla ${HANDOFF_SKILL} i zapisz handoff tej sesji.`
17const HANDOFF_MISSING = `handoff: brak skilla ${HANDOFF_SKILL}, zobacz README moda cache-bar`
18
19const COLOR = {
20  green: '#3fb950',
21  amber: '#d29922',
22  red: '#f85149',
23  blue: '#58a6ff',
24  violet: '#bc8cff',
25  muted: '#8b949e',
26  sep: '#6e7681',
27}
28
29type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
30
31export const register: Register = (on, options) => {
32  const override: CacheTtl | null = options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
33  let configured: CacheTtl | null = null // from the environment or settings, read at session start
34  let latest: CacheBarUsage | null = null // the last reading of the context and the plan's windows
35  let lastRequestAt: number | null = null // when the last main-thread request started; null before one
36  let timer: Timer | undefined
37  const resetClock: Record<string, string> = {} // resetsAt → local time, from the system clock
38
39  // The lifetime Claude Code asks for: 1h on a subscription within its limits, 5m otherwise.
40  const ttl = (): CacheTtl => {
41    if (override) return override
42    if (configured) return configured
43    if (!latest || latest.limits.length === 0) return '5m'
44    return latest.limits.some(l => l.percentUsed >= 100) ? '5m' : '1h'
45  }
46
47  on('session.start', async ($, e, next) => {
48    const result = await next(e)
49    configured = await configuredTtl($)
50    const now = await $.session.usage().catch(() => null)
51    if (now) {
52      latest = reading(now.context, now.rateLimits)
53      labelResets(latest.limits, resetClock)
54    }
55    $.ui.invalidate('ui.render')
56    timer?.cancel()
57    // Redraws only while a countdown moves, and once more as it goes cold.
58    timer = $.clock.every(TICK_MS, () => {
59      void (async () => {
60        if (lastRequestAt === null) return
61        if ((await $.clock.now()) - lastRequestAt > TTL_MS[ttl()] + TICK_MS) return
62        $.ui.invalidate('ui.render')
63      })().catch(() => {})
64    })
65    return result
66  })
67
68  on('session.measure', async ($, e, next) => {
69    const result = await next(e)
70    latest = reading(e.context, e.rateLimits)
71    labelResets(latest.limits, resetClock)
72    $.ui.invalidate('ui.render')
73    return result
74  })
75
76  // Each main-thread request reads or writes the cache, and the lifetime counts from its start.
77  on('turn.step', async function* ($, e, next) {
78    const startedAt = await $.clock.now()
79    const result = yield* next(e)
80    if (!e.agentId && result.stopReason !== null) {
81      lastRequestAt = startedAt
82      $.ui.invalidate('ui.render')
83    }
84    return result
85  })
86
87  // A /clear or a resume leaves the cached conversation behind; a compaction rewrites it.
88  on('session.end', async ($, e, next) => {
89    if (e.reason === 'clear' || e.reason === 'resume') lastRequestAt = null
90    return next(e)
91  })
92
93  on('session.compact', async ($, e, next) => {
94    const result = await next(e)
95    lastRequestAt = null
96    $.ui.invalidate('ui.render')
97    return result
98  })
99
100  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101    const rest = await next(e) // what Claude Code and other mods draw here stays
102    if (e.props.hasSurvey || !latest) return rest
103
104    const now = await $.clock.now()
105    const left = lastRequestAt === null ? null : lastRequestAt + TTL_MS[ttl()] - now
106    // Past half the window, a button on the right starts a handoff (between turns only).
107    const isHandoff = !e.props.isWorking && contextPercent(latest) >= HANDOFF_PERCENT
108    const room = e.props.bodyColumns - 2 - (isHandoff ? HANDOFF_LABEL.length + 6 : 0)
109    const segs = fit(latest, left, ttl(), now, resetClock, room)
110    if (segs.length === 0 && !isHandoff) return rest
111
112    const { Box, Text, Button } = $.ui.resolve(e)
113    return (
114      <Box flexDirection="column">
115        <Box flexDirection="row" paddingX={1} justifyContent="space-between">
116          <Box flexDirection="row">
117            {segs.map((s, i) => (
118              <Text key={`seg-${i}`} color={s.color} dimColor={s.dim} bold={s.bold} wrap="truncate">
119                {s.text}
120              </Text>
121            ))}
122          </Box>
123          {isHandoff && (
124            <Button
125              key="handoff"
126              label={HANDOFF_LABEL}
127              hotkey="h"
128              variant="primary"
129              onPress={() => {
130                void (async () => {
131                  const commands = await $.command.list().catch(() => null)
132                  if (commands && !commands.some(c => c.name === HANDOFF_SKILL || c.name.endsWith(`:${HANDOFF_SKILL}`))) {
133                    $.ui.toast(HANDOFF_MISSING)
134                    return
135                  }
136                  await $.prompt.submit({ text: HANDOFF_PROMPT, asUser: true })
137                })().catch(() => {})
138              }}
139            />
140          )}
141        </Box>
142        {rest}
143      </Box>
144    )
145  })
146}
147
148function reading(context: { tokens?: number; window: number; percent?: number }, limits: readonly CacheBarLimit[]): CacheBarUsage {
149  return {
150    tokens: context.tokens ?? null,
151    window: context.window,
152    percent: context.percent ?? null,
153    limits: limits
154      .filter(l => Number.isFinite(l.percentUsed))
155      .map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
156  }
157}
158
159async function configuredTtl($: EngineInterface): Promise<CacheTtl | null> {
160  const fromEnv = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(() => undefined)
161  if (fromEnv === '5m' || fromEnv === '1h') return fromEnv
162  const settings = (await $.settings.read().catch(() => null)) as { promptCacheTtl?: unknown } | null
163  const fromSettings = settings?.promptCacheTtl
164  return fromSettings === '5m' || fromSettings === '1h' ? fromSettings : null
165}
166
167// When each window resets, in the local time zone.
168function labelResets(limits: readonly CacheBarLimit[], into: Record<string, string>) {
169  for (const l of limits) {
170    if (!l.resetsAt || into[l.resetsAt] !== undefined) continue
171    const ms = Date.parse(l.resetsAt)
172    if (!Number.isFinite(ms)) continue
173    const text = localTime(ms, l.kind === 'seven_day')
174    if (text) into[l.resetsAt] = text
175  }
176}
177
178function localTime(ms: number, withDay: boolean) {
179  try {
180    const parts = new Intl.DateTimeFormat('en-GB', {
181      ...(withDay ? { weekday: 'short' as const } : {}),
182      hour: '2-digit',
183      minute: '2-digit',
184      hourCycle: 'h23',
185    }).formatToParts(new Date(ms))
186    const part = (type: string) => parts.find(p => p.type === type)?.value ?? ''
187    const time = `${part('hour')}:${part('minute')}`
188    return withDay ? `${part('weekday')} ${time}` : time
189  } catch {
190    return ''
191  }
192}
193
194// The widest line that fits: roomy first, then tighter, then the weekly window and the plan go.
195function fit(u: CacheBarUsage, left: number | null, ttl: CacheTtl, now: number, clock: Record<string, string>, columns: number): Seg[] {
196  const cache = cacheSegs(left, ttl)
197  const ctx = ctxSegs(u)
198  const fiveHour = limitSegs(u, 'five_hour', now, clock)
199  const week = limitSegs(u, 'seven_day', now, clock)
200  const variants = [
201    join([cache, ctx, fiveHour, week], WIDE),
202    join([cache, ctx, fiveHour, week], NARROW),
203    join([cache, ctx, fiveHour], NARROW),
204    join([cache, ctx], NARROW),
205    join([cache], NARROW),
206  ]
207  return variants.find(v => width(v) <= columns) ?? variants[variants.length - 1] ?? []
208}
209
210const WIDE = '   │   '
211const NARROW = '  │  '
212
213function cacheSegs(left: number | null, ttl: CacheTtl): Seg[] {
214  if (left === null) return [] // no request yet this session
215  if (left <= 0) return [{ text: '○ cache cold', color: COLOR.red, bold: true }]
216  const isLow = left < TTL_MS[ttl] * WARN_SHARE
217  const color = isLow ? COLOR.amber : COLOR.green
218  return [
219    { text: isLow ? '◐ cache cooling ' : '● cache warm ', color },
220    { text: minutes(left), color, bold: true },
221  ]
222}
223
224function contextPercent(u: CacheBarUsage) {
225  if (u.tokens === null) return 0
226  return u.percent ?? Math.round((u.tokens / Math.max(1, u.window)) * 100)
227}
228
229function ctxSegs(u: CacheBarUsage): Seg[] {
230  if (u.tokens === null) return []
231  const percent = contextPercent(u)
232  const color = percent >= 75 ? COLOR.red : percent >= 50 ? COLOR.amber : COLOR.blue
233  return [
234    { text: 'ctx ', color: COLOR.muted },
235    { text: short(u.tokens), color, bold: true },
236  ]
237}
238
239// The 5-hour window always says when it resets; the week only once it runs high.
240function limitSegs(u: CacheBarUsage, kind: string, now: number, clock: Record<string, string>): Seg[] {
241  const l = u.limits.find(w => w.kind === kind)
242  if (!l) return []
243  const p = l.percentUsed
244  const color = p >= 85 ? COLOR.red : p >= 60 ? COLOR.amber : COLOR.green
245  const segs: Seg[] = [
246    { text: `${LIMIT_LABELS[kind]} `, color: COLOR.muted },
247    { text: `${Math.round(p)}%`, color, bold: true },
248  ]
249  const showReset = kind === 'five_hour' || p >= 75
250  const until = l.resetsAt ? Date.parse(l.resetsAt) - now : NaN
251  if (showReset && Number.isFinite(until) && until > 0) {
252    const at = l.resetsAt ? clock[l.resetsAt] : undefined
253    segs.push({ text: at ? ` · resets ${at}` : ` · resets in ${duration(until)}`, color: COLOR.violet })
254  }
255  return segs
256}
257
258function join(parts: Seg[][], separator: string): Seg[] {
259  return parts
260    .filter(p => p.length > 0)
261    .flatMap((p, i) => (i === 0 ? p : [{ text: separator, color: COLOR.sep }, ...p]))
262}
263
264function width(segs: Seg[]) {
265  return segs.reduce((n, s) => n + [...s.text].length, 0)
266}
267
268function minutes(ms: number) {
269  return ms >= 60_000 ? `${Math.ceil(ms / 60_000)}m` : '<1m'
270}
271
272function duration(ms: number) {
273  const m = Math.ceil(ms / 60_000)
274  if (m < 60) return `${m}m`
275  const h = Math.floor(m / 60)
276  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}`
277  return `${Math.round(h / 24)}d`
278}
279
280function short(tokens: number) {
281  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
282  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
283  return String(tokens)
284}
285
types/index.d.ts 13 lines
1export type CacheTtl = '5m' | '1h'
2
3// One plan window as the engine reports it: `five_hour`, `seven_day`, a gateway's `spend_limit`.
4export type CacheBarLimit = { kind: string; percentUsed: number; resetsAt?: string }
5
6// The last reading of the context window and the plan's windows.
7export type CacheBarUsage = {
8  tokens: number | null
9  window: number
10  percent: number | null
11  limits: CacheBarLimit[]
12}
13