SLOPSHOPPER

token-weather-cache

Context-window weather band above the prompt, plus a prompt-cache countdown with expiry toasts. Modified from Anthropic's token-weather example.

newbandtoasttimer
v0.1.0Apache-2.0updated 2026-10-06youllook/ClaudeMods/plugins/token-weather-cache
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather-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 ☁ Cloudy 49% of context 97.4k / 200k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% of context 97.4k / 200k
README

token-weather-cache

提示框上方一條 context 用量天氣列,加上 prompt cache 倒數與過期提醒。

A context-window weather band above the prompt, plus a prompt-cache countdown with expiry toasts.

改自 Anthropic 官方範例 token-weather:原本右側的「最近 12 輪長條圖」換成快取倒數。非官方版本。

長這樣

 ☁  Cloudy  34% of context  68k / 200k   ⏱ cache 42m left (1h)
區塊意思
☀ ☁ ☂ ☇ ↯context 用量:<25% Clear、<50% Cloudy、<75% Showers、<90% Storm、以上 Compact soon
68k / 200k已用 token / 模型的 context window(和 status line 同一組數字)
⏱ cache 42m距離 prompt cache 過期還剩多久。綠 → 剩不到 40% 壽命轉黃 → 剩 5 分鐘轉紅(5m 快取則剩 1 分鐘);最後一分鐘改成 0:30 秒數
expired: next message rewrites 62.5k已過期:下一則訊息要重寫這麼多 token
  • 剩 5 分鐘和 1 分鐘時各跳一次 toast:送任何訊息就能讓快取續命。
  • 計時器只在倒數文字要變、或該跳 toast 時才醒來,不是每秒跑。
  • 只看主對話的請求,subagent 不算(它們有自己的快取)。

TTL 怎麼決定(先符合者優先)

FORCE_PROMPT_CACHING_5M → CLAUDE_CODE_PROMPT_CACHE_TTL → 設定 promptCacheTtl → ENABLE_PROMPT_CACHING_1H → 帳號(訂閱且額度內 1h,否則 5m)。

能碰到什麼

hooks: session.start, session.end, turn.step, turn.complete, ui.render{component=AbovePrompt}
calls: $.clock.after, $.clock.now, $.env.get, $.session.usage, $.settings.read, $.ui.invalidate, $.ui.resolve, $.ui.toast

只讀用量和設定、畫一行 UI、跳 toast。不連網、不寫檔、不送模型請求(L0)。

安裝

需要 Claude Code v2.1.287 以上。

claude plugin marketplace add youllook/ClaudeMods
claude plugin install token-weather-cache@claude-mods

和官方 token-weather 或其他畫 AbovePrompt 的 mod(例如 cache-countdown)會搶同一條位置,擇一安裝。

測試

node tests/token-weather.test.mjs

用假的 $ 模擬一小時:檢查顏色變化、toast 時機與過期顯示。

授權

Apache-2.0(見 LICENSE),原作 Copyright 2026 Anthropic PBC。 快取倒數邏輯改自 jmac122/cache-countdown(MIT),聲明見 THIRD_PARTY_NOTICES.md。

