A band above the prompt: context forecast and turns left, prompt cache countdown, rate limits with their pace, a cache hit warning, cost, and stand-up /…

Claude Code mods (function-hook plugins). The UI text is in Simplified Chinese.
Claude Code 的 mod 合集(function-hook 插件)。界面文字为简体中文。
A band above the prompt that answers "what should I do now?":
| Shows | Why |
|---|---|
☁ 多云 上下文 21% 210k/1M | Context size, judged in tokens (default warning at 200k), not in share of a 1M window |
约 6 轮到 200k | Turns left before the warning, from the recent growth per turn |
❄ 缓存 57:42 | Prompt cache countdown (1h / 5m, from the cacheTtl option, else inferred from your plan) |
5小时 ▰▰▰▱▱ 67% 预计14:20用完 15:10重置 | Rate-limit windows, with a run-out forecast from the last 30 minutes of use |
命中 40% 缓存可能失效 | Cache hit rate, only when it looks broken |
已坐 47m | Stand-up reminder: a pixel figure, a rotating tip and two buttons after 60 minutes |
Toasts, each sent once: cache about to expire (only when the rewrite is costly and you are at the desk), context at 200k / 400k, a rate-limit window running out before its reset, time to stand up, and a late-night nudge from 23:00. Reminders about you never interrupt a running turn. /meter prints every figure in full (the command works in the terminal; the desktop app does not route mod commands).
Needs a Claude Code build with function-hook plugins (developed against 2.1.288–2.1.291).
claude plugin marketplace add feizaodemon/claude-mods
claude plugin install session-meter@feizao-mods
Or clone this repo and list the folder in CLAUDE_CODE_PLUGIN_DIRS (;-separated on Windows, : elsewhere) in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/mods/session-meter" } }
| Option | Default | |
|---|---|---|
cacheTtl | auto | auto, 1h or 5m; set it to match if you fix the TTL with an environment variable or promptCacheTtl |
contextWarnPercent | 80 | Warn at this share of the window; 0 off |
contextWarnTokens | 200000 | Warn at this many tokens, again at twice it; 0 off |
standupMinutes | 60 | Stand-up reminder interval; 10 minutes without a prompt counts as a break; 0 off |
bedtimeHour | 23 | Late-night reminder from this hour to 5 am; 0 off |
The mod only reads: it never changes what other code sends, and it sends nothing off the machine.
| Hook | What it does |
|---|---|
prompt.submit | Notes the time: a turn has started (stand-up and late-night reminders wait for it to end), and a prompt you typed means you are at the desk. Passes the prompt on unchanged. |
turn.step | After each main-thread response, records when it landed, its cache hit rate and the cache TTL it got. Passes the result on unchanged. |
turn.complete | Marks the main turn over and records the context size, for the turns-left estimate. |
session.start / session.end | Registers /meter and starts the once-a-second tick; clears the per-conversation figures on /clear. |
ui.render (AbovePrompt) | Draws the band, and the stand-up panel while it is due. What others draw there is kept. |
command.run (meter) | Prints every figure in full. |
It reads nothing from the environment or settings.json: if you fix the cache TTL there, set cacheTtl to match. Its sitting clock and late-night flag live in the plugin's own store.
claude plugin validate mods/session-meter
claude plugin test mods/session-meter
Layout: hooks/format.ts (the band's figures), hooks/alerts.ts (every toast rule, pure), hooks/standup.ts (sitting clock, pixel figure), hooks/register.tsx (the once-a-second tick and the drawing).
MIT
hooks/register.tsx 299 lines1// session-meter: a band above the prompt with the context forecast and turns left, the prompt cache
2// countdown, the rate-limit windows and their pace, a cache-hit warning, the session cost, and
3// stand-up / late-night reminders, fitted to the band's width. /meter prints the figures in full.
4//
5// Once a second the tick reads the engine, works out what the band shows (a View in $.state, which
6// redraws its readers when it changes) and which toasts are due (alerts.ts). Drawing only reads.
7import type { EngineInterface, Register, SessionUsage } from 'claude-code'
8
9import type { View } from '../types'
10import type { Memo, Snapshot } from './alerts'
11import { EMPTY_MEMO, decideToasts } from './alerts'
12import type { Judge, Sample } from './format'
13import { SEP, WARN_MS, WINDOW_LABEL, cacheDetail, chips, exhaustsAt, hhmm, hhmmAt, hitPercent, kCount, layout, recentRate, ttlFor, ttlMs, warnTokens } from './format'
14import type { Standup } from './standup'
15import { IDLE_RESET_MS, advice, done, duration, figure, lateNight, nudgeKey, onActivity, parse, phase, snooze, standupChip } from './standup'
16
17const HISTORY = 12
18const STANDUP_KEY = 'standup' // in $.store: one sitting clock shared by every session
19const BEDTIME_KEY = 'bedtime' // in $.store: the night the late-hour reminder last went out for
20const STORE_SYNC_MS = 15_000 // how stale the stand-up clock may be before it is read again
21const SAMPLE_EVERY_MS = 60_000 // how often a rate-limit reading is kept for the recent pace
22const RUNNING_MAX_MS = 30 * 60_000 // a turn that never reported its end stops counting as running
23const FAILING_AFTER = 3 // ticks failed in a row before the band says its figures may be stale
24
25// This mod's values in $.state, each read and written with $.state.get / $.state.set.
26const LAST = { plugin: 'session-meter', key: 'last' } as const
27const READINGS = { plugin: 'session-meter', key: 'readings' } as const
28const VIEW = { plugin: 'session-meter', key: 'view' } as const
29
30// The options; set again on each (re)load.
31const cfg = { ttlOption: 'auto' as unknown, ctxWarn: 80, ctxWarnTokens: 200_000, standupMs: 60 * 60_000, bedtimeHour: 23 }
32
33// What this module remembers between ticks; a reload starts it over.
34// runningSince: when the main conversation's current turn began (0: idle); standup / standupReadAt:
35// this session's copy of the shared clock and when it was read; samples: each window's readings for
36// the recent pace, kept for the reset they belong to; nightChecked: the night whose shared flag was
37// read; viewKey: the View last written; failures / lastError: the tick's failures in a row.
38const fresh = () => ({
39 memo: { ...EMPTY_MEMO } as Memo,
40 runningSince: 0,
41 standup: undefined as Standup | undefined,
42 standupReadAt: 0,
43 samples: {} as Record<string, { resetsAt: string; list: Sample[] }>,
44 sampledAt: 0,
45 nightChecked: '',
46 viewKey: '',
47 failures: 0,
48 lastError: '',
49})
50let mem = fresh()
51
52const isRunning = (now: number) => mem.runningSince > 0 && now - mem.runningSince < RUNNING_MAX_MS
53
54// Keeps a reading of each window once a minute; a new reset starts its list over.
55function sample(now: number, usage: SessionUsage) {
56 if (now - mem.sampledAt < SAMPLE_EVERY_MS) return
57 mem.sampledAt = now
58 for (const r of usage.rateLimits) {
59 const held = mem.samples[r.kind]
60 const list = held && held.resetsAt === (r.resetsAt ?? '') ? held.list : []
61 mem.samples[r.kind] = { resetsAt: r.resetsAt ?? '', list: [...list, { t: now, pct: r.percentUsed }].slice(-40) }
62 }
63}
64
65function judge(now: number): Judge {
66 const rates: Record<string, number | undefined> = {}
67 for (const [kind, held] of Object.entries(mem.samples)) rates[kind] = recentRate(held.list, now)
68 return { warnPercent: cfg.ctxWarn, warnCapTokens: cfg.ctxWarnTokens, rates }
69}
70
71// Read from $.store at most every 15 s: the band redraws every second, the clock moves by minutes.
72async function loadStandup($: EngineInterface, now: number): Promise<Standup> {
73 if (mem.standup && now - mem.standupReadAt < STORE_SYNC_MS) return mem.standup
74 mem.standup = parse(await $.store.get(STANDUP_KEY).catch(() => undefined), now)
75 mem.standupReadAt = now
76 return mem.standup
77}
78
79async function saveStandup($: EngineInterface, s: Standup) {
80 mem.standup = s
81 await $.store.set(STANDUP_KEY, s).catch(() => {}) // the clock still runs in this session
82}
83
84// One second of the meter: the View the band draws, and the toasts now due.
85async function tick($: EngineInterface) {
86 const now = await $.clock.now()
87 const step = (await $.state.get(LAST)).value ?? null
88 const usage = await $.session.usage()
89 const ttl = step?.ttl ?? ttlMs('1h')
90 sample(now, usage)
91 const j = judge(now)
92
93 const s = await loadStandup($, now)
94 const p = cfg.standupMs > 0 ? phase(s, now, cfg.standupMs) : undefined
95 const upChip = p ? standupChip(p) : undefined
96 const isDue = p?.phase === 'due'
97
98 const next: View = {
99 chips: [...chips(step, now, ttl, usage, (await $.state.get(READINGS)).value ?? [], j), ...(upChip ? [upChip] : [])],
100 ...(isDue && p ? { standup: { tips: advice(s, p.sat, now), frame: Math.floor(now / 1000) % 2 } } : {}),
101 }
102 const key = JSON.stringify(next)
103 if (key !== mem.viewKey) {
104 mem.viewKey = key
105 await $.state.set(VIEW, next)
106 }
107
108 // The shared flags the toasts depend on: the sitting reminded, the night reminded.
109 const night = lateNight(now, cfg.bedtimeHour)
110 if (night && mem.nightChecked !== night) {
111 mem.nightChecked = night
112 const sent = await $.store.get(BEDTIME_KEY).catch(() => night) // unreadable: count it as sent
113 mem.memo.night = typeof sent === 'string' ? sent : ''
114 }
115 mem.memo.nudged = s.nudged
116
117 const snap: Snapshot = {
118 running: isRunning(now),
119 away: now - s.lastActiveAt >= IDLE_RESET_MS,
120 cache: step ? { at: step.at, left: step.at + ttl - now, warnMs: WARN_MS } : undefined,
121 context: { tokens: usage.context.tokens, percent: usage.context.percent, window: usage.context.window, warnAt: warnTokens(usage.context.window, cfg.ctxWarn, cfg.ctxWarnTokens) },
122 pace: usage.rateLimits.flatMap(r => {
123 const at = r.percentUsed >= 50 ? exhaustsAt(r, now, j.rates?.[r.kind]) : undefined
124 return at === undefined ? [] : [{ key: `${r.kind}:${r.resetsAt ?? ''}`, label: WINDOW_LABEL[r.kind] ?? r.kind, at: hhmmAt(at, now) ?? '', reset: hhmm(r.resetsAt, now) ?? undefined }]
125 }),
126 standup: isDue && p ? { key: nudgeKey(s), sat: duration(p.sat) } : undefined,
127 night: night ? { key: night, time: hhmmAt(now) ?? '' } : undefined,
128 }
129 const { toasts, memo } = decideToasts(snap, mem.memo)
130 if (memo.nudged !== mem.memo.nudged) await saveStandup($, { ...s, nudged: memo.nudged })
131 if (memo.night !== mem.memo.night) await $.store.set(BEDTIME_KEY, memo.night).catch(() => {})
132 mem.memo = memo
133 for (const t of toasts) $.ui.toast(t)
134}
135
136// The tick, with its failures counted rather than swallowed: after a few in a row the band says so
137// (its figures may be stale) and /meter names the last error.
138async function refresh($: EngineInterface) {
139 try {
140 await tick($)
141 if (mem.failures >= FAILING_AFTER) await setFailing($, false)
142 mem.failures = 0
143 } catch (error) {
144 mem.failures += 1
145 mem.lastError = error instanceof Error ? error.message : String(error)
146 if (mem.failures === FAILING_AFTER) await setFailing($, true).catch(() => {})
147 }
148}
149
150async function setFailing($: EngineInterface, failing: boolean) {
151 const v = (await $.state.get(VIEW)).value
152 if (v) await $.state.set(VIEW, { ...v, failing })
153}
154
155// A press on the stand-up panel's buttons: done or snooze the shared clock, then redraw.
156async function pressStandup($: EngineInterface, change: (s: Standup, t: number) => Standup) {
157 const t = await $.clock.now()
158 await saveStandup($, change(await loadStandup($, t), t))
159 await refresh($)
160}
161
162const FAILING_CHIP = { key: 'failing', text: '⚠ meter 数据可能过期(/meter 看原因)', short: '⚠ meter', color: 'yellow', rank: 2 }
163
164export const register: Register = (on, options) => {
165 cfg.ttlOption = options.cacheTtl
166 cfg.ctxWarn = typeof options.contextWarnPercent === 'number' ? options.contextWarnPercent : 80
167 cfg.ctxWarnTokens = typeof options.contextWarnTokens === 'number' ? options.contextWarnTokens : 200_000
168 cfg.standupMs = (typeof options.standupMinutes === 'number' ? options.standupMinutes : 60) * 60_000
169 cfg.bedtimeHour = typeof options.bedtimeHour === 'number' ? options.bedtimeHour : 23
170 mem = fresh()
171
172 // Any prompt starts a main-thread turn; one the person sent is also the sign they are at the desk.
173 // (turn.start cannot mark it: it does not say whose turn, and a subagent's would never be cleared.)
174 on('prompt.submit', async ($, e, next) => {
175 const now = await $.clock.now()
176 mem.runningSince = now
177 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
178 await saveStandup($, onActivity(await loadStandup($, now), now)).catch(() => {})
179 }
180 return next(e)
181 })
182
183 on('session.start', async ($, e, next) => {
184 const r = await next(e)
185 $.ui.status(undefined) // the band replaces the status line an older version wrote
186 await $.command.register({ name: 'meter', description: '查看缓存、上下文、额度和费用明细' }).catch(() => {})
187 $.clock.every(1000, () => void refresh($))
188 await refresh($)
189 return r
190 })
191
192 // Main-thread responses only: a subagent's requests keep a cache of their own.
193 on('turn.step', async function* ($, e, next) {
194 const result = yield* next(e)
195 if (e.agentId === undefined && result.usage) {
196 const at = await $.clock.now()
197 const hit = hitPercent(result.usage)
198 const { rateLimits } = await $.session.usage()
199 const { ttl, source } = ttlFor(cfg.ttlOption, rateLimits)
200 await $.state.set(LAST, { at, hitPercent: hit, ttl, ttlSource: source })
201 await refresh($)
202 }
203 return result
204 })
205
206 // The main conversation's turn is over; one reading of the context, for the turns left.
207 on('turn.complete', async ($, e, next) => {
208 const r = await next(e)
209 if (!e.agentId) {
210 mem.runningSince = 0
211 const { context } = await $.session.usage()
212 if (context.tokens !== undefined) {
213 const held = (await $.state.get(READINGS)).value ?? []
214 await $.state.set(READINGS, [...held, { tokens: context.tokens }].slice(-HISTORY))
215 }
216 await refresh($)
217 }
218 return r
219 })
220
221 on('session.end', async ($, e, next) => {
222 await $.state.set(LAST, null) // /clear starts a fresh prefix
223 await $.state.set(READINGS, [])
224 mem.viewKey = ''
225 return next(e)
226 })
227
228 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
229 const rest = await next(e) // what other mods and Claude Code draw here stays
230 const v = (await $.state.get(VIEW)).value ?? null
231 if (e.props.hasSurvey || !v) return rest
232 const { Box, Text, Button } = $.ui.resolve(e)
233 const all = v.failing ? [...v.chips, FAILING_CHIP] : v.chips
234 const rows = layout(all, Math.max(10, e.props.bodyColumns - 2), Math.min(2, Math.max(1, e.props.maxRows)))
235
236 return (
237 <Box flexDirection="column">
238 {v.standup && (
239 <Box key="standup" flexDirection="row" columnGap={2} paddingX={1}>
240 <Box flexDirection="column">
241 {figure(v.standup.frame).map((r, i) => (
242 <Text key={`px${i}`} color="green">{r}</Text>
243 ))}
244 </Box>
245 <Box flexDirection="column">
246 {v.standup.tips.map((t, i) => (
247 <Text key={`tip${i}`} color={i === 0 ? 'yellow' : undefined} bold={i === 0}>{t}</Text>
248 ))}
249 <Box flexDirection="row" columnGap={1}>
250 <Button key="moved" label="已活动" variant="primary" onPress={() => pressStandup($, done)} />
251 <Button key="later" label="10 分钟后提醒" onPress={() => pressStandup($, snooze)} />
252 </Box>
253 </Box>
254 </Box>
255 )}
256 {rows.map((row, i) => (
257 <Box key={`row${i}`} flexDirection="row" paddingX={1}>
258 {row.flatMap((it, j) => [
259 j > 0 ? <Text key={`sep:${it.chip.key}`} dimColor>{SEP}</Text> : null,
260 <Text key={it.chip.key} color={it.chip.color} dimColor={it.chip.dim} bold={it.chip.key === 'context'} wrap="truncate">
261 {it.label}
262 </Text>,
263 ])}
264 </Box>
265 ))}
266 {rest}
267 </Box>
268 )
269 })
270
271 on('command.run', { command: 'meter' }, async $ => {
272 const now = await $.clock.now()
273 const step = (await $.state.get(LAST)).value ?? null
274 const usage = await $.session.usage({ breakdown: 'summary' })
275 const { context, rateLimits, cost } = usage
276 const ttl = step?.ttl ?? ttlMs('1h')
277 const j = judge(now)
278 const out: string[] = [chips(step, now, ttl, usage, (await $.state.get(READINGS)).value ?? [], j).map(c => c.text).join(SEP), '']
279 out.push(cacheDetail(step, now))
280 const warnAt = warnTokens(context.window, cfg.ctxWarn, cfg.ctxWarnTokens)
281 out.push(`上下文:${context.tokens !== undefined ? kCount(context.tokens) : '–'} / ${kCount(context.window)}(${context.percent ?? '–'}%)${warnAt ? `,${kCount(warnAt)} 时提醒` : ''}`)
282 for (const c of context.breakdown?.categories ?? []) {
283 if (c.tokens > 0) out.push(` ${c.name}: ${kCount(c.tokens)}${c.isDeferred ? '(按需加载)' : ''}`)
284 }
285 for (const r of rateLimits) {
286 const runsOut = exhaustsAt(r, now, j.rates?.[r.kind])
287 const reset = hhmm(r.resetsAt, now)
288 out.push(`额度 ${WINDOW_LABEL[r.kind] ?? r.kind}:已用 ${r.percentUsed}%${reset ? `,${reset} 重置` : ''}${runsOut ? `;按当前速度约 ${hhmmAt(runsOut, now)} 用完` : ''}`)
289 }
290 if (cost) out.push(`本会话费用:$${cost.usd.toFixed(2)}`)
291 if (cfg.standupMs > 0) {
292 const p = phase(await loadStandup($, now), now, cfg.standupMs)
293 out.push(p.phase === 'away' ? '久坐:超过 10 分钟没发消息,下次发消息时重新计时' : `久坐:已坐 ${duration(p.sat)},每 ${cfg.standupMs / 60_000} 分钟提醒一次`)
294 }
295 if (mem.failures > 0) out.push(`刷新失败 ${mem.failures} 次,最后一次:${mem.lastError}`)
296 return { text: out.join('\n') }
297 })
298}
299hooks/alerts.ts 73 lines1// Every toast the meter sends, decided in one place from one snapshot, so the rules (once each,
2// never mid-turn for the person, never to an empty desk for the cache) are written once and tested.
3import { clock, kCount } from './format'
4
5export const CHEAP_REWRITE = 20_000 // context tokens under which a cache rewrite is not worth a toast
6
7export type Snapshot = {
8 /** A main-thread turn is under way: reminders about the person wait for it to end. */
9 running: boolean
10 /** Ten minutes without a prompt: nobody is there to act on a cache warning. */
11 away: boolean
12 /** The last main-thread response's `at`, and how long its cache has left. */
13 cache?: { at: number; left: number; warnMs: number }
14 context: { tokens?: number; percent?: number; window: number; warnAt?: number }
15 /** Windows the current pace runs out before their reset; `key` is the window and its reset. */
16 pace: { key: string; label: string; at: string; reset?: string }[]
17 /** The stand-up reminder while due; `key` is the sitting and snooze it is for. */
18 standup?: { key: string; sat: string }
19 /** The late-hour night we are in, and the clock time. */
20 night?: { key: string; time: string }
21}
22
23// What has been sent already. `nudged` and `night` are shared across sessions through $.store.
24export type Memo = { cacheFor: number; ctx: boolean; ctxStrong: boolean; pace: string[]; nudged: string; night: string }
25export const EMPTY_MEMO: Memo = { cacheFor: 0, ctx: false, ctxStrong: false, pace: [], nudged: '', night: '' }
26
27export function decideToasts(s: Snapshot, m: Memo): { toasts: string[]; memo: Memo } {
28 const toasts: string[] = []
29 const memo = { ...m }
30
31 // The cache: worth a toast only when the rewrite costs something (its price is the whole
32 // context) and someone is there to send the next message.
33 const tokens = s.context.tokens ?? 0
34 if (s.cache && s.cache.at !== m.cacheFor && tokens >= CHEAP_REWRITE && !s.away && s.cache.left > 0 && s.cache.left <= s.cache.warnMs) {
35 memo.cacheFor = s.cache.at
36 toasts.push(`提示缓存还剩 ${clock(s.cache.left)} 过期:现在发下一条消息,否则要重新写入约 ${kCount(tokens)} token`)
37 }
38
39 // The context: at the warning size, and again at twice it; each again after it drops well below.
40 const { warnAt, window } = s.context
41 if (warnAt !== undefined && s.context.tokens !== undefined) {
42 const t = s.context.tokens
43 if (!m.ctx && t >= warnAt) {
44 memo.ctx = true
45 toasts.push(`上下文已到 ${kCount(t)}(${s.context.percent ?? '–'}%):找个合适的节点手动 /compact`)
46 } else if (m.ctx && t < warnAt * 0.85) memo.ctx = false
47 if (!m.ctxStrong && t >= warnAt * 2 && warnAt * 2 <= window) {
48 memo.ctxStrong = true
49 toasts.push(`上下文已到 ${kCount(t)}:每轮都在重读这么多,建议现在 /compact,或写交接后 /clear`)
50 } else if (m.ctxStrong && t < warnAt * 1.7) memo.ctxStrong = false
51 }
52
53 // The rate limits: once per window and reset.
54 for (const p of s.pace) {
55 if (memo.pace.includes(p.key)) continue
56 memo.pace = [...memo.pace, p.key].slice(-8)
57 toasts.push(`${p.label}额度按当前速度约 ${p.at} 用完${p.reset ? `(${p.reset} 重置)` : ''}:可以降低 effort 或换小一点的模型`)
58 }
59
60 // The person: never in the middle of a turn.
61 if (!s.running) {
62 if (s.standup && s.standup.key !== m.nudged) {
63 memo.nudged = s.standup.key
64 toasts.push(`已经连续坐了 ${s.standup.sat},起来活动 3 分钟吧`)
65 }
66 if (s.night && s.night.key !== m.night) {
67 memo.night = s.night.key
68 toasts.push(`已经 ${s.night.time} 了:收个尾,早点休息`)
69 }
70 }
71 return { toasts, memo }
72}
73hooks/format.ts 237 lines1import type { ModelUsage, SessionUsage } from 'claude-code'
2
3import type { Chip, LastStep, Reading } from '../types'
4
5export const WARN_MS = 60_000 // the cache line turns to a warning this long before expiry
6
7export function ttlMs(option: unknown): number {
8 return option === '5m' ? 5 * 60_000 : 60 * 60_000
9}
10
11// The TTL a cache write gets now and why: this plugin's `cacheTtl` option when it is `1h` or `5m`,
12// else inferred from the plan. A subscription gets 1h within plan usage and 5m on usage credits,
13// and a rate-limit window at 100% stands for the latter. The mod reads nothing from the
14// environment or settings.json: whoever fixes the TTL there sets `cacheTtl` to match.
15export function ttlFor(option: unknown, rateLimits: readonly { percentUsed: number }[]): { ttl: number; source: string } {
16 if (option === '1h' || option === '5m') return { ttl: ttlMs(option), source: '插件设置' }
17 return rateLimits.some(r => r.percentUsed >= 100)
18 ? { ttl: ttlMs('5m'), source: '推断:额度已用满,按超额 5 分钟' }
19 : { ttl: ttlMs('1h'), source: '推断:额度内 1 小时' }
20}
21
22// Share of the prompt the cache served: read / (uncached + read + written).
23export function hitPercent(u: ModelUsage): number | null {
24 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
25 return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
26}
27
28export function clock(ms: number): string {
29 const s = Math.max(0, Math.ceil(ms / 1000))
30 const h = Math.floor(s / 3600)
31 const m = Math.floor((s % 3600) / 60)
32 const ss = String(s % 60).padStart(2, '0')
33 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
34}
35
36export function kCount(n: number): string {
37 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
38 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
39}
40
41const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
42
43// A local clock time, with its weekday when it is not today (a weekly window resets days away),
44// or its month and day when it is six days or more off. `now` absent: the time alone.
45export function hhmm(iso: string | undefined, now?: number): string | null {
46 if (!iso) return null
47 const d = new Date(iso)
48 if (Number.isNaN(d.getTime())) return null
49 const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
50 if (now === undefined || d.toDateString() === new Date(now).toDateString()) return time
51 return Math.abs(d.getTime() - now) < 6 * 24 * 3600_000 ? `${WEEKDAY[d.getDay()]}${time}` : `${d.getMonth() + 1}/${d.getDate()} ${time}`
52}
53
54export const hhmmAt = (ms: number, now?: number) => hhmm(new Date(ms).toISOString(), now)
55
56export const WINDOW_LABEL: Record<string, string> = { five_hour: '5小时', seven_day: '7天', spend_limit: '额度' }
57
58// The context forecast (from token-weather): icon, word and color by how full the window is.
59const FORECAST = [
60 { upTo: 25, icon: '☀', word: '晴', color: 'yellow' },
61 { upTo: 50, icon: '☁', word: '多云', color: 'cyan' },
62 { upTo: 75, icon: '☂', word: '阵雨', color: 'blue' },
63 { upTo: 90, icon: '⛈', word: '雷雨', color: 'magenta' },
64 { upTo: Infinity, icon: '⚠', word: '该压缩了', color: 'red' },
65] as const
66export const forecast = (percent: number) => FORECAST.find(f => percent < f.upTo) ?? FORECAST[4]
67
68// The context size the warning fires at: `percent` of the window, capped at `capTokens`. What a
69// turn costs and how well the model attends follow the tokens, not the share of the window, so a
70// 1M window warns at the cap long before 80% of it. Undefined when both are off.
71export function warnTokens(window: number, percent: number, capTokens: number): number | undefined {
72 const byShare = percent > 0 ? (window * percent) / 100 : Infinity
73 const at = Math.min(byShare, capTokens > 0 ? capTokens : Infinity)
74 return Number.isFinite(at) && at > 0 ? at : undefined
75}
76
77// The forecast's scale: the warning sits at 80 on it, where the weather turns to storms.
78export const forecastPercent = (tokens: number, warnAt: number) => Math.round((tokens / warnAt) * 80)
79
80// How many more turns, at the recent average growth, until the context reaches `target` tokens.
81// Only growth counts: a /compact or /clear shrinks the context but says nothing about the pace.
82export function turnsLeft(history: readonly Reading[], target: number | undefined): number | undefined {
83 const grows = history.slice(-7).flatMap((r, i, a) => (i > 0 && r.tokens > (a[i - 1]?.tokens ?? 0) ? [r.tokens - (a[i - 1]?.tokens ?? 0)] : []))
84 const now = history.at(-1)?.tokens
85 if (grows.length === 0 || now === undefined || target === undefined) return undefined
86 const room = target - now
87 if (room <= 0) return undefined
88 return Math.ceil(room / (grows.reduce((a, b) => a + b, 0) / grows.length))
89}
90
91const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 24 * 3600_000 }
92const RECENT_MS = 30 * 60_000 // the stretch the recent pace is read over
93const RECENT_MIN_MS = 10 * 60_000 // less than this of it says too little
94
95export type Sample = { t: number; pct: number }
96
97// Percent per millisecond over the last half hour of samples, or undefined with under ten minutes
98// of them. Zero when nothing was used: at that pace the window never runs out.
99export function recentRate(samples: readonly Sample[], now: number): number | undefined {
100 const recent = samples.filter(s => s.t >= now - RECENT_MS)
101 const first = recent[0]
102 const lastOne = recent.at(-1)
103 if (!first || !lastOne || lastOne.t - first.t < RECENT_MIN_MS) return undefined
104 return Math.max(0, (lastOne.pct - first.pct) / (lastOne.t - first.t))
105}
106
107// When a rate-limit window runs out, if that comes before its reset: at the recent pace (`rate`,
108// percent per ms) when there is one, else at the window's average pace so far, which needs a
109// tenth of the window behind it. Undefined for a window with no known length.
110export function exhaustsAt(r: { kind: string; percentUsed: number; resetsAt?: string }, now: number, rate?: number): number | undefined {
111 const length = WINDOW_MS[r.kind]
112 const reset = r.resetsAt ? Date.parse(r.resetsAt) : NaN
113 if (!length || Number.isNaN(reset) || r.percentUsed <= 0 || r.percentUsed >= 100) return undefined
114 let at: number
115 if (rate !== undefined) {
116 if (rate <= 0) return undefined
117 at = now + (100 - r.percentUsed) / rate
118 } else {
119 const elapsed = length - (reset - now)
120 if (elapsed < length / 10) return undefined
121 at = now + ((100 - r.percentUsed) / r.percentUsed) * elapsed
122 }
123 return at < reset ? at : undefined
124}
125
126// A rate-limit window as a five-cell meter.
127export function meter(percent: number, width = 5): string {
128 const full = Math.min(width, Math.round((percent / 100) * width))
129 return '▰'.repeat(full) + '▱'.repeat(width - full)
130}
131
132// Cells a string takes: CJK, fullwidth forms and the weather symbols count two.
133export function cells(text: string): number {
134 let n = 0
135 for (const ch of text) n += /[ᄀ-ᅟ☀-➿⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
136 return n
137}
138
139export type { Chip }
140
141export function cacheChip(last: LastStep | null, now: number, ttl: number): Chip {
142 if (last === null) return { key: 'cache', text: '❄ 缓存 –', short: '❄ –', dim: true, rank: 2 }
143 const left = last.at + ttl - now
144 if (left <= 0) return { key: 'cache', text: '❄ 缓存已过期', short: '❄ 过期', color: 'red', rank: 2 }
145 const isSoon = left <= WARN_MS
146 return { key: 'cache', text: `❄ 缓存 ${clock(left)}${isSoon ? ' 快过期' : ''}`, short: `❄ ${clock(left)}`, color: isSoon ? 'yellow' : 'cyan', rank: 2 }
147}
148
149// What the chips are judged against: the context warning's size in tokens, and each rate-limit
150// window's recent pace (percent per ms) where one is known.
151export type Judge = { warnPercent: number; warnCapTokens: number; rates?: Readonly<Record<string, number | undefined>> }
152export const DEFAULT_JUDGE: Judge = { warnPercent: 80, warnCapTokens: 200_000 }
153
154// Every part the engine has a figure for, in the order the band shows them.
155export function chips(last: LastStep | null, now: number, ttl: number, usage: SessionUsage, history: readonly Reading[] = [], judge: Judge = DEFAULT_JUDGE): Chip[] {
156 const out: Chip[] = []
157 const { context, rateLimits, cost } = usage
158 const warnAt = context.window ? warnTokens(context.window, judge.warnPercent, judge.warnCapTokens) : undefined
159 if (context.percent !== undefined && context.tokens !== undefined) {
160 const f = forecast(warnAt ? forecastPercent(context.tokens, warnAt) : context.percent)
161 out.push({
162 key: 'context', rank: 1, color: f.color,
163 text: `${f.icon} ${f.word} 上下文 ${context.percent}% ${kCount(context.tokens)}/${kCount(context.window)}`,
164 short: `${f.icon} ${kCount(context.tokens)}`,
165 })
166 }
167 out.push(cacheChip(last, now, ttl))
168 for (const r of rateLimits) {
169 const pct = Math.round(r.percentUsed)
170 const label = WINDOW_LABEL[r.kind] ?? r.kind
171 const reset = hhmm(r.resetsAt, now)
172 const runsOut = exhaustsAt(r, now, judge.rates?.[r.kind])
173 out.push({
174 key: `limit:${r.kind}`, rank: 3, color: pct >= 90 ? 'red' : pct >= 70 || runsOut ? 'yellow' : undefined,
175 text: `${label} ${meter(pct)} ${pct}%${runsOut ? ` 预计${hhmmAt(runsOut, now)}用完` : ''}${reset ? ` ${reset}重置` : ''}`,
176 short: `${label} ${pct}%${runsOut ? '!' : ''}`,
177 })
178 }
179 // Turns until the context warning, in place of a sparkline that took reading.
180 const left = turnsLeft(history, warnAt)
181 if (left !== undefined && warnAt !== undefined) out.push({ key: 'turns', text: `约 ${left} 轮到 ${kCount(warnAt)}`, short: `${left}轮`, color: left <= 3 ? 'yellow' : undefined, dim: left > 3, rank: 5 })
182 // The hit rate needs no action while the cache works; it shows only when it looks broken.
183 if (last?.hitPercent != null && last.hitPercent < 70) out.push({ key: 'hit', text: `命中 ${last.hitPercent}% 缓存可能失效`, short: `命中${last.hitPercent}%`, color: 'yellow', rank: 4 })
184 // A subscription's figure in API prices: informative, never a reason to act, so last to go.
185 if (cost) out.push({ key: 'cost', text: `$${cost.usd.toFixed(2)}`, short: `$${cost.usd.toFixed(2)}`, dim: true, rank: 8 })
186 return out
187}
188
189export const SEP = ' │ '
190
191// Fits the chips into at most `maxRows` rows of `columns` cells: full text where everything fits,
192// the short forms where it does not, then the lowest-ranked chips dropped until it fits.
193export function layout(all: readonly Chip[], columns: number, maxRows = 2): { chip: Chip; label: string }[][] {
194 const pack = (items: { chip: Chip; label: string }[]) => {
195 const rows: { chip: Chip; label: string }[][] = []
196 let used = 0
197 for (const it of items) {
198 const w = cells(it.label)
199 const row = rows.at(-1)
200 if (row && used + cells(SEP) + w <= columns) {
201 row.push(it)
202 used += cells(SEP) + w
203 } else {
204 rows.push([it])
205 used = w
206 }
207 }
208 return rows
209 }
210 let kept = [...all]
211 for (;;) {
212 for (const useShort of [false, true]) {
213 const rows = pack(kept.map(chip => ({ chip, label: useShort ? chip.short : chip.text })))
214 if (rows.length <= maxRows) return rows
215 }
216 if (kept.length <= 1) return pack(kept.map(chip => ({ chip, label: chip.short })))
217 const worst = Math.max(...kept.map(c => c.rank))
218 const at = kept.map(c => c.rank).lastIndexOf(worst)
219 kept = kept.filter((_, i) => i !== at)
220 }
221}
222
223// The whole meter as one plain line: /meter's first line, and what decides a redraw.
224export function line(last: LastStep | null, now: number, ttl: number, usage: SessionUsage, history: readonly Reading[] = [], judge: Judge = DEFAULT_JUDGE): string {
225 return chips(last, now, ttl, usage, history, judge).map(c => c.text).join(SEP)
226}
227
228// /meter's cache line. A step stored by an older version and kept across a reload has no TTL of its
229// own: it counts down 1h and names no source.
230export function cacheDetail(step: LastStep | null, now: number): string {
231 if (!step) return '提示缓存:主线程还没有响应'
232 const ttl = step.ttl ?? ttlMs('1h')
233 const source = step.ttlSource ? `(${step.ttlSource})` : ''
234 const left = step.at + ttl - now
235 return `提示缓存:上次主线程响应在 ${clock(now - step.at)} 前,TTL ${ttl / 60_000} 分钟${source},${left > 0 ? `还剩 ${clock(left)}` : '已过期'};命中率 ${step.hitPercent ?? '–'}%`
236}
237hooks/standup.ts 125 lines1// Stand-up reminder: how long the person has sat at Claude Code, kept in $.store so every open
2// session shares one clock. Ten minutes with no prompt counts as a break and starts it over.
3import type { Chip } from './format'
4
5const MINUTE = 60_000
6export const IDLE_RESET_MS = 10 * MINUTE // no prompt this long: they were away, the next prompt restarts the clock
7export const SNOOZE_MS = 10 * MINUTE
8const SOON_MS = 10 * MINUTE // the chip turns yellow this long before the reminder
9
10// `day` / `breaks`: the local date and how many breaks were taken on it, for the encouragement line.
11export type Standup = { since: number; lastActiveAt: number; snoozeUntil: number; nudged: string; day: string; breaks: number }
12export type Phase = 'away' | 'ok' | 'soon' | 'due'
13
14/** The local calendar date, `YYYY-MM-DD`. */
15export function dayOf(now: number): string {
16 const d = new Date(now)
17 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
18}
19
20export const fresh = (now: number): Standup => ({ since: now, lastActiveAt: now, snoozeUntil: 0, nudged: '', day: dayOf(now), breaks: 0 })
21
22/** A stored value as a Standup; anything else (nothing stored yet, an older shape) starts fresh. */
23export function parse(value: unknown, now: number): Standup {
24 const v = value as Partial<Standup> | undefined
25 const isOk = v && typeof v.since === 'number' && typeof v.lastActiveAt === 'number'
26 if (!isOk) return fresh(now)
27 return { since: v.since!, lastActiveAt: v.lastActiveAt!, snoozeUntil: v.snoozeUntil ?? 0, nudged: v.nudged ?? '', day: v.day ?? dayOf(now), breaks: v.breaks ?? 0 }
28}
29
30/** A new sitting after a break, counting the break toward today. */
31function afterBreak(s: Standup, now: number): Standup {
32 const today = dayOf(now)
33 return { ...fresh(now), day: today, breaks: (s.day === today ? s.breaks : 0) + 1 }
34}
35
36/** A prompt from the person: after a break (ten minutes without one) the clock starts over. */
37export function onActivity(s: Standup, now: number): Standup {
38 return now - s.lastActiveAt >= IDLE_RESET_MS ? afterBreak(s, now) : { ...s, lastActiveAt: now }
39}
40
41/** Breaks taken today; a count from an earlier day is none. */
42export const breaksToday = (s: Standup, now: number) => (s.day === dayOf(now) ? s.breaks : 0)
43
44export const done = (s: Standup, now: number): Standup => afterBreak(s, now)
45export const snooze = (s: Standup, now: number): Standup => ({ ...s, snoozeUntil: now + SNOOZE_MS })
46
47export function phase(s: Standup, now: number, intervalMs: number): { phase: Phase; sat: number } {
48 const sat = Math.max(0, now - s.since)
49 if (now - s.lastActiveAt >= IDLE_RESET_MS) return { phase: 'away', sat }
50 if (sat >= intervalMs && now >= s.snoozeUntil) return { phase: 'due', sat }
51 if (sat >= intervalMs - SOON_MS) return { phase: 'soon', sat } // also while snoozed
52 return { phase: 'ok', sat }
53}
54
55/** The one reminder per sitting (and per snooze) the toast goes out for. */
56export const nudgeKey = (s: Standup) => `${s.since}:${s.snoozeUntil}`
57
58export function duration(ms: number): string {
59 const m = Math.floor(ms / MINUTE)
60 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
61}
62
63export function standupChip(p: { phase: Phase; sat: number }): Chip | undefined {
64 const d = duration(p.sat)
65 if (p.phase === 'away') return undefined
66 if (p.phase === 'due') return { key: 'standup', text: `起来活动下!已坐 ${d}`, short: `起来!${d}`, color: 'red', rank: 1 }
67 if (p.phase === 'soon') return { key: 'standup', text: `已坐 ${d} 快该起来了`, short: `坐${d}`, color: 'yellow', rank: 3 }
68 return { key: 'standup', text: `已坐 ${d}`, short: `坐${d}`, dim: true, rank: 7 }
69}
70
71// Two frames of an 8×6 pixel figure: arms down, then stretching up. '#' is a lit pixel.
72const FRAMES: readonly (readonly string[])[] = [
73 ['...##...', '...##...', '..####..', '.#.##.#.', '..#..#..', '..#..#..'],
74 ['.#.##.#.', '.#.##.#.', '..####..', '...##...', '..#..#..', '.#....#.'],
75]
76
77/** Pixel rows two at a time as half blocks: one text row shows two pixel rows. */
78export function halfBlocks(grid: readonly string[]): string[] {
79 const out: string[] = []
80 for (let y = 0; y < grid.length; y += 2) {
81 const top = grid[y] ?? ''
82 const bottom = grid[y + 1] ?? ''
83 let row = ''
84 for (let x = 0; x < Math.max(top.length, bottom.length); x++) {
85 const t = top[x] === '#'
86 const b = bottom[x] === '#'
87 row += t && b ? '█' : t ? '▀' : b ? '▄' : ' '
88 }
89 out.push(row)
90 }
91 return out
92}
93
94export const figure = (frame: number): string[] => halfBlocks(FRAMES[frame % FRAMES.length] ?? FRAMES[0]!)
95
96// One suggestion per sitting, in turn: the same sentence every hour soon goes unread.
97const TIPS = [
98 '站起来走两步,伸个懒腰,3 分钟就够。',
99 '看向 6 米外的地方 20 秒,让眼睛放松。',
100 '去倒杯水,顺便走动一下。',
101 '转转脖子、耸耸肩,放松肩颈。',
102 '站起来做 10 个深蹲,或者原地踏步一分钟。',
103] as const
104
105/** The words beside the figure, so the picture is never the only message. */
106export function advice(s: Standup, sat: number, now: number): string[] {
107 const n = breaksToday(s, now)
108 return [
109 `你已经连续坐了 ${duration(sat)}。`,
110 TIPS[Math.floor(s.since / MINUTE) % TIPS.length] ?? TIPS[0],
111 ...(n > 0 ? [`今天已经活动了 ${n} 次,继续保持。`] : []),
112 ]
113}
114
115/**
116 * The night a late-hour reminder belongs to (the evening's date), or undefined outside the late
117 * hours: from `hour` until 5 in the morning. `hour` 0 turns it off.
118 */
119export function lateNight(now: number, hour: number): string | undefined {
120 if (hour <= 0 || hour > 23) return undefined
121 const h = new Date(now).getHours()
122 if (h >= hour) return dayOf(now)
123 return h < 5 ? dayOf(now - 24 * 3600_000) : undefined
124}
125types/index.d.ts 21 lines1// The last main-thread response: when it finished, how much of its prompt the cache served,
2// and the TTL its cache write got (fixed at that moment: a later overage does not shorten it).
3// `ttl` / `ttlSource` are absent on a step stored by an older version and kept across a reload.
4export type LastStep = { at: number; hitPercent: number | null; ttl?: number; ttlSource?: string }
5// Context tokens after each main-thread turn, newest last, for the turns-left estimate.
6export type Reading = { tokens: number }
7
8// One piece of the band. `short` is what it says when room runs out; `rank` 1 is kept longest.
9export type Chip = { key: string; text: string; short: string; color?: string; dim?: boolean; rank: number }
10
11// What the band shows, worked out once a second by the tick and only read while drawing.
12// `standup`: the reminder panel while it is due, with the figure's frame; `failing`: the tick has
13// failed several times in a row, so the figures may be stale.
14export type View = { chips: Chip[]; standup?: { tips: string[]; frame: number }; failing?: boolean }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'session-meter': { last: LastStep | null; readings: Reading[]; view: View | null }
19 }
20}
21