SLOPSHOPPER

cache-timer

Shows how long the prompt cache has left, next to the model name in the footer

newspinnertimer
A shopper browsing a rack in a slop shop
README

cache-timer

プロンプトキャッシュが切れるまでの残り時間を、フッターのモデル名の左に Cache 59m のように分単位で表示します。

会話のモデルへのリクエストがキャッシュを読み書きするたびに、残り時間は設定の長さに戻ります。 サブエージェントのリクエストは数えません。 セッションを始めてから最初のリクエストまでは、何も表示しません。

残り時間表示
5 分以上Cache 59m(通常の色)
1 分以上 5 分未満Cache 4m(警告の色)
1 分未満Cache <1m(警告の色)
切れた後Cache 切れ(エラーの色)

分の数は切り捨てです。Cache 59m は残り 59 分以上 60 分未満を表します。 数えている間だけ、表示が変わるときに 1 分に 1 回更新し、切れたときにもう 1 回更新します。切れた後は、次のリクエストまで更新しません。 毎秒の更新はデスクトップアプリでほかの mod のパネルまで描き直させ、ボタンがちらつくため、分単位にしています。

使い方

Claude Code v2.1.286 以降向けです。 インストール方法は、リポジトリの README を見てください。 インストールすると、フッターに残り時間が出ます。操作は要りません。

設定

キャッシュの有効期間は、設定 ttl で選びます。

値有効期間
1h1 時間(既定)
5m5 分

/config で cache-timer.ttl を変えます。例: /config cache-timer.ttl=5m

Claude Code が実際にどちらの有効期間でキャッシュしたかは、この mod からは分かりません。 使っている環境に合わせて選んでください。