Source 1 files
hooks/token-weather.mjs 242 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Modified 2026-10-06 by youllook: replaced the per-turn chart with a prompt-cache
5// countdown (turn.step, session.end, timer, toasts). Original: token-weather in
6// anthropics/claude-code-playground. See THIRD_PARTY_NOTICES.md for the MIT notice
7// covering the adapted cache-countdown logic.
8//
9// Token Weather: a live forecast of the context window, above the prompt.
10//
11// turn.complete: after each main-loop turn, read the context window's fill
12// from $.session.usage() (the same figures the status line shows).
13// session.start: take a first reading, so the band shows before any turn.
14// turn.step: time each main-loop request and note whether it touched the
15// prompt cache, for the cache countdown.
16// ui.render (AbovePrompt): one line: icon, forecast word, percent, tokens
17// used of the window, and the time left before the prompt cache expires.
18//
19// Cache countdown: TTL rules and the timer pacing are adapted from
20// jmac122/cache-countdown (MIT), itself from davila7/claude-code-templates
21// prompt-cache-control (MIT). The lifetime counts from the start of the last
22// request that read or wrote the cache; any message refreshes it.
23//
24// The host reads on(...) and $.noun.method(...) from source, so they are
25// spelled literally, and helpers that take $ are top-level functions.
26
27// Forecast bands, by percent of the window used.
28const FORECAST = [
29// Single-width text symbols, not emoji: they line up in every terminal font.
30  { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
31  { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
32  { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
33  { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
34  { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
35];
36
37const TTL_MS = { "5m": 300_000, "1h": 3_600_000 };
38// Toasts at these seconds left; marks longer than the TTL are skipped.
39const TOAST_AT = [300, 60];
40// Inside this window the countdown shows m:ss and ticks each second.
41const FINAL_MS = 60_000;
42
43// Latest reading: { tokens, window, percent }.
44let reading = null;
45// Last main-loop request that touched the cache: { startedAt, tokens }.
46let lastCache = null;
47let ttl = "5m";
48let timer;
49// Lowest toast mark already shown for the current cache entry.
50let toastLevel = Infinity;
51// Clock time of the last tick or request; ui.render draws from it.
52let nowMs = 0;
53
54export function register(on) {
55  on("session.start", async ($, e, next) => {
56    const result = await next(e);
57    reading = null;
58    lastCache = null;
59    await decideTtl($);
60    await takeReading($);
61    return result;
62  });
63
64  on("session.end", async ($, e, next) => {
65    stopTimer();
66    if (e.reason === "clear") {
67      lastCache = null;
68    }
69    return next(e);
70  });
71
72  on("turn.step", async function* ($, e, next) {
73    if (e.agentId) {
74      return yield* next(e);
75    }
76    const startedAt = await $.clock.now();
77    const r = yield* next(e);
78    const u = r?.usage;
79    if (u) {
80      const read = u.cache_read_input_tokens ?? 0;
81      const write = u.cache_creation_input_tokens ?? 0;
82      if (read + write > 0) {
83        lastCache = { startedAt, tokens: read + write + (u.input_tokens ?? 0) };
84        nowMs = startedAt;
85        toastLevel = Infinity;
86        schedule($, 0);
87      }
88    }
89    return r;
90  });
91
92  on("turn.complete", async ($, e, next) => {
93    const result = await next(e);
94    if (e.agentId) {
95      return result;
96    }
97    await takeReading($);
98    return result;
99  });
100
101  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
102    if (e.hasSurvey || !reading) {
103      return next(e);
104    }
105    const { Box, Text } = $.ui.resolve(e);
106    return band(Box, Text, nowMs);
107  });
108}
109
110async function takeReading($) {
111  try {
112    const { context, rateLimits } = await $.session.usage();
113    if (!context || !context.window) {
114      return;
115    }
116    const tokens = context.tokens ?? 0;
117    const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
118    // The session.start reading is 0 before any response; keep the last real one.
119    if (tokens > 0 || !reading) {
120      reading = { tokens, window: context.window, percent };
121    }
122    if (ttlByAccount) {
123      ttl = accountTtl(rateLimits ?? []);
124    }
125    $.ui.invalidate("ui.render");
126  } catch {
127    // No reading this turn; the band keeps the last one.
128  }
129}
130
131// The TTL Claude Code asks for, first match wins: FORCE_PROMPT_CACHING_5M,
132// CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting,
133// ENABLE_PROMPT_CACHING_1H, then the account (1h on a subscription within
134// plan usage, 5m otherwise).
135let ttlByAccount = true;
136async function decideTtl($) {
137  const none = () => null;
138  const isOn = (v) => v === "1" || v?.toLowerCase() === "true";
139  const asTtl = (v) => (v === "5m" || v === "1h" ? v : undefined);
140  const force5m = await $.env.get("FORCE_PROMPT_CACHING_5M").catch(none);
141  const ttlVar = await $.env.get("CLAUDE_CODE_PROMPT_CACHE_TTL").catch(none);
142  const enable1h = await $.env.get("ENABLE_PROMPT_CACHING_1H").catch(none);
143  const setting = (await $.settings.read().catch(() => ({}))).promptCacheTtl;
144  const pinned = isOn(force5m) ? "5m" : asTtl(ttlVar) ?? asTtl(setting) ?? (isOn(enable1h) ? "1h" : undefined);
145  ttlByAccount = !pinned;
146  ttl = pinned ?? "5m";
147}
148
149function accountTtl(windows) {
150  const plan = windows.filter((w) => w.kind === "five_hour" || w.kind === "seven_day");
151  if (plan.length === 0) return "5m";
152  return plan.some((w) => w.percentUsed >= 100) ? "5m" : "1h";
153}
154
155function leftMs(now) {
156  return lastCache ? Math.max(0, lastCache.startedAt + TTL_MS[ttl] - now) : 0;
157}
158
159function stopTimer() {
160  timer?.cancel();
161  timer = undefined;
162}
163
164function schedule($, delay) {
165  timer?.cancel();
166  // A tick that fails (module unloading) stops the timer rather than retrying.
167  timer = $.clock.after(delay, () => void tick($).catch(stopTimer));
168}
169
170// Wakes only when the countdown text changes or a toast is due.
171async function tick($) {
172  nowMs = await $.clock.now();
173  const left = leftMs(nowMs);
174  const secs = Math.ceil(left / 1000);
175  const due = TOAST_AT.filter((m) => m * 1000 < TTL_MS[ttl] && secs <= m && m < toastLevel);
176  if (due.length && left > 0) {
177    toastLevel = Math.min(...due);
178    $.ui.toast(`Prompt cache expires in ${clock(left)}: send any message to keep it warm`);
179  }
180  $.ui.invalidate("ui.render");
181  if (left > 0) {
182    schedule($, nextDelay(left));
183  } else {
184    timer = undefined;
185  }
186}
187
188function nextDelay(left) {
189  const period = left > FINAL_MS ? 60_000 : 1_000;
190  let d = ((left - 1) % period) + 1;
191  if (left > FINAL_MS) d = Math.min(d, left - FINAL_MS);
192  for (const m of TOAST_AT) {
193    if (m * 1000 < left) d = Math.min(d, left - m * 1000);
194  }
195  return Math.max(d, 20);
196}
197
198function band(Box, Text, now) {
199  const f = forecastFor(reading.percent);
200  const parts = [
201    Text({ color: f.color, bold: true, children: `${f.icon}  ${f.word}` }),
202    Text({ children: `  ${reading.percent}% of context` }),
203    Text({ dimColor: true, children: `  ${short(reading.tokens)} / ${short(reading.window)}` }),
204  ];
205  if (lastCache) {
206    const left = leftMs(now);
207    parts.push(Text({ dimColor: true, children: "   ⏱ cache " }));
208    if (left > 0) {
209      parts.push(Text({ color: cacheColor(left), bold: left <= FINAL_MS, children: countdown(left) }));
210      parts.push(Text({ dimColor: true, children: ` left (${ttl})` }));
211    } else {
212      parts.push(Text({ color: "red", children: `expired: next message rewrites ${short(lastCache.tokens)}` }));
213    }
214  }
215  return Box({ flexDirection: "row", paddingX: 1, children: parts });
216}
217
218// Green while plenty, yellow below 40% of the lifetime, red from 5 minutes (1 minute on a 5m cache).
219function cacheColor(left) {
220  if (left <= Math.min(300_000, TTL_MS[ttl] * 0.2)) return "red";
221  return left / TTL_MS[ttl] <= 0.4 ? "yellow" : "green";
222}
223
224function countdown(left) {
225  return left > FINAL_MS ? `${Math.ceil(left / 60_000)}m` : clock(left);
226}
227
228function clock(ms) {
229  const total = Math.max(0, Math.ceil(ms / 1000));
230  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
231}
232
233function forecastFor(percent) {
234  return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1];
235}
236
237function short(n) {
238  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
239  if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
240  return String(n);
241}
242