Полоса над полем ввода: лимиты аккаунта, обратный отсчёт кеша промпта и кнопки ↻ и «Авто», которые держат кеш тёплым скрытым форком сессии

Полоса над полем ввода: сколько лимитов аккаунта вы потратили и через сколько остынет кеш промпта.

| Элемент | Что показывает |
|---|---|
5ч 6% ↻ 4ч 10м | Пятичасовое окно: процент расхода и время до сброса |
7д 34% ↻ 5д | Недельное окно: процент расхода и время до сброса |
Кеш 1ч | Сколько осталось жить кешу промпта |
↻ | Прогреть кеш сейчас |
Авто | Прогревать кеш самому, пока вы не пишете |
✕ | Закрыть полосу |
Полоса окрашена по остатку: зелёная, пока осталось больше половины, жёлтая до пятой части, ниже красная. Остывший кеш показан полной красной полосой и словом «остыл».
Кнопка ↻ отправляет короткий запрос из скрытого форка сессии. Запрос попадает в тот же кеш, и отсчёт начинается заново. В чате форк не виден.
«Авто» включено с начала сессии и прогревает кеш за 3 минуты до конца (за минуту, если кеш живёт 5 минут). Если вы ничего не пишете час, «Авто» выключается до конца сессии: держать кеш дальше нет смысла.
Claude Code хранит контекст сессии в кеше промпта. Пока кеш жив, каждый запрос читает контекст за 10% цены входа и продлевает кешу жизнь. Если вы ушли дольше, чем живёт кеш, следующее сообщение записывает весь контекст заново.
| Что происходит | Цена контекста |
|---|---|
| Прогрев: чтение из кеша | ×0,1 от цены входа |
| Остывший 5-минутный кеш: запись заново | ×1,25 |
| Остывший часовой кеш: запись заново | ×2 |
Пример: в сессии 200 тысяч токенов контекста. Прогрев стоит как 20 тысяч токенов входа, перезапись часового кеша как 400 тысяч, в 20 раз дороже. Ответ после паузы к тому же приходит быстрее. Множители взяты из тарифов API Anthropic.
| Команда | Что делает |
|---|---|
/limits | Вернуть закрытую полосу |
/usage | То же в терминале. В десктопе /usage открывает окно приложения, поэтому там работает только /limits |
claude plugin marketplace add petrovyuri/claude-code-mods
claude plugin install limits-cache@petrovyuri-mods --scope user
В открытой сессии выполните /reload-plugins.
/usage: раз в 10 минут и после ответов, но не чаще раза в 2 минуты.Изменения по версиям: CHANGELOG.md.
hooks/register.tsx 414 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Meter, Ttl, Warmup } from '../types'
5import { drawBand } from './band'
6import {
7 WARM_PROMPT,
8 cacheStatus,
9 describeWarmup,
10 isAutoExpired,
11 isWarmDue,
12 warmupOf,
13} from './cache'
14import { mergeLimits, toMeter } from './limits'
15import { lastCacheUse, transcriptPath } from './transcript'
16import { USAGE_MIN_GAP_MS, USAGE_POLL_MS, USAGE_URL, parseUsage } from './usage'
17
18const TICK_MS = 1000
19// The TTL read from a transcript or named by a model switch, kept for the next session.
20const TTL_STORE_KEY = 'ttl'
21
22const meter = atom({ plugin: 'limits-cache', key: 'meter' } as const, { limits: [] } as Meter)
23const summary = atom({ plugin: 'limits-cache', key: 'summary' } as const, [] as Limit[])
24const lastRequestAt = atom({ plugin: 'limits-cache', key: 'lastRequestAt' } as const, null as number | null)
25const isWorking = atom({ plugin: 'limits-cache', key: 'isWorking' } as const, false)
26// Claude Code writes the main conversation's cache for an hour on a subscription; its
27// subagents and SDK sessions write it for five minutes.
28const ttl = atom({ plugin: 'limits-cache', key: 'ttl' } as const, '1h' as Ttl)
29// Auto is on from the session's start; turning it off, or an idle hour, holds for the session.
30const isAuto = atom({ plugin: 'limits-cache', key: 'isAuto' } as const, true)
31const activeAt = atom({ plugin: 'limits-cache', key: 'activeAt' } as const, null as number | null)
32const isWarming = atom({ plugin: 'limits-cache', key: 'isWarming' } as const, false)
33const now = atom({ plugin: 'limits-cache', key: 'now' } as const, 0)
34const isHidden = atom({ plugin: 'limits-cache', key: 'isHidden' } as const, false)
35
36// The mod's own command that brings the closed band back. The desktop app answers /usage
37// itself (its settings window) and never hands it to the engine; the terminal does, so
38// /usage brings the band back there too.
39const SHOW_COMMAND = 'limits'
40const USAGE_COMMAND = 'usage'
41const HIDDEN_TOAST = 'Полоса лимитов скрыта. Чтобы вернуть её, введите /limits'
42
43const isTtl = (value: unknown): value is Ttl => value === '5m' || value === '1h'
44
45// Writing an unchanged value would redraw the band for nothing. Each atom is read and written
46// where it is named: the engine's scan does not follow one passed as a parameter.
47const changed = (before: unknown, after: unknown): boolean => JSON.stringify(before) !== JSON.stringify(after)
48
49async function readMeter($: EngineInterface): Promise<void> {
50 try {
51 const usage = await $.session.usage()
52 const next = toMeter(usage.rateLimits)
53 if (changed(await read($, meter), next)) {
54 await update($, meter, () => next)
55 }
56 } catch {
57 // No figures yet: the band says so, and the next measurement fills them in.
58 }
59}
60
61// When the usage summary was last asked for; a reload asks again at once.
62let summaryAskedAt = Number.NEGATIVE_INFINITY
63
64// The account's usage summary, every window it keeps. Only a subscription has one: with an
65// API key, a gateway or a cloud provider the band shows what the responses reported.
66async function readSummary($: EngineInterface, isDue: boolean): Promise<void> {
67 const at = await $.clock.now()
68 if (!isDue && at - summaryAskedAt < USAGE_MIN_GAP_MS) {
69 return
70 }
71 summaryAskedAt = at
72 try {
73 const authorization = await $.session.authorize()
74 if (authorization === null || authorization.kind !== 'bearer') {
75 return
76 }
77 const response = await $.http.fetch(USAGE_URL, {
78 auth: authorization.handle,
79 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
80 })
81 if (!response.ok) {
82 return
83 }
84 const next = parseUsage(response.text)
85 if (changed(await read($, summary), next)) {
86 await update($, summary, () => next)
87 }
88 } catch {
89 // The summary stays as it was; the next ask may fare better.
90 }
91}
92
93// The prompt cache the band counted down belongs to a conversation that is gone.
94async function forgetCache($: EngineInterface): Promise<void> {
95 await update($, lastRequestAt, () => null)
96}
97
98// The session's TTL is known once a transcript or a model switch named it: a transcript read
99// later does not override that.
100let isTtlKnown = false
101
102async function setTtl($: EngineInterface, next: Ttl): Promise<void> {
103 isTtlKnown = true
104 await update($, ttl, () => next)
105 await $.store.set(TTL_STORE_KEY, next)
106}
107
108async function configDir($: EngineInterface): Promise<string | null> {
109 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
110 if (dir !== undefined && dir !== '') {
111 return dir
112 }
113 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
114
115 return home === undefined ? null : `${home}/.claude`
116}
117
118// The session's transcript: in its working directory's project folder, or, the directory
119// changed since the session began, in whichever project folder holds a file by its id.
120async function findTranscript($: EngineInterface): Promise<string | null> {
121 try {
122 const dir = await configDir($)
123 if (dir === null) {
124 return null
125 }
126 const id = await $.session.id()
127 const path = transcriptPath(dir, await $.session.cwd(), id)
128 if (await $.fs.exists(path)) {
129 return path
130 }
131 for (const entry of await $.fs.list(`${dir}/projects`)) {
132 const candidate = `${dir}/projects/${entry.name}/${id}.jsonl`
133 if (entry.kind === 'dir' && (await $.fs.exists(candidate))) {
134 return candidate
135 }
136 }
137 } catch {
138 // No transcript to read: the countdown starts with the next answer.
139 }
140
141 return null
142}
143
144// What the mod could not see for itself: when a response used the cache before the mod
145// loaded, and the TTL it was written with. A transcript past what one read takes (4 MiB)
146// is left unread.
147async function readTranscript($: EngineInterface, path: string | null): Promise<void> {
148 if (path === null) {
149 return
150 }
151 let text: string
152 try {
153 text = await $.fs.read(path)
154 } catch {
155 return
156 }
157 const { at, ttl: written } = lastCacheUse(text)
158 if (at !== null) {
159 const last = await read($, lastRequestAt)
160 if (last === null || at > last) {
161 await update($, lastRequestAt, () => at)
162 }
163 }
164 if (written !== null && !isTtlKnown) {
165 await setTtl($, written)
166 }
167}
168
169// A warmup in flight: a turn.complete it may raise is not the person's activity.
170let isForking = false
171
172async function warm($: EngineInterface, trigger: 'manual' | 'auto'): Promise<void> {
173 if (await read($, isWarming)) {
174 return
175 }
176 if (await read($, isWorking)) {
177 if (trigger === 'manual') {
178 $.ui.toast('Claude отвечает — каждый его запрос и так обновляет кеш')
179 }
180
181 return
182 }
183 await update($, isWarming, () => true)
184 isForking = true
185 let outcome: Warmup
186 try {
187 const result = await $.model.fork({ prompt: WARM_PROMPT })
188 outcome = warmupOf(result, await $.clock.now())
189 } catch (error) {
190 outcome = { kind: 'failed', at: await $.clock.now(), message: String(error) }
191 } finally {
192 isForking = false
193 }
194 if (outcome.kind === 'ok') {
195 await update($, lastRequestAt, () => outcome.at)
196 }
197 await update($, isWarming, () => false)
198 if (trigger === 'manual') {
199 $.ui.toast(describeWarmup(outcome))
200 } else if (outcome.kind === 'failed') {
201 // Retrying every second would spend the limits on the same refusal.
202 await update($, isAuto, () => false)
203 $.ui.toast(`Авто-подогрев выключен. ${describeWarmup(outcome)}`)
204 }
205}
206
207async function toggleAuto($: EngineInterface): Promise<void> {
208 if (await read($, isAuto)) {
209 await update($, isAuto, () => false)
210
211 return
212 }
213 const at = await $.clock.now()
214 await update($, activeAt, () => at)
215 await update($, isAuto, () => true)
216 const status = cacheStatus(await read($, lastRequestAt), at, await read($, ttl), await read($, isWorking))
217 if (status.kind === 'empty' || status.kind === 'cold') {
218 $.ui.toast('Авто включён: начнёт греть кеш после следующего ответа Claude')
219 }
220}
221
222// ✕ takes the band away whole, and says how to bring it back.
223async function close($: EngineInterface): Promise<void> {
224 await update($, isHidden, () => true)
225 $.ui.toast(HIDDEN_TOAST)
226}
227
228async function tick($: EngineInterface): Promise<void> {
229 const at = await $.clock.now()
230 // The countdowns move only while there is something to count.
231 const hasClock =
232 (await read($, lastRequestAt)) !== null ||
233 (await read($, meter)).limits.some(limit => limit.resetsAt !== undefined) ||
234 (await read($, summary)).some(limit => limit.resetsAt !== undefined)
235 if (hasClock) {
236 await update($, now, () => at)
237 }
238 if (!(await read($, isAuto))) {
239 return
240 }
241 const working = await read($, isWorking)
242 if (!working && isAutoExpired(await read($, activeAt), at)) {
243 await update($, isAuto, () => false)
244 $.ui.toast('Авто-подогрев выключен: час без активности в сессии')
245
246 return
247 }
248 const isDue = isWarmDue({
249 isAuto: true,
250 isWarming: await read($, isWarming),
251 isWorking: working,
252 lastRequestAt: await read($, lastRequestAt),
253 now: at,
254 ttl: await read($, ttl),
255 })
256 if (isDue) {
257 await warm($, 'auto')
258 }
259}
260
261export const register: Register = on => {
262 on('session.start', async ($, e, next) => {
263 await $.command.register({
264 name: SHOW_COMMAND,
265 description: 'Показать полосу лимитов и кеша над полем ввода',
266 immediate: true,
267 })
268 // State outlives a reload of the mod; a warmup or turn it cut short never ends.
269 await update($, isWarming, () => false)
270 await update($, isWorking, () => false)
271 const stored = await $.store.get(TTL_STORE_KEY)
272 if (isTtl(stored)) {
273 await update($, ttl, () => stored)
274 }
275 // Loaded mid-session, the mod has seen no answer yet: the transcript has.
276 await readTranscript($, await findTranscript($))
277 await readMeter($)
278 // Auto on by default counts its idle hour from the start, as a press would.
279 if ((await read($, isAuto)) && (await read($, activeAt)) === null) {
280 const at = await $.clock.now()
281 await update($, activeAt, () => at)
282 }
283 await tick($)
284 $.clock.every(TICK_MS, () => void tick($))
285 // The summary is a network call: asked for off the start, which it must not hold up.
286 $.clock.after(0, () => void readSummary($, true))
287 $.clock.every(USAGE_POLL_MS, () => void readSummary($, true))
288
289 return next(e)
290 })
291
292 // The status line's figures, pushed after each turn and as a rate-limit window moves.
293 on('session.measure', async ($, e, next) => {
294 const nextMeter = toMeter(e.rateLimits)
295 if (changed(await read($, meter), nextMeter)) {
296 await update($, meter, () => nextMeter)
297 }
298
299 return next(e)
300 })
301
302 on('turn.start', async ($, e, next) => {
303 await update($, isWorking, () => true)
304
305 return next(e)
306 })
307
308 on('turn.complete', async ($, e, next) => {
309 // A subagent's loop has a prompt of its own; the band counts the main conversation's.
310 if (e.agentId === undefined) {
311 const at = await $.clock.now()
312 await update($, isWorking, () => false)
313 if (!isForking) {
314 await update($, activeAt, () => at)
315 }
316 // A turn that made no request (an interrupt before the first reply) used no cache.
317 if (e.usage !== undefined) {
318 await update($, lastRequestAt, () => at)
319 }
320 // A turn spent some of the limits: the summary is asked again, no more than its gap.
321 $.clock.after(0, () => void readSummary($, false))
322 }
323
324 return next(e)
325 })
326
327 on('session.end', async ($, e, next) => {
328 if (e.reason === 'clear') {
329 await forgetCache($)
330 }
331
332 return next(e)
333 })
334
335 on('classic.SessionStart', async ($, e, next) => {
336 if (e.source === 'compact' || e.source === 'clear') {
337 // The next request caches a conversation the old entry does not open.
338 await forgetCache($)
339 } else if (e.seconds_since_last_response !== undefined) {
340 // A resumed conversation: its cache was last used when its last answer came.
341 const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
342 await update($, lastRequestAt, () => at)
343 await readTranscript($, e.transcript_path)
344 }
345
346 return next(e)
347 }).catch(($, e, next) => next(e))
348
349 // A new session's first answer is the first that shows which TTL its cache is written with.
350 on('classic.Stop', async ($, e, next) => {
351 if (!isTtlKnown) {
352 await readTranscript($, e.transcript_path)
353 }
354
355 return next(e)
356 }).catch(($, e, next) => next(e))
357
358 // A switch names the session's TTL, and leaves the new model with nothing cached.
359 on('classic.PostModelSwitch', async ($, e, next) => {
360 await setTtl($, e.cache_ttl)
361 await forgetCache($)
362
363 return next(e)
364 }).catch(($, e, next) => next(e))
365
366 // /limits shows the band the person closed. The app's own limits button shows them
367 // without the engine: nothing reaches a hook.
368 on('command.run', { command: SHOW_COMMAND }, async $ => {
369 if (!(await read($, isHidden))) {
370 return { text: 'Полоса лимитов уже на месте' }
371 }
372 await update($, isHidden, () => false)
373
374 return { text: 'Полоса лимитов снова над полем ввода' }
375 })
376
377 // Where /usage reaches the engine (the terminal), it shows the band too and runs as it
378 // always does.
379 on('command.run', { command: USAGE_COMMAND }, async ($, e, next) => {
380 if (await read($, isHidden)) {
381 await update($, isHidden, () => false)
382 }
383
384 return next(e)
385 }).catch(($, e, next) => next(e))
386
387 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
388 // The surveys draw in the same band and take it while they ask. The band is raised on the
389 // terminal and the desktop only. Closed with ✕, it leaves the band to the engine.
390 if (
391 e.props.hasSurvey ||
392 (e.surface !== 'terminal' && e.surface !== 'desktop') ||
393 (await read($, isHidden))
394 ) {
395 return next(e)
396 }
397 const view = {
398 meter: { limits: mergeLimits((await read($, meter)).limits, await read($, summary)) },
399 lastRequestAt: await read($, lastRequestAt),
400 isWorking: e.props.isWorking,
401 ttl: await read($, ttl),
402 isAuto: await read($, isAuto),
403 isWarming: await read($, isWarming),
404 now: await read($, now),
405 }
406
407 return drawBand($.ui.resolve(e), e.surface, view, {
408 close: () => void close($),
409 warm: () => void warm($, 'manual'),
410 toggleAuto: () => void toggleAuto($),
411 })
412 })
413}
414hooks/band.tsx 208 lines1import type { Elements } from 'claude-code'
2
3import type { Limit, Meter, Ttl } from '../types'
4import {
5 BAR_HEIGHT,
6 BAR_WIDTH,
7 SPINNER_SIZE,
8 barCells,
9 barSvg,
10 spinnerFrame,
11 spinnerSvg,
12 toneOfRemaining,
13 type Tone,
14} from './bar'
15import { cacheStatus, formatLeft, lifeLeft, type CacheStatus } from './cache'
16import { formatPercent, limitLabel, limitTone, resetIn } from './limits'
17
18// The band is raised on the terminal and in the desktop app. The terminal's table lists an
19// Svg too, which draws nothing there: the surface, not the table, says which bar to draw.
20export type BandSurface = 'terminal' | 'desktop'
21type Table = Elements[BandSurface]
22type El = Pick<Table, 'Box' | 'Text'>
23
24export type BandView = {
25 meter: Meter
26 lastRequestAt: number | null
27 isWorking: boolean
28 ttl: Ttl
29 isAuto: boolean
30 isWarming: boolean
31 now: number
32}
33
34// The controls: close the band, warm the cache now, and Auto on or off. The TTL is the
35// session's own, read from its transcript; nothing in the band picks it.
36export type BandActions = {
37 close: () => void
38 warm: () => void
39 toggleAuto: () => void
40}
41
42// Warm the cache now: the clockwise open-circle arrow, a restart.
43export const WARM_GLYPH = '↻'
44
45type Status = { text: string; tone: Tone | null }
46
47export function statusOf(status: CacheStatus, ttl: Ttl): Status {
48 switch (status.kind) {
49 case 'empty':
50 return { text: 'пусто, ответов ещё не было', tone: null }
51 // Drawn as a spinner beside a full bar; the words are what a reader that cannot see it hears.
52 case 'working':
53 return { text: 'идёт ответ', tone: 'success' }
54 case 'warm':
55 return { text: formatLeft(status.leftMs), tone: toneOfRemaining(lifeLeft(status, ttl)) }
56 // How long ago it lapsed says nothing a red bar does not: a resumed session's can be weeks.
57 case 'cold':
58 return { text: 'остыл', tone: 'error' }
59 }
60}
61
62// A Text takes no key: the keyed Box around it gives the text its address. One with no tone
63// is drawn dim.
64function toned({ Box, Text }: El, key: string, { text, tone }: Status) {
65 return <Box key={key}>{tone === null ? <Text dimColor>{text}</Text> : <Text color={tone}>{text}</Text>}</Box>
66}
67
68type Draw = { table: Table; surface: BandSurface }
69
70// A bar's length: CSS pixels on the desktop, cells on the terminal.
71type BarSize = { px: number; cells: number }
72
73// The cache's bar is the band's main one; the limits' are shorter, so the line holds them all.
74const CACHE_BAR: BarSize = { px: BAR_WIDTH, cells: 5 }
75const LIMIT_BAR: BarSize = { px: 36, cells: 5 }
76
77// A progress bar filled to `fraction` in the tone: cells on the terminal, an Svg on the desktop.
78function drawBar({ table, surface }: Draw, key: string, size: BarSize, fraction: number, tone: Tone, alt: string) {
79 const { Box, Text } = table
80 if (surface === 'desktop' && 'Svg' in table) {
81 const { Svg } = table
82
83 return (
84 <Box key={key} alignItems="center">
85 <Svg source={barSvg(fraction, tone, size.px)} alt={alt} width={size.px} height={BAR_HEIGHT} />
86 </Box>
87 )
88 }
89 const { filled, track } = barCells(fraction, size.cells)
90
91 return (
92 <Box key={key}>
93 {filled !== '' && <Text color={tone}>{filled}</Text>}
94 {track !== '' && <Text dimColor>{track}</Text>}
95 </Box>
96 )
97}
98
99// While a turn runs, a spinner: turning on the desktop, a frame a second on the terminal.
100function drawSpinner({ table, surface }: Draw, key: string, now: number, alt: string) {
101 const { Box, Text } = table
102 if (surface === 'desktop' && 'Svg' in table) {
103 const { Svg } = table
104
105 // An Svg drawn as an image does not animate: the interactive frame plays its SMIL.
106 return (
107 <Box key={key} alignItems="center">
108 <Svg source={spinnerSvg()} alt={alt} width={SPINNER_SIZE} height={SPINNER_SIZE} isInteractive />
109 </Box>
110 )
111 }
112
113 return (
114 <Box key={key}>
115 <Text color="success">{spinnerFrame(now)}</Text>
116 </Box>
117 )
118}
119
120// A window's reset is written once: one that resets with the window before it (a model's
121// weekly window with the week across all models) leaves it out.
122function drawLimit(draw: Draw, limit: Limit, now: number, before: Limit | undefined) {
123 const { Box, Text } = draw.table
124 const label = limitLabel(limit.kind, limit.label)
125 const percent = formatPercent(limit.percentUsed)
126 const tone = limitTone(limit.percentUsed)
127 const reset = resetIn(limit.resetsAt, now)
128 const isRepeated = before !== undefined && reset !== null && resetIn(before.resetsAt, now) === reset
129
130 return (
131 <Box key={`limit:${limit.kind}`} gap={1} alignItems="center" flexShrink={0}>
132 <Text dimColor>{label}</Text>
133 {drawBar(draw, `bar:${limit.kind}`, LIMIT_BAR, limit.percentUsed / 100, tone, `${label}: использовано ${percent}`)}
134 {toned(draw.table, `percent:${limit.kind}`, { text: percent, tone })}
135 {reset !== null && !isRepeated && <Text dimColor>{`↻ ${reset}`}</Text>}
136 </Box>
137 )
138}
139
140// One line: ✕ that closes the band and the limits on the left; at the right end a divider,
141// the cache, and its two controls beside it. A line too long for the band cuts the limits,
142// never ✕, the cache or the controls.
143export function drawBand(table: Table, surface: BandSurface, view: BandView, actions: BandActions) {
144 const { Box, Text, Button } = table
145 const draw = { table, surface }
146 const status = cacheStatus(view.lastRequestAt, view.now, view.ttl, view.isWorking)
147 const statusText = statusOf(status, view.ttl)
148 // A warmup needs a response to fork from, and is pointless while a turn refreshes the cache.
149 const canWarm = !view.isWarming && !view.isWorking && view.lastRequestAt !== null
150
151 return (
152 <Box key="band" flexDirection="row" flexWrap="nowrap" columnGap={1} alignItems="center">
153 <Box key="close" flexShrink={0} alignItems="center">
154 <Button key="close-button" label="✕" plain dimColor onPress={actions.close} />
155 </Box>
156 <Box
157 key="limits"
158 flexDirection="row"
159 flexWrap="nowrap"
160 flexGrow={1}
161 flexShrink={1}
162 minWidth={0}
163 overflow="hidden"
164 columnGap={2}
165 alignItems="center"
166 >
167 {view.meter.limits.length === 0
168 ? toned(table, 'no-limits', { text: 'Лимиты: нет данных', tone: null })
169 : view.meter.limits.map((limit, index) => drawLimit(draw, limit, view.now, view.meter.limits[index - 1]))}
170 </Box>
171 <Box key="divider" flexShrink={0} paddingX={1}>
172 <Text dimColor>│</Text>
173 </Box>
174 <Box key="cache" gap={1} alignItems="center" flexShrink={0}>
175 <Text dimColor>Кеш</Text>
176 {status.kind !== 'empty' &&
177 drawBar(
178 draw,
179 'cache-bar',
180 CACHE_BAR,
181 // A lapsed cache is a full red bar, not an empty one.
182 status.kind === 'cold' ? 1 : lifeLeft(status, view.ttl),
183 statusText.tone ?? 'error',
184 `Жизнь кеша: ${statusText.text}`,
185 )}
186 {status.kind === 'working'
187 ? drawSpinner(draw, 'cache-spinner', view.now, `Кеш: ${statusText.text}`)
188 : toned(table, 'cache-status', statusText)}
189 </Box>
190 <Box key="controls" gap={1} alignItems="center" flexShrink={0}>
191 {/* A text glyph, not an emoji: it takes the button's own font and color. */}
192 <Button key="warm" label={view.isWarming ? '⏳' : WARM_GLYPH} plain dimColor={!canWarm} onPress={actions.warm} />
193 {view.isAuto ? (
194 <Button key="auto" label="Авто" variant="primary" dimColor={false} onPress={actions.toggleAuto} />
195 ) : (
196 // Off, Auto is grey through and through: the button dim and its label the theme's
197 // inactive grey, so it does not read as a control that is on.
198 <Button key="auto" label="Авто" variant="secondary" dimColor onPress={actions.toggleAuto}>
199 <Text color="inactive" dimColor>
200 Авто
201 </Text>
202 </Button>
203 )}
204 </Box>
205 </Box>
206 )
207}
208hooks/cache.ts 143 lines1import type { ModelForkResult } from 'claude-code'
2
3import type { Ttl, Warmup } from '../types'
4
5export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
6
7// Auto warms this long before the cache would lapse. The countdown starts when a turn ends,
8// while the entry's TTL ran from its last request's start: the lead covers that reply too.
9export const LEAD_MS: Record<Ttl, number> = { '5m': 60_000, '1h': 3 * 60_000 }
10
11// Every warmup reads the whole context and counts against the limits: Auto gives up after
12// an hour in which nobody used the session.
13export const AUTO_IDLE_MS = 60 * 60_000
14
15// The one message the warmup's fork answers. It never reaches the transcript.
16export const WARM_PROMPT = 'Prompt cache keep-alive. Reply with the single word: ok'
17
18export type CacheStatus =
19 | { kind: 'empty' }
20 | { kind: 'working' }
21 | { kind: 'warm'; leftMs: number }
22 | { kind: 'cold'; agoMs: number }
23
24export function cacheStatus(lastRequestAt: number | null, now: number, ttl: Ttl, isWorking: boolean): CacheStatus {
25 if (isWorking) {
26 return { kind: 'working' }
27 }
28 if (lastRequestAt === null) {
29 return { kind: 'empty' }
30 }
31 const leftMs = lastRequestAt + TTL_MS[ttl] - now
32
33 return leftMs > 0 ? { kind: 'warm', leftMs } : { kind: 'cold', agoMs: -leftMs }
34}
35
36// How much of the cache's life is left, 0 to 1: full while a turn refreshes it.
37export function lifeLeft(status: CacheStatus, ttl: Ttl): number {
38 switch (status.kind) {
39 case 'working':
40 return 1
41 case 'warm':
42 return Math.min(1, status.leftMs / TTL_MS[ttl])
43 case 'empty':
44 case 'cold':
45 return 0
46 }
47}
48
49export type AutoState = {
50 isAuto: boolean
51 isWarming: boolean
52 isWorking: boolean
53 lastRequestAt: number | null
54 now: number
55 ttl: Ttl
56}
57
58// Auto keeps a warm cache warm: a cache already gone costs a full write either way, and the
59// next prompt pays it as well as a warmup would.
60export function isWarmDue(state: AutoState): boolean {
61 const status = cacheStatus(state.lastRequestAt, state.now, state.ttl, state.isWorking)
62
63 return state.isAuto && !state.isWarming && status.kind === 'warm' && status.leftMs <= LEAD_MS[state.ttl]
64}
65
66export function isAutoExpired(activeAt: number | null, now: number): boolean {
67 return activeAt !== null && now - activeAt >= AUTO_IDLE_MS
68}
69
70// A fork that reached the API used the cache whether or not its reply had words in it.
71export function warmupOf(result: ModelForkResult, at: number): Warmup {
72 if (result.isAnswered || result.reason === 'empty-reply') {
73 return {
74 kind: 'ok',
75 at,
76 read: result.usage.cache_read_input_tokens,
77 write: result.usage.cache_creation_input_tokens,
78 }
79 }
80 switch (result.reason) {
81 case 'nothing-to-fork':
82 return { kind: 'failed', at, message: 'в сессии ещё не было ответа Claude' }
83 case 'aborted':
84 return { kind: 'failed', at, message: 'запрос прерван' }
85 case 'api-error':
86 return {
87 kind: 'failed',
88 at,
89 message: result.status === null ? `ошибка API: ${result.error}` : `ошибка API ${result.status}: ${result.error}`,
90 }
91 }
92}
93
94export function describeWarmup(warmup: Warmup): string {
95 if (warmup.kind === 'failed') {
96 return `Подогрев не удался: ${warmup.message}`
97 }
98 // Nothing read and something written: the entry had lapsed, and the fork cached it anew.
99 if (warmup.read === 0 && warmup.write > 0) {
100 return `Кеш уже остыл — записан заново: ${formatTokens(warmup.write)} токенов`
101 }
102
103 return `Кеш подогрет: ${formatTokens(warmup.read)} токенов из кеша`
104}
105
106export function formatTokens(count: number): string {
107 if (count < 1000) {
108 return String(count)
109 }
110 if (count < 1_000_000) {
111 return `${Math.round(count / 1000)}k`
112 }
113
114 return `${(count / 1_000_000).toFixed(1)}M`
115}
116
117// What is left of the cache's life, to the minute begun: `59м`, `1ч`, `1ч 05м`. A cache
118// still alive never reads `0м`: its last minute reads `1м`.
119export function formatLeft(ms: number): string {
120 const minutes = Math.max(1, Math.ceil(ms / 60_000))
121 if (minutes < 60) {
122 return `${minutes}м`
123 }
124 const hours = Math.floor(minutes / 60)
125
126 return minutes % 60 === 0 ? `${hours}ч` : `${hours}ч ${String(minutes % 60).padStart(2, '0')}м`
127}
128
129// A span in the past or the future, to the minute: `12м`, `2ч 15м`, `3д 4ч`.
130export function formatSpan(ms: number): string {
131 const minutes = Math.max(0, Math.floor(ms / 60_000))
132 if (minutes < 60) {
133 return `${minutes}м`
134 }
135 const hours = Math.floor(minutes / 60)
136 if (hours < 24) {
137 return minutes % 60 === 0 ? `${hours}ч` : `${hours}ч ${minutes % 60}м`
138 }
139 const days = Math.floor(hours / 24)
140
141 return hours % 24 === 0 ? `${days}д` : `${days}д ${hours % 24}ч`
142}
143hooks/limits.ts 71 lines1import type { SessionRateLimit } from 'claude-code'
2
3import type { Limit, Meter } from '../types'
4import { type Tone, toneOfRemaining } from './bar'
5import { formatSpan } from './cache'
6
7const LABELS: Record<string, string> = {
8 five_hour: '5ч',
9 seven_day: '7д',
10 seven_day_opus: '7д Opus',
11 seven_day_sonnet: '7д Sonnet',
12 seven_day_fable: '7д Fable',
13 spend_limit: 'Расходы',
14}
15
16// The shorter window first: it is the one that runs out within a session. The per-model
17// weekly windows follow the account's own, any other the server keeps after them.
18const ORDER = ['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet', 'seven_day_fable']
19
20export function toMeter(rateLimits: readonly SessionRateLimit[]): Meter {
21 return { limits: sortLimits(rateLimits) }
22}
23
24// The windows the last response reported, and those only the usage summary has: a window
25// in both is the response's reading, the newer of the two.
26export function mergeLimits(reported: readonly Limit[], summary: readonly Limit[]): Limit[] {
27 const kinds = new Set(reported.map(limit => limit.kind))
28
29 return sortLimits([...reported, ...summary.filter(limit => !kinds.has(limit.kind))])
30}
31
32export function sortLimits(rateLimits: readonly (SessionRateLimit | Limit)[]): Limit[] {
33 const rank = (kind: string) => (ORDER.includes(kind) ? ORDER.indexOf(kind) : ORDER.length)
34
35 return rateLimits
36 .map(({ kind, percentUsed, resetsAt, ...rest }) => ({
37 kind,
38 percentUsed,
39 ...(resetsAt === undefined ? {} : { resetsAt }),
40 ...('label' in rest && rest.label !== undefined ? { label: rest.label } : {}),
41 }))
42 .sort((a, b) => rank(a.kind) - rank(b.kind))
43}
44
45// The window's own name where the server gave one, else the short name for its kind.
46export function limitLabel(kind: string, label?: string): string {
47 return label ?? LABELS[kind] ?? kind.replace(/_/g, ' ')
48}
49
50// The bar fills as the window is used; its color says how much of it is left.
51export function limitTone(percentUsed: number): Tone {
52 return toneOfRemaining((100 - percentUsed) / 100)
53}
54
55export function formatPercent(percentUsed: number): string {
56 return `${Math.round(percentUsed)}%`
57}
58
59// How long until the window resets; null when the API gave no time or it already passed.
60export function resetIn(resetsAt: string | undefined, now: number): string | null {
61 if (resetsAt === undefined) {
62 return null
63 }
64 const at = Date.parse(resetsAt)
65 if (Number.isNaN(at) || at <= now) {
66 return null
67 }
68
69 return formatSpan(at - now)
70}
71hooks/transcript.ts 67 lines1import type { Ttl } from '../types'
2
3// What the transcript says of the prompt cache: when a response last used it, and the TTL
4// the session writes it with. Each is null where the transcript does not say.
5export type TranscriptCache = { at: number | null; ttl: Ttl | null }
6
7type Usage = {
8 cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } | null
9}
10
11type Entry = {
12 type?: string
13 timestamp?: string
14 isSidechain?: boolean
15 message?: { model?: string; usage?: Usage }
16}
17
18// Claude Code names a project's folder after its working directory, every character but
19// letters and digits made a dash: `C:\Users\dev\app` is `C--Users-dev-app`.
20export function projectFolder(cwd: string): string {
21 return cwd.replace(/[^a-zA-Z0-9]/g, '-')
22}
23
24export function transcriptPath(configDir: string, cwd: string, sessionId: string): string {
25 return `${configDir}/projects/${projectFolder(cwd)}/${sessionId}.jsonl`
26}
27
28function ttlOf(usage: Usage): Ttl | null {
29 const creation = usage.cache_creation
30 if ((creation?.ephemeral_1h_input_tokens ?? 0) > 0) {
31 return '1h'
32 }
33
34 return (creation?.ephemeral_5m_input_tokens ?? 0) > 0 ? '5m' : null
35}
36
37// Reads the transcript from its end: the last response of the main conversation, and the
38// last one that wrote to the cache, whose split between the two TTLs names the session's.
39export function lastCacheUse(text: string): TranscriptCache {
40 const found: TranscriptCache = { at: null, ttl: null }
41 const lines = text.split('\n')
42 for (let index = lines.length - 1; index >= 0 && (found.at === null || found.ttl === null); index -= 1) {
43 const line = lines[index] ?? ''
44 if (!line.includes('"assistant"') || !line.includes('"usage"')) {
45 continue
46 }
47 let entry: Entry
48 try {
49 entry = JSON.parse(line) as Entry
50 } catch {
51 continue
52 }
53 const usage = entry.message?.usage
54 // An error the engine wrote itself (`<synthetic>`) made no request.
55 if (entry.type !== 'assistant' || entry.isSidechain === true || usage === undefined || entry.message?.model === '<synthetic>') {
56 continue
57 }
58 if (found.at === null) {
59 const at = Date.parse(entry.timestamp ?? '')
60 found.at = Number.isNaN(at) ? null : at
61 }
62 found.ttl ??= ttlOf(usage)
63 }
64
65 return found
66}
67hooks/usage.ts 85 lines1import type { Limit } from '../types'
2
3// The account's usage summary, the one /usage reads: every window the server keeps, the
4// per-model weekly ones too, where a response's headers carry only the windows it touched.
5export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
6
7// The summary is asked for after a turn at most this often, and every so often while idle.
8export const USAGE_MIN_GAP_MS = 2 * 60_000
9export const USAGE_POLL_MS = 10 * 60_000
10
11type Window = { utilization?: unknown; resets_at?: unknown }
12
13type Named = { display_name?: unknown } | null | undefined
14
15// One of the summary's `limits` rows: a window the server names itself, `Fable` for a model.
16type Row = {
17 group?: unknown
18 percent?: unknown
19 resets_at?: unknown
20 scope?: { model?: Named; surface?: Named } | null
21}
22
23// Top-level keys of the summary that are no window: the month's extra usage, and the rows.
24const NOT_WINDOWS = new Set(['extra_usage', 'limits'])
25
26const isPercent = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
27
28const slug = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, '_')
29
30// A row of a model or a product: weekly ones keyed as the top-level weekly windows are, so a
31// window the summary lists both ways shows once. The account's own rows (the five hours, the
32// week across all models) have no scope; their windows are the top-level ones.
33function rowLimit(row: unknown): Limit | null {
34 if (typeof row !== 'object' || row === null) {
35 return null
36 }
37 const { group, percent, resets_at: resetsAt, scope } = row as Row
38 const name = scope?.model?.display_name ?? scope?.surface?.display_name
39 if (typeof name !== 'string' || name === '' || !isPercent(percent)) {
40 return null
41 }
42 const isWeekly = group === 'weekly'
43 const limit: Limit = {
44 kind: `${isWeekly ? 'seven_day' : slug(String(group))}_${slug(name)}`,
45 label: isWeekly ? `7д ${name}` : name,
46 percentUsed: percent,
47 }
48
49 return typeof resetsAt === 'string' ? { ...limit, resetsAt } : limit
50}
51
52// `{ five_hour: { utilization: 12, resets_at: "…" }, seven_day_opus: null, …, limits: [ … ] }`:
53// a window the account has none of is null, and is left out.
54export function parseUsage(text: string): Limit[] {
55 let body: unknown
56 try {
57 body = JSON.parse(text)
58 } catch {
59 return []
60 }
61 if (typeof body !== 'object' || body === null || Array.isArray(body)) {
62 return []
63 }
64 const limits: Limit[] = []
65 for (const [kind, window] of Object.entries(body)) {
66 if (NOT_WINDOWS.has(kind) || typeof window !== 'object' || window === null) {
67 continue
68 }
69 const { utilization, resets_at: resetsAt } = window as Window
70 if (!isPercent(utilization)) {
71 continue
72 }
73 limits.push(typeof resetsAt === 'string' ? { kind, percentUsed: utilization, resetsAt } : { kind, percentUsed: utilization })
74 }
75 const rows = (body as { limits?: unknown }).limits
76 for (const row of Array.isArray(rows) ? rows : []) {
77 const limit = rowLimit(row)
78 if (limit !== null && !limits.some(known => known.kind === limit.kind)) {
79 limits.push(limit)
80 }
81 }
82
83 return limits
84}
85hooks/bar.ts 69 lines1export type Tone = 'success' | 'warning' | 'error'
2
3// Green while half or more is left, yellow down to a fifth, red below it.
4export function toneOfRemaining(fraction: number): Tone {
5 if (fraction >= 0.5) {
6 return 'success'
7 }
8
9 return fraction >= 0.2 ? 'warning' : 'error'
10}
11
12const clamp = (fraction: number): number => Math.min(1, Math.max(0, fraction))
13
14// The terminal's bar: a cell per tenth, the filled ones drawn in the tone, the rest a track.
15export function barCells(fraction: number, cells = 10): { filled: string; track: string } {
16 const filled = Math.round(clamp(fraction) * cells)
17
18 return { filled: '█'.repeat(filled), track: '░'.repeat(cells - filled) }
19}
20
21export const BAR_WIDTH = 40
22export const BAR_HEIGHT = 6
23
24// Colors that read on the desktop's light and dark themes alike.
25const COLORS: Record<Tone, string> = { success: '#3fb950', warning: '#d29922', error: '#f85149' }
26const TRACK = '#8b949e'
27
28// The desktop's bar: a rounded track and the fill clipped to it, so a short fill keeps
29// the track's round end instead of turning into a dot.
30export function barSvg(fraction: number, tone: Tone, width = BAR_WIDTH): string {
31 const fill = Math.round(clamp(fraction) * width)
32 const size = `width="${width}" height="${BAR_HEIGHT}"`
33
34 return (
35 `<svg xmlns="http://www.w3.org/2000/svg" ${size} viewBox="0 0 ${width} ${BAR_HEIGHT}">` +
36 `<clipPath id="track"><rect ${size} rx="${BAR_HEIGHT / 2}"/></clipPath>` +
37 `<g clip-path="url(#track)">` +
38 `<rect ${size} fill="${TRACK}" fill-opacity="0.3"/>` +
39 `<rect width="${fill}" height="${BAR_HEIGHT}" fill="${COLORS[tone]}"/>` +
40 `</g></svg>`
41 )
42}
43
44export const SPINNER_SIZE = 12
45
46// The desktop's spinner: a quarter arc turning once a second over a faint ring.
47export function spinnerSvg(): string {
48 const center = SPINNER_SIZE / 2
49 const radius = center - 1.5
50
51 return (
52 `<svg xmlns="http://www.w3.org/2000/svg" width="${SPINNER_SIZE}" height="${SPINNER_SIZE}" ` +
53 `viewBox="0 0 ${SPINNER_SIZE} ${SPINNER_SIZE}" fill="none" stroke-width="1.5">` +
54 `<circle cx="${center}" cy="${center}" r="${radius}" stroke="${TRACK}" stroke-opacity="0.3"/>` +
55 `<path d="M${center} ${center - radius}A${radius} ${radius} 0 0 1 ${center + radius} ${center}" ` +
56 `stroke="${COLORS.success}" stroke-linecap="round">` +
57 `<animateTransform attributeName="transform" type="rotate" ` +
58 `from="0 ${center} ${center}" to="360 ${center} ${center}" dur="1s" repeatCount="indefinite"/>` +
59 `</path></svg>`
60 )
61}
62
63// The terminal's spinner: a quarter-filled circle, turned a quarter each second the band redraws.
64const FRAMES = ['◐', '◓', '◑', '◒']
65
66export function spinnerFrame(now: number): string {
67 return FRAMES[Math.floor(now / 1000) % FRAMES.length] ?? '◐'
68}
69types/index.d.ts 39 lines1// How long the API keeps a prompt in its cache after the last request that used it.
2export type Ttl = '5m' | '1h'
3
4// One rate-limit window of the account: `five_hour`, `seven_day`, a gateway's `spend_limit`;
5// `label` when the server names the window itself (`7д Fable`).
6export type Limit = { kind: string; percentUsed: number; resetsAt?: string; label?: string }
7
8// The account's windows, as the status line has them.
9export type Meter = { limits: Limit[] }
10
11// What a warmup did: the prompt cache read (or written anew), or why it was not. Its toast
12// says it; the band shows only the countdown it restarts.
13export type Warmup =
14 | { kind: 'ok'; at: number; read: number; write: number }
15 | { kind: 'failed'; at: number; message: string }
16
17declare module 'claude-code' {
18 interface PluginState {
19 'limits-cache': {
20 meter: Meter
21 // The usage summary's windows (/usage's): the per-model weekly ones the responses omit.
22 summary: Limit[]
23 // When a request last used the cache: a main-thread turn's end, or a warmup.
24 lastRequestAt: number | null
25 // A main-thread turn is running: each of its requests refreshes the cache itself.
26 isWorking: boolean
27 ttl: Ttl
28 isAuto: boolean
29 // When the person last did something in the session; Auto stops an hour after it.
30 activeAt: number | null
31 isWarming: boolean
32 // The clock the band's countdowns are drawn from.
33 now: number
34 // The person closed the band with ✕; /limits (and /usage in the terminal) shows it again.
35 isHidden: boolean
36 }
37 }
38}
39