SLOPSHOPPER

limits-cache

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

newbandcommandtoastmodelnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits-cache
› 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 › /limits ⎿ limits-cache: Полоса лимитов уже на месте ✕ 5ч ██░░░ 31% │ Кеш █████ 1ч 01м ↻ [ Авто ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✕ 5ч ██░░░ 31% │ Кеш █████ 1ч 01м ↻ [ Авто ]
README

limits-cache

версия Claude Code

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

Полоса limits-cache над полем ввода

Что на полосе

ЭлементЧто показывает
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.

Откуда данные

  • На подписке лимиты мод берёт из того же API, что и /usage: раз в 10 минут и после ответов, но не чаще раза в 2 минуты.
  • С API-ключом, через шлюз или облачного провайдера полоса показывает то, что сообщают сами ответы.
  • Время жизни кеша (5 минут или час) мод читает из транскрипта сессии.

Изменения по версиям: CHANGELOG.md.

Source 8 files
hooks/register.tsx 414 lines
1import { 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}
414
hooks/band.tsx 208 lines
1import 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}
208
hooks/cache.ts 143 lines
1import 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}
143
hooks/limits.ts 71 lines
1import 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}
71
hooks/transcript.ts 67 lines
1import 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}
67
hooks/usage.ts 85 lines
1import 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}
85
hooks/bar.ts 69 lines
1export 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}
69
types/index.d.ts 39 lines
1// 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