Animated usage meter above the prompt: context window, rate limits, cost, and a live prompt-cache expiry countdown

兩個 Claude Code 擴展(function hooks 模組),終端和 Claude 桌面版(Code 分頁)都能用:
Claude Code 的擴展 API 目前是搶先體驗版,版本更新時可能改變。這兩個擴展在 Claude Code 2.1.289 上開發與測試。
<img alt="cc-pulse:輸入框上方的狀態列,滑鼠移到 cache 上時在下方展開明細" src="docs/cc-pulse-light.svg">
<sub>圖片是會動的 SVG,用擴展本身的繪製程式和範例資料產生:緩存倒數逐秒跳動,進度條上有光澤掃過。</sub>
輸入框上方的一行:
| 項目 | 內容 |
|---|---|
| ctx | 上下文已用的百分比 |
| 5h / 7d | 訂閱方案的 5 小時與 7 天額度;用量超過 80% 時一併顯示重置時間 |
| cache | 提示緩存剩下的時間。Claude 工作時顯示 live,過期後顯示 cold |
| $ | 這個會話目前的花費 |
保溫開關:cache 旁邊的「保溫」開關,預設關閉。打開後,閒置時會在緩存過期前約 30 秒送出一個極短的請求,讓緩存重新計時,每段閒置最多 3 次;你送出下一則訊息後次數歸零。每次保溫都會以緩存讀取的價格把整段上下文計費一次,上下文越大越貴。
指令:/pulse 顯示或隱藏狀態列。
設定放在 ~/.claude/settings.json 的 pluginConfigs:
| 欄位 | 預設值 | 說明 |
|---|---|---|
cacheTtl | auto | 緩存有效期:auto(自動推測)、5m、1h |
warnBeforeExpiry | true | 緩存過期前一分鐘跳出提示 |
keepWarm | false | 新會話是否一開始就打開保溫 |
keepWarmMax | 3 | 每段閒置最多保溫幾次 |
{
"pluginConfigs": {
"cc-pulse": { "options": { "cacheTtl": "1h" } }
}
}
<img alt="task-flow:側邊面板的縱向時間軸,步驟 3 進行中、步驟 4 受阻、步驟 5 已引用" src="docs/task-flow-light.svg">
Claude 接到多步驟的需求時,會先在側邊面板畫出計畫,做的過程中逐步更新每一步的狀態。
[步驟 2.1「…」] 插進輸入框,接著寫你想怎麼改。寫不進輸入框時,改成附在你的下一則訊息裡一起送出。指令:/flow 打開面板。
Claude 透過擴展註冊的兩個工具 mcp__task-flow__plan、mcp__task-flow__step 畫出和更新流程;擴展會在對話的第一則訊息附上使用說明,Claude 不需要額外設定。
~/.claude/mods: git clone https://github.com/Chinsin/claude-code-mods.git ~/.claude/mods
~/.claude/settings.json 的 env 加上這兩行(多個路徑用冒號分隔): {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/cc-pulse:~/.claude/mods/task-flow",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH 讓擴展的檔案存檔後自動重新載入,不需要的話可以省略。
claude)。之後新開的會話都會載入這兩個擴展。只想試用一次,不改設定:
claude --plugin-dir ~/.claude/mods/cc-pulse --plugin-dir ~/.claude/mods/task-flow
claude plugin validate ~/.claude/mods/task-flow # 檢查設定與程式
claude plugin test ~/.claude/mods/task-flow # 執行測試
擴展載入一次後,Claude Code 會在每個擴展的 .claude-plugin/types/ 產生型別定義,之後就能用 tsc -p <擴展資料夾> 做型別檢查。這個資料夾每次載入都會重新產生,所以沒有放進倉庫。
hooks/register.tsx 387 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { PulseCache, PulseFrame, PulseUsage } from '../types'
5import { ACCENT, compact, dotSvg, svgMeters, switchSvg } from './svg'
6
7const usageAtom = atom({ plugin: 'cc-pulse', key: 'usage' } as const, null)
8const cacheAtom = atom({ plugin: 'cc-pulse', key: 'cache' } as const, {
9 lastHitAt: null,
10 readTokens: 0,
11 writeTokens: 0,
12 learnedTtl: null,
13 warmed: 0,
14} as PulseCache)
15const hiddenAtom = atom({ plugin: 'cc-pulse', key: 'isHidden' } as const, false)
16const warmChoiceAtom = atom({ plugin: 'cc-pulse', key: 'warmChoice' } as const, null as 'on' | 'off' | null)
17
18const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
19const WARN_MS = 60_000
20// Keep-warm pings this long before expiry, leaving room for a slow request.
21const WARM_LEAD_MS = 30_000
22const WARM_PROMPT = 'Keep-alive ping from a status plugin; reply with just: ok'
23// A hit after this long idle can only have come from a 1h cache.
24const LONG_GAP_MS = TTL_MS['5m'] + 30_000
25
26type Ttl = keyof typeof TTL_MS
27
28export function toPulseUsage(u: SessionUsage | SessionMeasureInput): PulseUsage {
29 return {
30 contextTokens: u.context.tokens,
31 contextWindow: u.context.window,
32 contextPercent: u.context.percent,
33 limits: u.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
34 costUsd: u.cost?.usd,
35 }
36}
37
38export function resolveTtl(option: string, learned: Ttl | null, envSaysHour: boolean): Ttl {
39 if (option === '5m' || option === '1h') return option
40 if (learned) return learned
41 return envSaysHour ? '1h' : '5m'
42}
43
44type Ctx = {
45 ttlOption: string
46 shouldWarn: boolean
47 envSaysHour: boolean
48 isWorking: boolean
49 /** Keep-warm pings allowed per idle stretch while it is on. */
50 warmLimit: number
51 /** Whether a session starts with keep-warm on (the keepWarm setting). */
52 isWarmByDefault: boolean
53 isWarming: boolean
54 warnTimer: { cancel: () => void } | null
55 /** The fills the desktop meters last drew, so the next draw eases from them. */
56 shown: Record<string, number>
57 /** The keep-warm switch as last drawn, so the next draw slides from it. */
58 wasWarmOn: boolean
59}
60
61function ttlOf(ctx: Ctx, cache: PulseCache): Ttl {
62 return resolveTtl(ctx.ttlOption, cache.learnedTtl, ctx.envSaysHour)
63}
64
65// Keep-warm's budget now: its limit while the switch is on, else 0.
66async function warmMaxOf($: EngineInterface, ctx: Ctx): Promise<number> {
67 const choice = await read($, warmChoiceAtom)
68 const isOn = choice === null ? ctx.isWarmByDefault : choice === 'on'
69 return isOn ? ctx.warmLimit : 0
70}
71
72// The 保溫 button: keep-warm on or off for this session. Turning it on gives
73// a fresh budget and takes effect on the cache as it stands.
74async function toggleWarm($: EngineInterface, ctx: Ctx) {
75 const isOn = (await warmMaxOf($, ctx)) > 0
76 await update($, warmChoiceAtom, () => (isOn ? 'off' : 'on'))
77 const cache = await update($, cacheAtom, c => (isOn ? c : { ...c, warmed: 0 }))
78 const now = await $.clock.now()
79 await scheduleExpiry($, ctx, cache, now)
80 if (isOn) {
81 $.ui.toast('已關閉自動保溫。')
82 } else {
83 const isCold = cache.lastHitAt === null || cache.lastHitAt + TTL_MS[ttlOf(ctx, cache)] <= now
84 $.ui.toast(
85 `已開啟自動保溫:閒置時在緩存過期前延長,最多 ${ctx.warmLimit} 次。` +
86 (isCold ? '目前沒有有效的緩存,下一次回應後開始。' : ''),
87 )
88 }
89}
90
91// Before the cache lapses while idle: keep it warm while the budget lasts,
92// else toast a minute ahead, once.
93async function scheduleExpiry($: EngineInterface, ctx: Ctx, cache: PulseCache, now: number) {
94 ctx.warnTimer?.cancel()
95 ctx.warnTimer = null
96 if (cache.lastHitAt === null) return
97 const expiresAt = cache.lastHitAt + TTL_MS[ttlOf(ctx, cache)]
98 const warmed = cache.warmed ?? 0
99 const warmMax = await warmMaxOf($, ctx)
100 if (warmed < warmMax && expiresAt > now) {
101 ctx.warnTimer = $.clock.after(Math.max(0, expiresAt - WARM_LEAD_MS - now), () => {
102 if (!ctx.isWorking) void keepWarm($, ctx)
103 })
104 return
105 }
106 if (!ctx.shouldWarn) return
107 const wait = expiresAt - WARN_MS - now
108 if (wait <= 0) return
109 const tail = warmMax > 0 ? ` Keep-warm used ${warmed}/${warmMax}.` : ''
110 ctx.warnTimer = $.clock.after(wait, () => {
111 if (!ctx.isWorking) $.ui.toast(`Prompt cache expires in 1 minute; send a prompt to keep it warm.${tail}`)
112 })
113}
114
115// One tiny request over the session's own transcript: the API serves the
116// prefix from the cache, and reading it starts the TTL over.
117async function keepWarm($: EngineInterface, ctx: Ctx) {
118 if (ctx.isWarming) return
119 ctx.isWarming = true
120 try {
121 // The switch may have gone off since the timer was set.
122 const warmMax = await warmMaxOf($, ctx)
123 if (warmMax === 0) return
124 const before = await read($, cacheAtom)
125 const r = await $.model.fork({ prompt: WARM_PROMPT }).catch((err: unknown) => ({
126 isAnswered: false as const,
127 reason: err instanceof Error ? err.message : 'the request was refused',
128 }))
129 const u = 'usage' in r ? r.usage : undefined
130 const warmed = (before.warmed ?? 0) + 1
131 if (u && u.cache_read_input_tokens > 1024) {
132 await recordHit($, ctx, u, warmed)
133 $.ui.toast(`Kept the prompt cache warm (${compact(u.cache_read_input_tokens)} tokens) · ${warmed}/${warmMax}`)
134 } else {
135 // A miss means the entry was gone already; stop spending on it.
136 await update($, cacheAtom, c => ({ ...c, warmed: warmMax }))
137 const why = r.isAnswered ? 'the cache had already lapsed' : r.reason
138 $.ui.toast(`Keep-warm stopped: ${why}.`)
139 }
140 await refreshUsage($)
141 } finally {
142 ctx.isWarming = false
143 }
144}
145
146// The response touched the prompt cache: its TTL starts over from now.
147async function recordHit($: EngineInterface, ctx: Ctx, u: ModelUsage, warmed?: number) {
148 const now = await $.clock.now()
149 const prev = await read($, cacheAtom)
150 let learnedTtl = prev.learnedTtl
151 if (
152 prev.lastHitAt !== null &&
153 now - prev.lastHitAt > LONG_GAP_MS &&
154 now - prev.lastHitAt < TTL_MS['1h'] &&
155 u.cache_read_input_tokens > 1024
156 ) {
157 learnedTtl = '1h'
158 await $.store.set('learnedTtl', '1h')
159 }
160 const cache: PulseCache = {
161 lastHitAt: now,
162 readTokens: u.cache_read_input_tokens,
163 writeTokens: u.cache_creation_input_tokens + u.input_tokens,
164 learnedTtl,
165 warmed: warmed ?? prev.warmed ?? 0,
166 }
167 await update($, cacheAtom, () => cache)
168 await scheduleExpiry($, ctx, cache, now)
169}
170
171// The figures after a response; a failed read leaves the last ones standing.
172async function refreshUsage($: EngineInterface) {
173 try {
174 const usage = await $.session.usage()
175 await update($, usageAtom, () => toPulseUsage(usage))
176 } catch {
177 // keep what the band shows
178 }
179}
180
181export const register: Register = (on, options) => {
182 const ctx: Ctx = {
183 ttlOption: String(options.cacheTtl ?? 'auto'),
184 shouldWarn: options.warnBeforeExpiry !== false,
185 warmLimit: Math.max(1, Math.floor(Number(options.keepWarmMax ?? 3))),
186 isWarmByDefault: options.keepWarm === true,
187 isWarming: false,
188 shown: {},
189 wasWarmOn: false,
190 envSaysHour: false,
191 isWorking: false,
192 warnTimer: null,
193 }
194
195 on('session.start', async ($, e, next) => {
196 // A reload runs this again: redraw the band with the module now loaded.
197 $.ui.invalidate('ui.render')
198 const flag = (await $.env.get('ENABLE_PROMPT_CACHING_1H')) ?? ''
199 ctx.envSaysHour = flag !== '' && flag !== '0' && flag.toLowerCase() !== 'false'
200
201 const stored = await $.store.get('learnedTtl')
202 if (stored === '5m' || stored === '1h') {
203 const learned: Ttl = stored
204 await update($, cacheAtom, c => (c.learnedTtl ? c : { ...c, learnedTtl: learned }))
205 }
206
207 await $.command.register({
208 name: 'pulse',
209 description: 'Show or hide the animated usage and cache band',
210 })
211 const usage = await $.session.usage()
212 await update($, usageAtom, () => toPulseUsage(usage))
213
214 const now = await $.clock.now()
215 await scheduleExpiry($, ctx, await read($, cacheAtom), now)
216
217 return next(e)
218 })
219
220 on('command.run', { command: 'pulse' }, async $ => {
221 const isHidden = await update($, hiddenAtom, h => !h)
222 return { text: isHidden ? 'Pulse band hidden.' : 'Pulse band shown.' }
223 })
224
225 // The terminal band's 保溫 button, posted from its Client module.
226 on('ui.message', async ($, e, next) => {
227 const data = e.data as { action?: string } | null
228 if (data?.action === 'toggle-warm' && (e.module.endsWith('pulse.tsx') || e.module.endsWith('hit.tsx'))) {
229 await toggleWarm($, ctx)
230 return {}
231 }
232 return next(e)
233 })
234
235 on('session.measure', async ($, e, next) => {
236 await update($, usageAtom, () => toPulseUsage(e))
237 return next(e)
238 })
239
240 // Engine's own word on the cache TTL, whenever a model switch reports it.
241 on('classic.PreModelSwitch', async ($, e, next) => {
242 const ttl = e.cache_ttl
243 if (ttl === '5m' || ttl === '1h') {
244 await update($, cacheAtom, c => ({ ...c, learnedTtl: ttl }))
245 await $.store.set('learnedTtl', ttl)
246 }
247 return next(e)
248 })
249
250 // A turn of the person's own refreshes the cache and the keep-warm budget.
251 on('turn.start', async ($, e, next) => {
252 ctx.isWorking = true
253 await update($, cacheAtom, c => ((c.warmed ?? 0) === 0 ? c : { ...c, warmed: 0 }))
254 return next(e)
255 })
256
257 // Every main-thread response moves the figures and refreshes the prompt
258 // cache's TTL, so the band follows a turn step by step.
259 on('turn.step', async function* ($, e, next) {
260 const result = yield* next(e)
261 if (e.agentId !== undefined || !result.usage) return result
262 const u = result.usage
263 if (u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) {
264 await recordHit($, ctx, u)
265 }
266 await refreshUsage($)
267 return result
268 })
269
270 on('turn.complete', async ($, e, next) => {
271 const result = await next(e)
272 if (e.agentId === undefined) {
273 ctx.isWorking = false
274 await refreshUsage($)
275 }
276 return result
277 })
278
279 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
280 if (e.props.hasSurvey || e.props.view.agentId !== undefined || (await read($, hiddenAtom))) {
281 return next(e)
282 }
283 const cache = await read($, cacheAtom)
284 const ttl = ttlOf(ctx, cache)
285 const frame: PulseFrame = {
286 surface: e.surface,
287 now: await $.clock.now(),
288 isWorking: e.props.isWorking,
289 usage: await read($, usageAtom),
290 cache,
291 ttlMs: TTL_MS[ttl],
292 ttlLabel: ttl,
293 warmMax: await warmMaxOf($, ctx),
294 }
295
296 if (e.surface === 'desktop') {
297 const { Box, Client, Svg, Text } = $.ui.resolve(e)
298 const meters = svgMeters(frame, frame.now, ctx.shown)
299 const cost = frame.usage?.costUsd
300 const scopeOf = (key: string) => `cc-pulse:${key}`
301 const isWarmOn = frame.warmMax > 0
302 const used = cache.warmed ?? 0
303 const toggle = switchSvg(isWarmOn, ctx.wasWarmOn, used, frame.warmMax)
304 ctx.wasWarmOn = isWarmOn
305 const warmDetail = isWarmOn
306 ? `自動保溫 · 開 · 這段閒置已延長 ${used}/${frame.warmMax} 次 · 緩存過期前 30 秒送出一個極短請求 · 點一下關閉`
307 : `自動保溫 · 關 · 點一下開啟:閒置時在緩存過期前延長,最多 ${ctx.warmLimit} 次(每次按緩存讀取計費)`
308 const details = [
309 ...meters.map(m => ({ key: m.key, tone: m.tone, text: m.detail })),
310 { key: 'warm', tone: isWarmOn ? '#5fd38d' : '#9aa0a6', text: warmDetail },
311 ...(cost !== undefined
312 ? [{ key: 'cost', tone: ACCENT, text: `$${cost.toFixed(2)} spent this session, as /cost totals it` }]
313 : []),
314 ]
315 return (
316 <Box flexDirection="column">
317 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
318 <Svg key="dot" source={dotSvg(frame.isWorking)} alt="pulse" width={10} height={20} isInteractive />
319 {meters.map(m => (
320 <Box key={m.key} hover={{ scope: scopeOf(m.key) }}>
321 <Svg source={m.source} alt={m.alt} width={m.width} height={m.height} isInteractive />
322 </Box>
323 ))}
324 <Box key="warm" hover={{ scope: scopeOf('warm') }}>
325 <Svg
326 source={toggle.source}
327 alt={isWarmOn ? `keep-warm on ${used}/${frame.warmMax}` : 'keep-warm off'}
328 width={toggle.width}
329 height={20}
330 isInteractive
331 />
332 {/* The SVG takes no press: a transparent region over it does. */}
333 <Box position="absolute" top={0} left={0} width="100%" height="100%">
334 <Client key="warm-hit" module="./hit.tsx" props={{ action: 'toggle-warm' }} width="100%" height="100%" />
335 </Box>
336 </Box>
337 {cost !== undefined && (
338 <Box key="cost" hover={{ scope: scopeOf('cost') }}>
339 <Text dimColor hover={{ scope: scopeOf('cost'), dimColor: false }}>
340 ${cost.toFixed(2)}
341 </Text>
342 </Box>
343 )}
344 </Box>
345 {details.map(d => (
346 <Box
347 key={`detail:${d.key}`}
348 display="none"
349 hover={{ scope: scopeOf(d.key), display: 'flex' }}
350 flexDirection="row"
351 >
352 <Text color={d.tone}>{' ▸ '}</Text>
353 <Text dimColor wrap="truncate">
354 {d.text}
355 </Text>
356 </Box>
357 ))}
358 </Box>
359 )
360 }
361
362 if (e.surface === 'terminal') {
363 const { Client } = $.ui.resolve(e)
364 return (
365 <Client
366 key="pulse"
367 module="./pulse.tsx"
368 props={frame}
369 width={e.props.bodyColumns}
370 />
371 )
372 }
373
374 // Surfaces without a Client: one still line.
375 const { Text } = $.ui.resolve(e)
376 const u = frame.usage
377 const parts = [
378 u?.contextPercent !== undefined ? `context ${u.contextPercent}%` : null,
379 ...(u?.limits ?? []).map(l => `${l.kind} ${l.percent}%`),
380 cache.lastHitAt !== null
381 ? `cache ${Math.max(0, Math.ceil((cache.lastHitAt + frame.ttlMs - frame.now) / 60000))}m left`
382 : null,
383 ].filter(Boolean)
384 return <Text dimColor>{parts.join(' · ')}</Text>
385 })
386}
387hooks/svg.ts 416 lines1import type { PulseFrame, PulseLimit } from '../types'
2
3// The desktop band as vector meters: one small SVG per meter, every motion in
4// SMIL, so the drawing keeps moving (bars easing in, a sheen, the cache bar
5// draining, an odometer countdown) with no redraw from the plugin. A redraw
6// comes only when a figure changes, and eases from the figure shown before.
7
8export type SvgMeter = {
9 key: string
10 source: string
11 width: number
12 height: number
13 alt: string
14 /** The hover row's line; times in it are absolute, so it never goes stale. */
15 detail: string
16 tone: string
17}
18
19const H = 20
20const BASE = 14
21const BAR_Y = 8
22const BAR_H = 4
23const BAR_W = 52
24const GAP = 6
25const CHAR = 6.7
26// Bold figures run wider than the labels' lowercase.
27const FIGURE = 7.8
28const DIGIT = 7.4
29const LINE = 14
30
31const CONTEXT = ['#7aa2f7', '#9d8cf5', '#d97757']
32const LIMIT = ['#73daca', '#9ece6a', '#e0af68', '#f7768e']
33const CACHE_COLD = '#ef5b5b'
34const CACHE_MID = '#f2c14e'
35const CACHE_WARM = '#5fd38d'
36export const ACCENT = '#d97757'
37const DANGER = '#f7768e'
38const WARN = '#e0af68'
39const EASE = 'calcMode="spline" keyTimes="0;1" keySplines=".22 1 .36 1"'
40
41const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
42const LIMIT_NAME: Record<string, string> = {
43 five_hour: '5-hour window',
44 seven_day: '7-day window',
45 spend_limit: 'spend limit',
46}
47const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
48
49const STYLE =
50 '<style>' +
51 ':root{color-scheme:light dark}' +
52 'text{font:12px -apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",system-ui,sans-serif;font-variant-numeric:tabular-nums}' +
53 '.l{fill:#8b8f96}.v{fill:#24292f;font-weight:600}.c{font-weight:600}.d{fill:#8b8f96;font-weight:400}' +
54 '@media (prefers-color-scheme:dark){.v{fill:#e6e6e6}}' +
55 '</style>'
56
57type Rgb = [number, number, number]
58const hex = (c: string): Rgb => [
59 parseInt(c.slice(1, 3), 16),
60 parseInt(c.slice(3, 5), 16),
61 parseInt(c.slice(5, 7), 16),
62]
63const toHex = ([r, g, b]: Rgb) =>
64 '#' + [r, g, b].map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')
65const mix = (a: Rgb, b: Rgb, k: number): Rgb => [
66 a[0] + (b[0] - a[0]) * k,
67 a[1] + (b[1] - a[1]) * k,
68 a[2] + (b[2] - a[2]) * k,
69]
70const ramp = (stops: string[], k: number): string => {
71 const rgb = stops.map(hex)
72 const x = Math.max(0, Math.min(1, k)) * (rgb.length - 1)
73 const i = Math.min(rgb.length - 2, Math.floor(x))
74 return toHex(mix(rgb[i]!, rgb[i + 1]!, x - i))
75}
76const cacheTone = (k: number) =>
77 k > 0.5
78 ? toHex(mix(hex(CACHE_MID), hex(CACHE_WARM), (k - 0.5) * 2))
79 : toHex(mix(hex(CACHE_COLD), hex(CACHE_MID), Math.max(0, k) * 2))
80
81const n = (v: number) => String(Math.round(v * 100) / 100)
82const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
83
84export function compact(v: number): string {
85 if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(v >= 10_000_000 ? 0 : 1)}M`
86 if (v >= 1000) return `${(v / 1000).toFixed(v >= 100_000 ? 0 : 1)}k`
87 return String(Math.round(v))
88}
89
90function spanText(ms: number): string {
91 const m = Math.max(1, Math.round(ms / 60000))
92 if (m < 60) return `${m}m`
93 const h = Math.floor(m / 60)
94 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}`
95 return `${Math.floor(h / 24)}d${h % 24}h`
96}
97
98export function when(at: number, now: number, withSeconds = false): string {
99 const d = new Date(at)
100 const pad = (v: number) => String(v).padStart(2, '0')
101 const time = `${pad(d.getHours())}:${pad(d.getMinutes())}${withSeconds ? `:${pad(d.getSeconds())}` : ''}`
102 return Math.abs(at - now) > 20 * 3_600_000 ? `${WEEKDAYS[d.getDay()]} ${time}` : time
103}
104
105const svg = (width: number, body: string) =>
106 `<svg xmlns="http://www.w3.org/2000/svg" width="${n(width)}" height="${H}" viewBox="0 0 ${n(width)} ${H}">${STYLE}${body}</svg>`
107
108const textWidth = (s: string) => s.length * CHAR
109const figureWidth = (s: string) => s.length * FIGURE
110
111// The label, the track and its clip; `fill` draws what goes on the track.
112function frameOf(label: string) {
113 const barX = textWidth(label) + GAP
114 return {
115 barX,
116 valueX: barX + BAR_W + GAP + 1,
117 head:
118 `<text class="l" x="0" y="${BASE}">${esc(label)}</text>` +
119 `<rect x="${n(barX)}" y="${BAR_Y}" width="${BAR_W}" height="${BAR_H}" rx="2" fill="#808080" fill-opacity=".22"/>` +
120 `<clipPath id="t"><rect x="${n(barX)}" y="${BAR_Y}" width="${BAR_W}" height="${BAR_H}" rx="2"/></clipPath>`,
121 }
122}
123
124// A gradient fill easing from `from` to `to` (0..1), a sheen sweeping over it.
125function easedFill(barX: number, stops: string[], from: number, to: number, sweepAt: number): string {
126 const w0 = Math.max(0, Math.min(1, from)) * BAR_W
127 const w1 = Math.max(0, Math.min(1, to)) * BAR_W
128 const grow = `<animate attributeName="width" from="${n(w0)}" to="${n(w1)}" dur=".9s" fill="freeze" ${EASE}/>`
129 const gradient =
130 `<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="${n(barX)}" x2="${n(barX + BAR_W)}">` +
131 stops.map((c, i) => `<stop offset="${n(i / Math.max(1, stops.length - 1))}" stop-color="${c}"/>`).join('') +
132 '</linearGradient>' +
133 '<linearGradient id="s"><stop offset="0" stop-color="#fff" stop-opacity="0"/>' +
134 '<stop offset=".5" stop-color="#fff" stop-opacity=".5"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>'
135 return (
136 gradient +
137 `<clipPath id="f"><rect x="${n(barX)}" y="${BAR_Y}" width="${n(w1)}" height="${BAR_H}" rx="2">${grow}</rect></clipPath>` +
138 `<rect x="${n(barX)}" y="${BAR_Y}" width="${n(w1)}" height="${BAR_H}" rx="2" fill="url(#g)">${grow}</rect>` +
139 `<rect y="${BAR_Y}" width="16" height="${BAR_H}" fill="url(#s)" clip-path="url(#f)">` +
140 `<animate attributeName="x" values="${n(barX - 16)};${n(barX + BAR_W)};${n(barX + BAR_W)}" keyTimes="0;.45;1" ` +
141 `dur="3.6s" begin="${n(sweepAt)}s" repeatCount="indefinite"/></rect>`
142 )
143}
144
145// mm:ss counting down from `leftMs`, each digit a strip rolled by SMIL.
146// Seconds roll on a 10 s and a 60 s loop phased to the wall clock; minutes
147// step at their exact times; the whole counter hides when it reaches zero.
148// `inner` rides inside the counter's group (its recolor, pulse and hiding).
149function odometer(x: number, leftMs: number, color: string, inner: string): { body: string; width: number } {
150 const secs = Math.ceil(leftMs / 1000)
151 // Time until the counter first ticks over, then a tick every second.
152 const firstTick = leftMs - (secs - 1) * 1000
153 const begin = n(firstTick / 1000 - 1)
154 const minutes = Math.floor(secs / 60)
155 const minW = 2 * DIGIT
156 const colonX = x + minW + 1
157 const tensX = colonX + 4
158 const onesX = tensX + DIGIT
159 const width = onesX + DIGIT - x
160 const clip = (id: string, cx: number, w: number) =>
161 `<clipPath id="${id}"><rect x="${n(cx)}" y="2" width="${n(w)}" height="${LINE + 2}"/></clipPath>`
162
163 // Digit strip: digit d sits d lines down; showing d is translating by -d lines.
164 const strip = (cx: number) =>
165 Array.from({ length: 10 }, (_, d) => `<text x="${n(cx + DIGIT / 2)}" y="${BASE + d * LINE}" text-anchor="middle">${d}</text>`).join('')
166 const roll = (digits: number[], period: number) =>
167 `<animateTransform attributeName="transform" type="translate" calcMode="discrete" ` +
168 `values="${digits.map(d => `0 ${-d * LINE}`).join(';')}" dur="${period}s" begin="${begin}s" repeatCount="indefinite"/>`
169 const ones = Array.from({ length: 10 }, (_, k) => (((secs - k) % 10) + 10) % 10)
170 const tens = Array.from({ length: 60 }, (_, k) => Math.floor(((((secs - k) % 60) + 60) % 60) / 10))
171
172 // Minutes: a strip of whole numbers, stepped when each minute runs out.
173 const minuteLabels = Array.from({ length: minutes + 1 }, (_, j) => minutes - j)
174 let minuteRoll = ''
175 if (minutes > 0) {
176 const times = [0]
177 for (let j = 1; j <= minutes; j++) {
178 const tick = secs - 60 * (minutes - j + 1) + 1
179 times.push((firstTick + (tick - 1) * 1000) / leftMs)
180 }
181 minuteRoll =
182 `<animateTransform attributeName="transform" type="translate" calcMode="discrete" ` +
183 `values="${minuteLabels.map((_, j) => `0 ${-j * LINE}`).join(';')}" ` +
184 `keyTimes="${times.map(t => Math.min(0.99999, Math.max(0, t)).toFixed(5)).join(';')}" ` +
185 `dur="${n(leftMs / 1000)}s" fill="freeze"/>`
186 }
187 const body =
188 clip('m', x, minW) +
189 clip('a', tensX, DIGIT) +
190 clip('b', onesX, DIGIT) +
191 `<g class="c" fill="${color}">${inner}` +
192 `<g clip-path="url(#m)"><g>${minuteLabels
193 .map((label, j) => `<text x="${n(x + minW)}" y="${BASE + j * LINE}" text-anchor="end">${label}</text>`)
194 .join('')}${minuteRoll}</g></g>` +
195 `<text x="${n(colonX)}" y="${BASE - 1}">:</text>` +
196 `<g clip-path="url(#a)"><g>${strip(tensX)}${roll(tens, 60)}</g></g>` +
197 `<g clip-path="url(#b)"><g>${strip(onesX)}${roll(ones, 10)}</g></g>` +
198 '</g>'
199 return { body, width }
200}
201
202function cacheMeter(frame: PulseFrame, now: number): SvgMeter {
203 const cache = frame.cache
204 const { barX, valueX, head } = frameOf('cache')
205 const ttl = `prompt cache · ${frame.ttlLabel} TTL`
206 const total = cache.readTokens + cache.writeTokens
207 const io =
208 total > 0
209 ? ` · last response read ${compact(cache.readTokens)}, wrote ${compact(cache.writeTokens)} (${Math.round((cache.readTokens / total) * 100)}% hit)`
210 : ''
211 const warm = frame.warmMax > 0 ? ` · keep-warm ${cache.warmed ?? 0}/${frame.warmMax}` : ' · keep-warm off'
212 const base = { key: 'cache', height: H }
213 const word = (text: string, color: string, extra = '') => ({
214 body: `<text class="v" x="${n(valueX)}" y="${BASE}" style="fill:${color}">${text}${extra}</text>`,
215 width: valueX + figureWidth(text) + 4,
216 })
217
218 if (frame.isWorking) {
219 const scan =
220 '<linearGradient id="k"><stop offset="0" stop-color="' + CACHE_WARM + '" stop-opacity="0"/>' +
221 '<stop offset=".5" stop-color="' + CACHE_WARM + '"/><stop offset="1" stop-color="' + CACHE_WARM + '" stop-opacity="0"/></linearGradient>' +
222 `<rect y="${BAR_Y}" width="22" height="${BAR_H}" fill="url(#k)" clip-path="url(#t)">` +
223 `<animate attributeName="x" values="${n(barX - 11)};${n(barX + BAR_W - 11)};${n(barX - 11)}" keyTimes="0;.5;1" ` +
224 `calcMode="spline" keySplines=".45 0 .55 1;.45 0 .55 1" dur="1.8s" repeatCount="indefinite"/></rect>`
225 const live = word('live', CACHE_WARM, '<animate attributeName="opacity" values="1;.45;1" dur="1.4s" repeatCount="indefinite"/>')
226 return {
227 ...base,
228 source: svg(live.width, head + scan + live.body),
229 width: live.width,
230 alt: 'cache live',
231 detail: `${ttl} · every response of this turn refreshes it${io}`,
232 tone: CACHE_WARM,
233 }
234 }
235 if (cache.lastHitAt === null) {
236 const none = word('—', '#8b8f96')
237 return {
238 ...base,
239 source: svg(none.width, head + none.body),
240 width: none.width,
241 alt: 'cache empty',
242 detail: `${ttl} · nothing cached yet; the first response writes it`,
243 tone: CACHE_WARM,
244 }
245 }
246 const expiresAt = cache.lastHitAt + frame.ttlMs
247 const left = expiresAt - now
248 const resend = frame.usage?.contextTokens
249 const coldDetail = `${ttl} · expired at ${when(expiresAt, now)}${resend ? ` · next prompt re-writes ~${compact(resend)} tokens` : ''}${warm}`
250 if (left <= 0) {
251 const cold = word('cold', CACHE_COLD)
252 return {
253 ...base,
254 source: svg(cold.width, head + cold.body),
255 width: cold.width,
256 alt: 'cache cold',
257 detail: coldDetail,
258 tone: CACHE_COLD,
259 }
260 }
261
262 // Warm: the bar drains to nothing over what is left, shifting green to red,
263 // and pulses through its last minute; then the counter gives way to "cold".
264 const k = left / frame.ttlMs
265 const secs = n(left / 1000)
266 const tones = Array.from({ length: 6 }, (_, i) => cacheTone(k * (1 - i / 5)))
267 const recolor = `<animate attributeName="fill" values="${tones.join(';')}" dur="${secs}s" fill="freeze"/>`
268 const urgentAt = n(Math.max(0, left - 60_000) / 1000)
269 const pulse = `<animate attributeName="opacity" values="1;.4;1" dur="1s" begin="${urgentAt}s" end="${secs}s" repeatCount="indefinite"/>`
270 const bar =
271 `<rect x="${n(barX)}" y="${BAR_Y}" width="${n(k * BAR_W)}" height="${BAR_H}" rx="2" fill="${tones[0]}">` +
272 `<animate attributeName="width" from="${n(k * BAR_W)}" to="0" dur="${secs}s" fill="freeze"/>${recolor}${pulse}</rect>`
273 const counter = odometer(
274 valueX,
275 left,
276 tones[0]!,
277 recolor + pulse + `<set attributeName="visibility" to="hidden" begin="${secs}s" fill="freeze"/>`,
278 )
279 const cold =
280 `<text class="v" x="${n(valueX)}" y="${BASE}" style="fill:${CACHE_COLD}" visibility="hidden">cold` +
281 `<set attributeName="visibility" to="visible" begin="${secs}s" fill="freeze"/></text>`
282 const width = valueX + Math.max(counter.width, figureWidth('cold')) + 4
283 return {
284 ...base,
285 source: svg(width, head + bar + counter.body + cold),
286 width,
287 alt: `cache ${Math.ceil(left / 60000)} minutes left`,
288 detail: `${ttl} · expires at ${when(expiresAt, now, true)}${io}${warm}`,
289 tone: tones[0]!,
290 }
291}
292
293/**
294 * The desktop meters for a frame. `shown` holds the fills last drawn, keyed
295 * like the meters; the call eases from them and writes the new ones back.
296 */
297export function svgMeters(frame: PulseFrame, now: number, shown: Record<string, number>): SvgMeter[] {
298 const usage = frame.usage
299 const meters: SvgMeter[] = []
300
301 const ctxFill = usage?.contextPercent !== undefined ? usage.contextPercent / 100 : 0
302 {
303 const { barX, valueX, head } = frameOf('ctx')
304 const pct = Math.round(ctxFill * 100)
305 const value = usage?.contextPercent !== undefined ? `${pct}%` : '—'
306 const color = pct >= 80 ? DANGER : ''
307 const width = valueX + figureWidth(value) + 4
308 meters.push({
309 key: 'ctx',
310 height: H,
311 width,
312 source: svg(
313 width,
314 head +
315 easedFill(barX, CONTEXT, shown.ctx ?? 0, ctxFill, 0) +
316 `<text class="v" x="${n(valueX)}" y="${BASE}"${color ? ` style="fill:${color}"` : ''}>${value}</text>`,
317 ),
318 alt: `context ${value}`,
319 detail:
320 usage?.contextTokens !== undefined
321 ? `context window · ${compact(usage.contextTokens)} of ${compact(usage.contextWindow)} tokens used · ${compact(Math.max(0, usage.contextWindow - usage.contextTokens))} free`
322 : `context window · ${compact(usage?.contextWindow ?? 0)} tokens · filled once the first response arrives`,
323 tone: ramp(CONTEXT, ctxFill),
324 })
325 shown.ctx = ctxFill
326 }
327
328 ;(usage?.limits ?? []).forEach((limit: PulseLimit, i) => {
329 const key = `limit:${limit.kind}`
330 const label = LIMIT_LABEL[limit.kind] ?? limit.kind
331 const fill = Math.min(1, limit.percent / 100)
332 const { barX, valueX, head } = frameOf(label)
333 const value = `${limit.percent.toFixed(limit.percent < 10 ? 1 : 0)}%`
334 const at = limit.resetsAt !== undefined ? Date.parse(limit.resetsAt) : NaN
335 const resets = !Number.isNaN(at) && at > now
336 const reset = limit.percent >= 80 && resets ? ` ↻${spanText(at - now)}` : ''
337 const color = limit.percent >= 90 ? DANGER : limit.percent >= 70 ? WARN : ''
338 const width = valueX + figureWidth(value) + textWidth(reset) + 6
339 meters.push({
340 key,
341 height: H,
342 width,
343 source: svg(
344 width,
345 head +
346 easedFill(barX, LIMIT, shown[key] ?? 0, fill, 0.9 * (i + 1)) +
347 `<text class="v" x="${n(valueX)}" y="${BASE}"${color ? ` style="fill:${color}"` : ''}>${value}` +
348 (reset ? `<tspan class="d">${esc(reset)}</tspan>` : '') +
349 '</text>',
350 ),
351 alt: `${label} limit ${value}`,
352 detail: `${LIMIT_NAME[limit.kind] ?? limit.kind} · ${limit.percent}% used${resets ? ` · resets at ${when(at, now)}` : ''}`,
353 tone: ramp(LIMIT, fill),
354 })
355 shown[key] = fill
356 })
357
358 meters.push(cacheMeter(frame, now))
359 return meters
360}
361
362/** The breathing dot that leads the line; it quickens while a turn runs. */
363export function dotSvg(isWorking: boolean): string {
364 const dur = isWorking ? '1s' : '2.8s'
365 return (
366 `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="${H}" viewBox="0 0 10 ${H}">` +
367 `<circle cx="5" cy="10" r="3.4" fill="${ACCENT}">` +
368 `<animate attributeName="r" values="3;4;3" dur="${dur}" repeatCount="indefinite"/>` +
369 `<animate attributeName="opacity" values=".65;1;.65" dur="${dur}" repeatCount="indefinite"/>` +
370 '</circle></svg>'
371 )
372}
373
374/**
375 * The keep-warm switch: a pill track and a knob in the meters' own style.
376 * The knob slides and the track recolors from `wasOn` to `isOn`; while on,
377 * a soft halo breathes around the track and the budget used rides beside it.
378 */
379export function switchSvg(isOn: boolean, wasOn: boolean, used: number, max: number): { source: string; width: number } {
380 const label = '保溫'
381 const trackX = 2 * 12 + 6
382 const trackW = 26
383 const trackH = 14
384 const trackY = (H - trackH) / 2
385 const knobR = 5.5
386 const offX = trackX + trackH / 2
387 const onX = trackX + trackW - trackH / 2
388 const offFill = '#9aa0a6'
389 const count = isOn ? `${used}/${max}` : ''
390 const width = trackX + trackW + (count ? GAP + figureWidth(count) : 0) + 4
391 const slide = (from: string, to: string, attr: string) =>
392 from === to ? '' : `<animate attributeName="${attr}" from="${from}" to="${to}" dur=".28s" fill="freeze" ${EASE}/>`
393 const track = isOn ? CACHE_WARM : offFill
394 const halo = isOn
395 ? `<rect x="${n(trackX - 2.5)}" y="${n(trackY - 2.5)}" width="${trackW + 5}" height="${trackH + 5}" rx="${n(trackH / 2 + 2.5)}" ` +
396 `fill="none" stroke="${CACHE_WARM}" stroke-width="1" opacity="0">` +
397 '<animate attributeName="opacity" values="0;.3;0" dur="3s" begin=".3s" repeatCount="indefinite"/></rect>'
398 : ''
399 const source = svg(
400 width,
401 `<text class="l" x="0" y="${BASE}">${label}</text>` +
402 halo +
403 `<rect x="${n(trackX)}" y="${n(trackY)}" width="${trackW}" height="${trackH}" rx="${n(trackH / 2)}" ` +
404 `fill="${track}" fill-opacity="${isOn ? '1' : '.45'}">` +
405 slide(wasOn ? CACHE_WARM : offFill, track, 'fill') +
406 slide(wasOn ? '1' : '.45', isOn ? '1' : '.45', 'fill-opacity') +
407 '</rect>' +
408 `<circle cx="${n(isOn ? onX : offX)}" cy="${H / 2}" r="${knobR}" fill="#fff" ` +
409 'style="filter:drop-shadow(0 .5px 1px rgba(0,0,0,.35))">' +
410 slide(n(wasOn ? onX : offX), n(isOn ? onX : offX), 'cx') +
411 '</circle>' +
412 (count ? `<text class="d" x="${n(trackX + trackW + GAP)}" y="${BASE}">${count}</text>` : ''),
413 )
414 return { source, width }
415}
416hooks/hit.tsx 25 lines1import type { ClientModule, JsonValue } from 'claude-code'
2
3// A transparent region laid over a drawing that cannot take presses (an
4// SVG): a click, or Enter or Space once it has the focus, posts `action` to
5// the hooks module.
6
7type HitProps = { action: string }
8
9const Hit: ClientModule<JsonValue, true> = (props, surface) => {
10 const { action } = props as unknown as HitProps
11 if (surface.state === undefined) {
12 surface.setState(true)
13 surface.onPointer(e => {
14 if (e.type === 'down' && (e.button ?? 'left') === 'left') surface.post({ action })
15 })
16 surface.onKey(k => {
17 if (k.key === 'return' || k.key === ' ' || k.key === 'space') surface.post({ action })
18 })
19 }
20 const { Box } = surface.elements
21 return <Box width="100%" height="100%" />
22}
23
24export default Hit
25hooks/pulse.tsx 426 lines1import type { ClientModule, JsonValue, RenderElement } from 'claude-code'
2
3import type { PulseFrame } from '../types'
4
5// One compact animated line: context, rate limits, prompt cache, cost.
6// Figures ease toward their targets, mini bars carry a soft travelling glow,
7// and the cache countdown runs on the wall clock between the hooks module's
8// redraws (re-synced to the engine's clock on each one).
9//
10// The terminal draws bars as block glyphs (its font is monospaced); the
11// desktop draws them as colored boxes, since its font is proportional.
12
13const TICK = 80
14const EASE = 0.2
15const BAR = 6
16const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
17
18type Box = {
19 frame: PulseFrame
20 /** Engine time minus wall time, taken when the frame arrived. */
21 skew: number
22 frameNow: number
23 shown: Record<string, number>
24}
25type State = { box: Box }
26type Rgb = [number, number, number]
27type Run = { text: string; color?: string; dim?: boolean; bold?: boolean }
28type Bar = {
29 fill: number
30 colorAt: (k: number) => Rgb
31 sheen: number | null
32 /** Cell position of the working scanner; replaces the fill when set. */
33 scan?: number
34}
35// `detail` is the line a hover over the meter opens beneath the band.
36type Meter = { key: string; label: string; bar: Bar; value: Run; detail: string; tone: string }
37
38const hex = (c: string): Rgb => [
39 parseInt(c.slice(1, 3), 16),
40 parseInt(c.slice(3, 5), 16),
41 parseInt(c.slice(5, 7), 16),
42]
43const toHex = ([r, g, b]: Rgb) =>
44 '#' +
45 [r, g, b]
46 .map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
47 .join('')
48const mix = (a: Rgb, b: Rgb, k: number): Rgb => [
49 a[0] + (b[0] - a[0]) * k,
50 a[1] + (b[1] - a[1]) * k,
51 a[2] + (b[2] - a[2]) * k,
52]
53const lighten = (c: Rgb, k: number): Rgb => mix(c, [255, 255, 255], k)
54const ramp = (stops: string[], k: number): Rgb => {
55 const rgb = stops.map(hex)
56 const x = Math.max(0, Math.min(1, k)) * (rgb.length - 1)
57 const i = Math.min(rgb.length - 2, Math.floor(x))
58 return mix(rgb[i]!, rgb[i + 1]!, x - i)
59}
60
61const CONTEXT = ['#7aa2f7', '#9d8cf5', '#d97757']
62const LIMIT = ['#73daca', '#9ece6a', '#e0af68', '#f7768e']
63const CACHE_COLD = '#ef5b5b'
64const CACHE_MID = '#f2c14e'
65const CACHE_WARM = '#5fd38d'
66const ACCENT = '#d97757'
67const DANGER = '#f7768e'
68const WARN = '#e0af68'
69// Translucent, so the track sits quietly on a light or a dark background.
70const TRACK = '#8080803a'
71const SCAN_DARK = '#2f5a4a'
72const SHEEN_RADIUS = 2
73const SHEEN_PEAK = 0.32
74
75const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
76const LIMIT_NAME: Record<string, string> = {
77 five_hour: '5-hour window',
78 seven_day: '7-day window',
79 spend_limit: 'spend limit',
80}
81const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
82
83// A hover group per meter: the meter heats it, its detail row reveals in it.
84const scopeOf = (key: string) => `cc-pulse:${key}`
85
86function compact(n: number): string {
87 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
88 if (n >= 1000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`
89 return String(Math.round(n))
90}
91
92// Local wall time of `at`, with the weekday when it is not within a day.
93function when(at: number, now: number, withSeconds = false): string {
94 const d = new Date(at)
95 const pad = (n: number) => String(n).padStart(2, '0')
96 const time = `${pad(d.getHours())}:${pad(d.getMinutes())}${withSeconds ? `:${pad(d.getSeconds())}` : ''}`
97 return Math.abs(at - now) > 20 * 3_600_000 ? `${WEEKDAYS[d.getDay()]} ${time}` : time
98}
99
100const wall = () => Date.now()
101
102function clockText(ms: number): string {
103 const s = Math.max(0, Math.ceil(ms / 1000))
104 const h = Math.floor(s / 3600)
105 const m = Math.floor((s % 3600) / 60)
106 const sec = s % 60
107 return h > 0
108 ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
109 : `${m}:${String(sec).padStart(2, '0')}`
110}
111
112function spanText(ms: number): string {
113 const m = Math.max(1, Math.round(ms / 60000))
114 if (m < 60) return `${m}m`
115 const h = Math.floor(m / 60)
116 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}`
117 return `${Math.floor(h / 24)}d${h % 24}h`
118}
119
120function cellColor(bar: Bar, i: number): Rgb | null {
121 if (bar.scan !== undefined) {
122 const k = Math.max(0, 1 - Math.abs(i - bar.scan) / 2.5)
123 return k > 0 ? mix(hex(SCAN_DARK), hex(CACHE_WARM), k) : null
124 }
125 let c = bar.colorAt(i / (BAR - 1))
126 if (bar.sheen !== null) {
127 const d = Math.abs(i - bar.sheen)
128 if (d < SHEEN_RADIUS) c = lighten(c, SHEEN_PEAK * ((Math.cos((d / SHEEN_RADIUS) * Math.PI) + 1) / 2))
129 }
130 return c
131}
132
133function push(runs: Run[], run: Run) {
134 const last = runs[runs.length - 1]
135 if (last && last.color === run.color && last.dim === run.dim && last.bold === run.bold) last.text += run.text
136 else runs.push(run)
137}
138
139// Terminal: block glyphs, eighth blocks for the fractional cell.
140function glyphBar(bar: Bar): Run[] {
141 const runs: Run[] = []
142 if (bar.scan !== undefined) {
143 for (let i = 0; i < BAR; i++) {
144 const c = cellColor(bar, i)
145 push(runs, c ? { text: '█', color: toHex(c) } : { text: '░', dim: true })
146 }
147 return runs
148 }
149 const cells = Math.max(0, Math.min(1, bar.fill)) * BAR
150 const whole = Math.floor(cells)
151 const part = Math.floor((cells - whole) * 8)
152 for (let i = 0; i < whole; i++) push(runs, { text: '█', color: toHex(cellColor(bar, i)!) })
153 if (whole < BAR) {
154 if (part > 0) push(runs, { text: EIGHTHS[part]!, color: toHex(cellColor(bar, whole)!) })
155 const rest = BAR - whole - (part > 0 ? 1 : 0)
156 if (rest > 0) push(runs, { text: '░'.repeat(rest), dim: true })
157 }
158 return runs
159}
160
161const Pulse: ClientModule<JsonValue, State> = (props, surface) => {
162 const { Box, Button, Text } = surface.elements
163 const frame = props as unknown as PulseFrame
164 const isGrid = frame.surface === 'desktop'
165
166 let state = surface.state
167 if (state === undefined) {
168 const box: Box = { frame, skew: frame.now - wall(), frameNow: frame.now, shown: {} }
169 state = { box }
170 // Seat the state now, so a second call before the first tick cannot
171 // start a second timer.
172 surface.setState(state)
173 surface.every(TICK, () => {
174 const targets = targetsOf(box.frame)
175 for (const k of Object.keys(targets)) {
176 const target = targets[k]!
177 const cur = box.shown[k] ?? 0
178 box.shown[k] = Math.abs(target - cur) < 0.0005 ? target : cur + (target - cur) * EASE
179 }
180 surface.setState({ box })
181 })
182 }
183 const box = state.box
184 if (frame.now !== box.frameNow) {
185 box.frameNow = frame.now
186 box.skew = frame.now - wall()
187 }
188 box.frame = frame
189
190 const now = wall() + box.skew
191 const t = wall() / 1000
192 const sweep = (period: number, offset = 0) =>
193 (((t + offset) % period) / period) * (BAR + SHEEN_RADIUS * 6) - SHEEN_RADIUS * 3
194
195 const usage = frame.usage
196 const meters: Meter[] = []
197
198 const ctx = box.shown.ctx ?? 0
199 const ctxPct = Math.round(ctx * 100)
200 meters.push({
201 key: 'ctx',
202 label: 'ctx',
203 bar: { fill: ctx, colorAt: k => ramp(CONTEXT, k), sheen: sweep(3) },
204 value:
205 usage?.contextPercent !== undefined
206 ? { text: `${ctxPct}%`, bold: true, color: ctxPct >= 80 ? DANGER : undefined }
207 : { text: '—', dim: true },
208 detail:
209 usage?.contextTokens !== undefined
210 ? `context window · ${compact(usage.contextTokens)} of ${compact(usage.contextWindow)} tokens used · ${compact(Math.max(0, usage.contextWindow - usage.contextTokens))} free`
211 : `context window · ${compact(usage?.contextWindow ?? 0)} tokens · filled once the first response arrives`,
212 tone: toHex(ramp(CONTEXT, ctx)),
213 })
214
215 ;(usage?.limits ?? []).forEach((limit, i) => {
216 const fill = box.shown[`limit:${limit.kind}`] ?? 0
217 const pct = fill * 100
218 let text = `${pct.toFixed(pct < 10 ? 1 : 0)}%`
219 const at = limit.resetsAt !== undefined ? Date.parse(limit.resetsAt) : NaN
220 const resets = !Number.isNaN(at) && at > now
221 // On the line the reset time shows once the window runs short.
222 if (pct >= 80 && resets) text += ` ↻${spanText(at - now)}`
223 let detail = `${LIMIT_NAME[limit.kind] ?? limit.kind} · ${limit.percent}% used`
224 if (resets) detail += ` · resets in ${spanText(at - now)}, at ${when(at, now)}`
225 meters.push({
226 key: `limit:${limit.kind}`,
227 label: LIMIT_LABEL[limit.kind] ?? limit.kind,
228 bar: { fill, colorAt: k => ramp(LIMIT, k), sheen: sweep(3.6, 0.9 * (i + 1)) },
229 value: { text, bold: true, color: pct >= 90 ? DANGER : pct >= 70 ? WARN : undefined },
230 detail,
231 tone: toHex(ramp(LIMIT, fill)),
232 })
233 })
234
235 meters.push(cacheMeter(frame, now, t, sweep))
236
237 // A breathing dot leads the line; it quickens while a turn runs.
238 const breath = (Math.sin(t * (frame.isWorking ? 6 : 2.2)) + 1) / 2
239 const dot = toHex(mix(hex(ACCENT), [255, 214, 170], breath * 0.6))
240 const cost = usage?.costUsd !== undefined ? `$${(box.shown.cost ?? usage.costUsd).toFixed(2)}` : null
241 const costDetail = usage?.costUsd !== undefined ? `$${usage.costUsd.toFixed(2)} spent this session, as /cost totals it` : ''
242
243 const texts = (runs: Run[], scope?: string) =>
244 runs.map((r, i) => (
245 <Text
246 key={String(i)}
247 color={r.color}
248 dimColor={r.dim}
249 bold={r.bold}
250 wrap="truncate"
251 hover={scope && r.dim ? { scope, dimColor: false } : undefined}
252 >
253 {r.text}
254 </Text>
255 ))
256
257 // Hidden rows beneath the line, one per meter, each opened by its meter.
258 const details = [
259 ...meters.map(m => ({ key: m.key, tone: m.tone, text: m.detail })),
260 ...(cost ? [{ key: 'cost', tone: ACCENT, text: costDetail }] : []),
261 ].map(d => (
262 <Box
263 key={`detail:${d.key}`}
264 display="none"
265 hover={{ scope: scopeOf(d.key), display: 'flex' }}
266 flexDirection="row"
267 >
268 <Text color={d.tone}>{' ▸ '}</Text>
269 <Text dimColor wrap="truncate">
270 {d.text}
271 </Text>
272 </Box>
273 ))
274
275 if (!isGrid) {
276 return (
277 <Box flexDirection="column">
278 <Box flexDirection="row">
279 <Text color={dot}>{'● '}</Text>
280 {meters.map((m, i) => (
281 <Box key={m.key} flexDirection="row" flexShrink={0} hover={{ scope: scopeOf(m.key) }}>
282 {texts(
283 [{ text: `${i > 0 ? ' ' : ''}${m.label} `, dim: true }, ...glyphBar(m.bar), { text: ' ' }, m.value],
284 scopeOf(m.key),
285 )}
286 </Box>
287 ))}
288 <Text>{' '}</Text>
289 <Button
290 key="warm"
291 label={frame.warmMax > 0 ? `━● 保溫 ${frame.cache.warmed ?? 0}/${frame.warmMax}` : '○━ 保溫'}
292 plain
293 dimColor={frame.warmMax === 0}
294 onPress={() => surface.post({ action: 'toggle-warm' })}
295 />
296 {cost && (
297 <Box key="cost" flexDirection="row" flexShrink={0} hover={{ scope: scopeOf('cost') }}>
298 {texts([{ text: ` ${cost}`, dim: true }], scopeOf('cost'))}
299 </Box>
300 )}
301 </Box>
302 {details}
303 </Box>
304 )
305 }
306
307 const gridBar = (bar: Bar): RenderElement => {
308 const fill = bar.scan !== undefined ? 1 : Math.max(0, Math.min(1, bar.fill))
309 const cells: RenderElement[] = []
310 for (let i = 0; i < Math.min(BAR, Math.ceil(fill * BAR) + 1); i++) {
311 const c = cellColor(bar, i)
312 cells.push(
313 c ? (
314 <Box key={String(i)} width={1} height="100%" flexShrink={0} backgroundColor={toHex(c)} />
315 ) : (
316 <Box key={String(i)} width={1} height="100%" flexShrink={0} />
317 ),
318 )
319 }
320 return (
321 <Box width={BAR} height={1} alignItems="center" flexShrink={0} marginLeft={1}>
322 <Box width="100%" height="34%" backgroundColor={TRACK} flexDirection="row" overflow="hidden">
323 <Box width={Math.round(fill * BAR * 100) / 100} height="100%" flexShrink={0} flexDirection="row" overflow="hidden">
324 {cells}
325 </Box>
326 </Box>
327 </Box>
328 )
329 }
330
331 return (
332 <Box flexDirection="column">
333 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
334 <Text key="dot" color={dot}>●</Text>
335 {meters.map(m => (
336 <Box key={m.key} flexDirection="row" alignItems="center" flexShrink={0} hover={{ scope: scopeOf(m.key) }}>
337 <Text dimColor hover={{ scope: scopeOf(m.key), dimColor: false }}>
338 {m.label}
339 </Text>
340 {gridBar(m.bar)}
341 <Box marginLeft={1}>
342 <Text color={m.value.color} dimColor={m.value.dim} bold={m.value.bold}>
343 {m.value.text}
344 </Text>
345 </Box>
346 </Box>
347 ))}
348 {cost && (
349 <Box key="cost" hover={{ scope: scopeOf('cost') }}>
350 <Text dimColor hover={{ scope: scopeOf('cost'), dimColor: false }}>
351 {cost}
352 </Text>
353 </Box>
354 )}
355 </Box>
356 {details}
357 </Box>
358 )
359}
360
361function cacheMeter(frame: PulseFrame, now: number, t: number, sweep: (period: number, offset?: number) => number): Meter {
362 const cache = frame.cache
363 const base = { key: 'cache', label: 'cache' }
364 const ttl = `prompt cache · ${frame.ttlLabel} TTL`
365 const total = cache.readTokens + cache.writeTokens
366 const io =
367 total > 0
368 ? ` · last response read ${compact(cache.readTokens)}, wrote ${compact(cache.writeTokens)} (${Math.round((cache.readTokens / total) * 100)}% hit)`
369 : ''
370 const warm = frame.warmMax > 0 ? ` · keep-warm ${cache.warmed ?? 0}/${frame.warmMax}` : ' · keep-warm off'
371 if (frame.isWorking) {
372 return {
373 ...base,
374 bar: { fill: 1, colorAt: () => hex(CACHE_WARM), sheen: null, scan: ((Math.sin(t * 3) + 1) / 2) * (BAR - 1) },
375 value: { text: 'live', bold: true, color: CACHE_WARM },
376 detail: `${ttl} · every response of this turn refreshes it${io}`,
377 tone: CACHE_WARM,
378 }
379 }
380 if (cache.lastHitAt === null) {
381 return {
382 ...base,
383 bar: { fill: 0, colorAt: () => hex(CACHE_WARM), sheen: null },
384 value: { text: '—', dim: true },
385 detail: `${ttl} · nothing cached yet; the first response writes it`,
386 tone: CACHE_WARM,
387 }
388 }
389 const expiresAt = cache.lastHitAt + frame.ttlMs
390 const left = expiresAt - now
391 if (left <= 0) {
392 const resend = frame.usage?.contextTokens
393 return {
394 ...base,
395 bar: { fill: 0, colorAt: () => hex(CACHE_COLD), sheen: null },
396 value: { text: 'cold', bold: true, color: CACHE_COLD },
397 detail: `${ttl} · expired ${spanText(-left)} ago, at ${when(expiresAt, now)}${resend ? ` · next prompt re-writes ~${compact(resend)} tokens` : ''}${warm}`,
398 tone: CACHE_COLD,
399 }
400 }
401 const k = left / frame.ttlMs
402 const tone = k > 0.5 ? mix(hex(CACHE_MID), hex(CACHE_WARM), (k - 0.5) * 2) : mix(hex(CACHE_COLD), hex(CACHE_MID), k * 2)
403 const urgent = left < 60_000
404 const color = lighten(tone, urgent ? ((Math.sin(t * 7) + 1) / 2) * 0.4 : 0)
405 return {
406 ...base,
407 bar: { fill: k, colorAt: () => color, sheen: urgent ? null : sweep(4.5) },
408 value: { text: clockText(left), bold: true, color: toHex(color) },
409 detail: `${ttl} · expires at ${when(expiresAt, now, true)}${io}${warm}`,
410 tone: toHex(tone),
411 }
412}
413
414function targetsOf(f: PulseFrame): Record<string, number> {
415 const out: Record<string, number> = {}
416 const u = f.usage
417 if (u) {
418 out.ctx = u.contextPercent !== undefined ? u.contextPercent / 100 : 0
419 if (u.costUsd !== undefined) out.cost = u.costUsd
420 for (const l of u.limits) out[`limit:${l.kind}`] = Math.min(1, l.percent / 100)
421 }
422 return out
423}
424
425export default Pulse
426types/index.d.ts 48 lines1export type PulseLimit = { kind: string; percent: number; resetsAt?: string }
2
3export type PulseUsage = {
4 contextTokens?: number
5 contextWindow: number
6 contextPercent?: number
7 limits: PulseLimit[]
8 costUsd?: number
9}
10
11export type PulseCache = {
12 /** Epoch ms of the last main-thread response that touched the cache. */
13 lastHitAt: number | null
14 /** Tokens that response read from / wrote to the prompt cache. */
15 readTokens: number
16 writeTokens: number
17 /** A TTL learned from the engine or from a hit after a long gap. */
18 learnedTtl: '5m' | '1h' | null
19 /** Keep-warm pings sent since the person's last turn. */
20 warmed?: number
21}
22
23/** What the hooks module hands the animated Client module as props. */
24export type PulseFrame = {
25 /** Where the band draws: the desktop lays bars on the cell grid. */
26 surface: string
27 now: number
28 isWorking: boolean
29 usage: PulseUsage | null
30 cache: PulseCache
31 ttlMs: number
32 ttlLabel: string
33 /** Keep-warm pings allowed per idle stretch; 0 while keep-warm is off. */
34 warmMax: number
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'cc-pulse': {
40 usage: PulseUsage | null
41 cache: PulseCache
42 isHidden: boolean
43 /** The person's keep-warm switch this session; null follows the setting. */
44 warmChoice: 'on' | 'off' | null
45 }
46 }
47}
48