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

Jedna kolorowa linia nad polem promptu:
cache cold),Liczby bierze z tego, co Claude Code już wie. Nie woła modelu i nie łączy się z siecią.
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.
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.
hooks/register.tsx 285 lines1// 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}
285types/index.d.ts 13 lines1export 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