Source 2 files
hooks/register.js 135 lines
1// Shows how long the prompt cache has left, next to the model name in the footer.
2//
3// Each model request of the main conversation that reads or writes the prompt cache restarts
4// its lifetime. The time of the last such request is kept in $.state, so a hot reload of this
5// module keeps counting down from it; the footer reads it while drawing and is drawn again when
6// it changes. While the countdown runs, a ticker writes the time to $.state only when the label
7// changes (once a minute, and once at expiry); only the footer reads that value, so only the
8// footer is drawn again (a $.ui.invalidate would redraw every plugin's ui.render). Even so, on
9// the desktop app any redraw of a mod drawing rebuilds every mod drawing, other mods' open panes
10// included, so the label counts minutes to keep the redraws few.
11
12// The last request that touched the cache: milliseconds since the epoch; unset before the first
13const LAST_REQUEST = { plugin: 'cache-timer', key: 'lastRequest' }
14// The ticker's last tick: milliseconds since the epoch. Read by the footer only to be drawn again
15const TICK = { plugin: 'cache-timer', key: 'tick' }
16
17// The `ttl` option's values. The API's usage (turn.step's) gives the four token counts only,
18// not which lifetime a cache write used, so the option decides.
19const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
20const DEFAULT_TTL = '1h'
21
22// Under this many seconds left, the countdown turns to the warning color
23const WARNING_SECONDS = 5 * 60
24
25// The lifetime the `ttl` option chose; set as the module registers
26let ttlMs = TTL_MS[DEFAULT_TTL]
27// The countdown's next redraw (a $.clock.after timer); one at a time, none once it reaches zero
28let ticker = null
29
30export function register(on, options) {
31  ttlMs = TTL_MS[options?.ttl] ?? TTL_MS[DEFAULT_TTL]
32
33  // Fires on the session's start, and again for this module after a hot reload (which drops
34  // the timers): carries on a countdown the state still holds
35  on('session.start', async ($, e, next) => {
36    const { value: lastRequest } = await $.state.get(LAST_REQUEST)
37    const now = await $.clock.now()
38    if (typeof lastRequest === 'number' && now < lastRequest + ttlMs) {
39      startTicker($, lastRequest + ttlMs, now)
40    } else {
41      ticker?.cancel()
42      ticker = null
43    }
44    return next(e)
45  })
46
47  // One model request; a subagent's (agentId set) runs in its own loop and is left out
48  on('turn.step', async function* ($, e, next) {
49    const result = yield* next(e)
50    if (e.agentId == null && touchesCache(result?.usage)) {
51      const now = await $.clock.now()
52      // Draws the footer again for every instance that read it
53      await $.state.set(LAST_REQUEST, now)
54      startTicker($, now + ttlMs, now)
55    }
56    return result
57  })
58
59  // The mode labels in the footer: on the desktop app a tree of our own shows left of the
60  // model name. The countdown comes first, then what the engine (and any mod beneath) draws,
61  // so the original labels stay.
62  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
63    const { value: lastRequest } = await $.state.get(LAST_REQUEST)
64    // Subscribes this drawing to the ticker; the time itself comes from the clock below, which
65    // is right before the first tick as well
66    await $.state.get(TICK)
67    const beneath = await next(e)
68    if (typeof lastRequest !== 'number') return beneath
69
70    const { Box, Text } = $.ui.resolve(e)
71    const left = secondsLeft(lastRequest + ttlMs - (await $.clock.now()))
72    // A Text takes no key; the Box around it carries one
73    const timer = Box({ key: 'cache-timer', children: [Text({ ...colorOf(left), children: [labelOf(left)] })] })
74    // With no modes the engine's drawing has no labels to show; leave it out so no gap trails
75    const isEmpty = beneath?.type === 'engine' && e.props.modes.length === 0
76    if (!beneath || isEmpty) return timer
77    return Box({ flexDirection: 'row', columnGap: 2, children: [timer, beneath] })
78  })
79}
80
81// Redraws the countdown each time its label changes, from `now` until `expiresAt`, replacing
82// any ticker before it. Each tick writes the time to TICK, which draws again the footers that
83// read it, and nothing else
84function startTicker($, expiresAt, now) {
85  ticker?.cancel()
86  ticker = null
87  const schedule = (remainingMs) => {
88    if (remainingMs <= 0) return
89    const own = $.clock.after(remainingMs - nextChangeAt(remainingMs), async () => {
90      if (ticker !== own) return
91      ticker = null
92      const tickedAt = await $.clock.now()
93      await $.state.set(TICK, tickedAt)
94      // A new request or a reload replaced this ticker while the state was being written
95      if (ticker !== null) return
96      schedule(expiresAt - tickedAt)
97    })
98    ticker = own
99  }
100  schedule(expiresAt - now)
101}
102
103// The remaining milliseconds at which the label next changes, below `remainingMs`: the label
104// shows whole minutes of the whole seconds left (as the minutes of mm:ss), so it changes when
105// the seconds left reach 60k - 1 for some k >= 1 (59:59, 58:59, ..., 00:59), and at zero
106function nextChangeAt(remainingMs) {
107  const k = Math.ceil((remainingMs + 1000) / 60_000) - 1
108  return k >= 1 ? k * 60_000 - 1000 : 0
109}
110
111// Whether a response read from or wrote to the prompt cache; one that did neither (no usage,
112// a prompt too short to cache) leaves the last countdown as it was
113function touchesCache(usage) {
114  if (!usage) return false
115  return (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) > 0
116}
117
118// Whole seconds left, rounded up so the label reads "切れ" only once the time is over
119function secondsLeft(remainingMs) {
120  return Math.ceil(remainingMs / 1000)
121}
122
123// Whole minutes up to an hour (60m included) and beyond; "<1m" under a minute, never "0m"
124function labelOf(seconds) {
125  if (seconds <= 0) return 'Cache 切れ'
126  if (seconds < 60) return 'Cache <1m'
127  return 'Cache ' + Math.floor(seconds / 60) + 'm'
128}
129
130function colorOf(seconds) {
131  if (seconds <= 0) return { color: 'error' }
132  if (seconds < WARNING_SECONDS) return { color: 'warning' }
133  return {}
134}
135
types/index.d.ts 19 lines
1// The values cache-timer keeps in $.state for the session.
2
3declare module 'claude-code' {
4  interface PluginState {
5    'cache-timer': {
6      /**
7       * When the main conversation's last model request that read or wrote the prompt cache
8       * completed, in milliseconds since the epoch. Unset before the first such request.
9       */
10      lastRequest: number
11      /**
12       * The time of the countdown's last one-second tick, in milliseconds since the epoch.
13       * Written only to draw the footer again; unset before the first tick.
14       */
15      tick: number
16    }
17  }
18}
19