ccOverhead: context, per-turn growth, quota, cache warmth, native cost and agent activity above the Claude Code prompt, with session and agent details.

Your Claude Code overhead, right overhead. ccOverhead is a Claude Code mod that puts context usage, per-turn growth, 5-hour and weekly quota, prompt-cache warmth, native cost and running-agent activity in one quiet band above the prompt. One cool-to-warm color scale makes changes easy to notice while you work. Type /ccoverhead for a pane with the detail: where auto-compaction runs, the window by category and MCP server, growth and compactions, cache hit rate, quota with a shaded projection at the window-average pace, native cost, and subagents.
The band uses text and glyphs in the terminal, and vector graphics in the Claude desktop app's Code tab. It reads only figures Claude Code already reports: no user files, network requests, model requests or telemetry. Compacting, pausing and switching models remain your decisions.
Choose one installation source and enable only one copy:
ccOverhead, and install the entry from Anthropic Directory. No marketplace setup or terminal commands are needed. Open the directory listing./plugin marketplace add shengyy/ccoverhead
/plugin install ccoverhead@ccoverhead
/reload-plugins
The band appears above the prompt when figures are available. Quota appears only when the host reports it. /ccoverhead opens the pane on every surface, VS Code and mobile included. See the full English README for requirements, updates and the group-by-group explanation, and verified behavior for tested surfaces and remaining limitations.
Four signals share one band and one visual scale. Secondary details yield first when space is tight, keeping context visible longest. The following diagrams use fictional figures and illustrate the design specification.


On session start and after /clear, /resume or /branch, the lifecycle hook refreshes only ccOverhead's numeric state and clears its growth, cache and subagent history. It forwards the original event and the downstream result unchanged, preserving the first message, instructions and permission decisions. Other hooks observe usage and model changes; the render hooks only draw the band and the pane, and the /ccoverhead command adds nothing to the conversation.
Session figures stay in memory. Only the last quota reading is kept in Claude Code's plugin store so a new session can show it until its own arrives. See the privacy details.
The tests/ files are offline fixtures for claude plugin test; they are not registered hook modules. They simulate engine operations, including agent spawns and slash commands, without model or network requests. The manifest's types field points to declaration-only plugin state types, not executable code.
Released under the MIT license. ccOverhead is an independent project, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 554 lines1// ccOverhead: the band above the Claude Code prompt and the /ccoverhead pane. The context window with where
2// auto-compaction runs, each turn's growth, the 5h/7d quota and prompt-cache warmth, on one safe-to-warning
3// colour scale. Reads only what the engine reports: no files, no processes, no network, no model requests.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, SessionContextBreakdown, SessionContextUsage, SessionRateLimit, Timer } from 'claude-code'
6
7import type { OverheadAgent, OverheadBreakdown, OverheadCache, OverheadCacheStats, OverheadCompaction, OverheadCtx, OverheadEffort, OverheadLimit, OverheadTheme } from '../types'
8import { bandRich, bandTerminal, paneRich, paneTerminal } from './draw'
9import { CACHE_TTL_MS, SHORT_TTL_MS, fit, groups, validCost } from './format'
10import type { AgentView, BandInput } from './format'
11import { paneLines } from './pane'
12import { NO_CACHE_STATS, TIMELINE, addAgentStep, addCompaction, addSample, addStep, baseModel, clearRewrite, compacted, learnedTtl } from './track'
13
14// Session-long values the host keeps across a reload of this module.
15const ctx = atom({ plugin: 'ccoverhead', key: 'ctx' } as const, null as OverheadCtx | null)
16const history = atom({ plugin: 'ccoverhead', key: 'history' } as const, [] as number[])
17const timeline = atom({ plugin: 'ccoverhead', key: 'timeline' } as const, [] as number[])
18const compactions = atom({ plugin: 'ccoverhead', key: 'compactions' } as const, [] as OverheadCompaction[])
19const limits = atom({ plugin: 'ccoverhead', key: 'limits' } as const, [] as OverheadLimit[])
20const limitsLive = atom({ plugin: 'ccoverhead', key: 'limitsLive' } as const, false)
21const cache = atom({ plugin: 'ccoverhead', key: 'cache' } as const, null as OverheadCache | null)
22const cacheStats = atom({ plugin: 'ccoverhead', key: 'cacheStats' } as const, NO_CACHE_STATS as OverheadCacheStats)
23const cacheTtl = atom({ plugin: 'ccoverhead', key: 'cacheTtl' } as const, null as number | null)
24const cost = atom({ plugin: 'ccoverhead', key: 'cost' } as const, null as number | null)
25const turnCostBase = atom({ plugin: 'ccoverhead', key: 'turnCostBase' } as const, null as number | null)
26const turnCost = atom({ plugin: 'ccoverhead', key: 'turnCost' } as const, null as number | null)
27// null until the host's theme has been read; the band draws native colors meanwhile.
28const theme = atom({ plugin: 'ccoverhead', key: 'theme' } as const, null as OverheadTheme | null)
29const sessionId = atom({ plugin: 'ccoverhead', key: 'sessionId' } as const, null as string | null)
30const limitsAt = atom({ plugin: 'ccoverhead', key: 'limitsAt' } as const, null as number | null)
31const activeAgents = atom({ plugin: 'ccoverhead', key: 'activeAgents' } as const, 0)
32const model = atom({ plugin: 'ccoverhead', key: 'model' } as const, null as string | null)
33const effort = atom({ plugin: 'ccoverhead', key: 'effort' } as const, null as OverheadEffort | null)
34const agents = atom({ plugin: 'ccoverhead', key: 'agents' } as const, [] as OverheadAgent[])
35const breakdown = atom({ plugin: 'ccoverhead', key: 'breakdown' } as const, null as OverheadBreakdown | null)
36
37// Countdowns are whole minutes; redraw often enough that they never lag by more than half of one.
38const TICK_MS = 30_000
39// The last live quota windows, shared with new sessions until they get their own reading.
40const STORE_LIMITS = 'limits'
41// The pane's id and the command that opens it; named after the plugin, since a command has no plugin prefix.
42const PANE = 'ccoverhead'
43// Invalidates in-flight native reads on a conversation reset. This is cancellation bookkeeping;
44// all displayed values remain in the host's atoms.
45let conversationRevision = 0
46
47export const register: Register = on => {
48 let tick: Timer | undefined
49 let settling: Timer | undefined
50
51 on('session.start', async ($, e, next) => {
52 const result = await next(e)
53 tick?.cancel()
54 tick = $.clock.every(TICK_MS, async () => {
55 await ensureTheme($)
56 await poll($).catch(() => undefined)
57 $.ui.invalidate('ui.render')
58 })
59 await readTheme($)
60 await readActiveAgents($)
61 await load($).catch(() => undefined)
62 // A host that registers no commands still draws the band.
63 try {
64 await $.command.register({ name: PANE, description: 'Show the context, growth, cache and quota in detail' })
65 } catch {
66 // no pane command
67 }
68 return result
69 })
70
71 // /clear, /resume and /branch start another conversation with no new session.start: the old
72 // one's context, breakdown, growth, cache and subagents no longer apply, the account's quota still does.
73 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
74 // Return the chain's answer directly; refreshing our figures cannot change its first message.
75 try {
76 return await next(e)
77 } finally {
78 await resetConversation($)
79 await ensureTheme($)
80 await load($)
81 if (e.source !== 'clear') await resumed($, e)
82 }
83 })
84
85 // After each turn, and whenever a quota window moves a point. A reading that says the windows changed is
86 // taken even when it is empty: a window withdrawn (a spend limit has no reset to expire it) goes too.
87 on('session.measure', async ($, e, next) => {
88 let revision = conversationRevision
89 const result = await next(e)
90 if (revision !== conversationRevision) return result
91 const changed = await syncSession($)
92 if (!changed && revision !== conversationRevision) return result
93 revision = conversationRevision
94 await readModel($)
95 if (revision !== conversationRevision) return result
96 await takeCost($, e.cost?.usd)
97 if (e.changed.includes('rateLimits')) {
98 const at = await $.clock.now()
99 await update($, limitsAt, () => at)
100 }
101 await take($, e.context, e.rateLimits, e.changed.includes('rateLimits'), true, revision)
102 return result
103 })
104
105 // A compaction of the main conversation, observed and passed on unchanged: growth starts over from it, the
106 // old window's figures and breakdown no longer describe the conversation, and its next request writes a
107 // new conversation, which is no rewrite of a lapsed cache.
108 on('session.compact', async ($, e, next) => {
109 const result = await next(e)
110 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
111 const before = result.tokensBefore ?? (await read($, timeline)).at(-1)
112 await update($, compactions, list => addCompaction(list ?? [], { before, after: result.tokensAfter }))
113 await update($, history, () => [])
114 await update($, timeline, () => [])
115 await update($, cacheStats, s => compacted(s ?? NO_CACHE_STATS))
116 await update($, cache, () => null)
117 await update($, breakdown, () => null)
118 await update($, ctx, c => (c ? withoutReading(c) : c))
119 settling = refreshSoon($, settling)
120 }
121 return result
122 })
123
124 // /model, the picker, a fallback: the weekly group follows the new model at once, and the new model starts
125 // with a cold cache (each model has its own); the switch also says how long the cache lives. The old
126 // model's threshold and breakdown no longer apply.
127 on('classic.PostModelSwitch', async ($, e, next) => {
128 const result = await next(e)
129 await setModel($, e.to_model)
130 await update($, cacheTtl, () => (e.cache_ttl === '5m' ? SHORT_TTL_MS : CACHE_TTL_MS))
131 settling = refreshSoon($, settling)
132 return result
133 })
134
135 // Observe the host's spawn, never initiate one. Its list can settle just after the hook returns.
136 on('agent.spawn', async ($, e, next) => {
137 const result = await next(e)
138 await readActiveAgents($)
139 settling = refreshSoon($, settling)
140 return result
141 })
142
143 on('turn.start', async ($, e, next) => {
144 await syncSession($)
145 const revision = conversationRevision
146 const reading = await $.session.usage().catch(() => undefined)
147 if (revision !== conversationRevision) return next(e)
148 await update($, turnCostBase, () => (validCost(reading?.cost?.usd) ? reading.cost.usd : null))
149 await update($, turnCost, () => null)
150 return next(e)
151 })
152
153 on('turn.complete', async ($, e, next) => {
154 const revision = conversationRevision
155 const result = await next(e)
156 if (revision !== conversationRevision) return result
157 await readActiveAgents($)
158 settling = refreshSoon($, settling)
159 if (e.agentId === undefined) {
160 const ledger = await $.session.usage().catch(() => undefined)
161 if (revision !== conversationRevision) return result
162 await takeCost($, ledger?.cost?.usd)
163 // Keep the baseline until the next turn/reset: the host can post the ledger after this hook.
164 }
165 return result
166 })
167
168 // Each request: on the main conversation, when it started, whether it touched the cache and the running
169 // counts; on a subagent, its input history, cumulative usage and observed requested effort.
170 on('turn.step', async function* ($, e, next) {
171 const revision = conversationRevision
172 // Cache age starts with the request, not after a potentially long streamed response.
173 const started = await $.clock.now()
174 const result = yield* next(e)
175 if (revision !== conversationRevision) return result
176 const u = result.usage
177 if (!u) return result
178 // A downstream model rewrite/fallback cannot establish the effort of the model that answered.
179 const requestedEffort = baseModel(e.model) === baseModel(u.model) ? e.effort : undefined
180 if (e.agentId === undefined) {
181 const touched = (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
182 const changed = await syncSession($)
183 if (!changed && revision !== conversationRevision) return result
184 await setModel($, u.model)
185 await update($, effort, () => requestedEffort ?? null)
186 const before = await read($, cache)
187 const counts = (await read($, cacheStats)) ?? NO_CACHE_STATS
188 const learned = learnedTtl(before, started, counts, u)
189 if (learned !== undefined) await update($, cacheTtl, () => learned)
190 await update($, cache, () => ({ at: started, warm: touched > 0 }))
191 await update($, cacheStats, s => addStep(s ?? NO_CACHE_STATS, u, e.turnId))
192 // Read the host's last context; step usage may sum several server-side responses.
193 settling = refreshSoon($, settling)
194 } else {
195 const id = e.agentId
196 await update($, agents, list => addAgentStep(list ?? [], id, u.model, u, requestedEffort))
197 }
198 await readActiveAgents($)
199 return result
200 })
201
202 // Native events remain useful where the organization's guard skips classic.*.
203 on('command.run', { command: ['clear', 'resume', 'branch', 'model', 'autocompact', 'theme'] }, async ($, e, next) => {
204 const result = await next(e)
205 if (e.command === 'theme') await readTheme($)
206 settling = refreshSoon($, settling)
207 return result
208 })
209
210 on('config.set', { key: ['theme', 'autoCompact'] }, async ($, e, next) => {
211 try {
212 return await next(e)
213 } finally {
214 // A display refresh must never replace the host's decision or error.
215 try {
216 await readTheme($)
217 settling = refreshSoon($, settling)
218 } catch {
219 // Display-only failures do not govern settings changes.
220 }
221 }
222 })
223
224 on('command.run', { command: PANE }, async $ => {
225 await load($)
226 // A pane the surface cannot place yet waits and is seated when one can. No text either way: a command's
227 // text is a transcript row the model reads too.
228 await $.ui.open({ id: PANE, title: 'ccOverhead', rows: 24 })
229 return {}
230 })
231
232 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
233 if (e.props.hasSurvey) return next(e)
234 const band = await bandInput($, e.props.view.agentId)
235 // `bodyColumns` counts cells of a monospace font; a proportional surface draws the band narrower than that, so
236 // it keeps every group and wraps when the window really is narrow, rather than dropping the rightmost.
237 const gs = e.surface === 'terminal' ? fit(band, e.props.bodyColumns - 2) : groups(band)
238 if (gs.length === 0) return next(e)
239 const els = $.ui.resolve(e)
240 const palette = (await read($, theme)) ?? 'native'
241 const below = await next(e)
242 // Branch on the surface, not on the table: the terminal's table answers `'Svg' in` with a placeholder.
243 const own = e.surface === 'terminal' ? bandTerminal($.ui.resolve(e), gs, palette) : bandRich($.ui.resolve(e), gs, palette)
244 return <els.Box flexDirection="column">{own}{below}</els.Box>
245 })
246
247 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248 const band = await bandInput($, e.props.view.agentId)
249 const lines = paneLines({
250 ...band,
251 timeline: await read($, timeline),
252 compactions: await read($, compactions),
253 cacheStats: await read($, cacheStats),
254 sessionId: await read($, sessionId),
255 effort: await read($, effort),
256 agents: await read($, agents),
257 breakdown: await read($, breakdown),
258 viewing: e.props.view.agentId,
259 limitsAt: await read($, limitsAt),
260 turnCost: await read($, turnCost),
261 })
262 const palette = (await read($, theme)) ?? 'native'
263 if (e.surface === 'terminal') return paneTerminal($.ui.resolve(e), lines, palette)
264 return paneRich($.ui.resolve(e), lines, palette)
265 })
266}
267
268// What the band draws from, read (and so subscribed to) by a render hook.
269async function bandInput($: EngineInterface, viewing: string | undefined): Promise<BandInput> {
270 const c = await read($, ctx)
271 const m = await read($, model)
272 const stats = await read($, cacheStats)
273 let view: AgentView | undefined
274 if (viewing !== undefined) {
275 const agent = (await read($, agents)).find(a => a.id === viewing)
276 // An agent on the model the window was read for has that window; on another model (or before the first
277 // reading after a switch) the window is not reported.
278 const window = agent && c?.model && baseModel(agent.model) === baseModel(c.model) ? c.window : undefined
279 view = { agent, window }
280 }
281 return {
282 now: await $.clock.now(),
283 ctx: c,
284 history: await read($, history),
285 limits: await read($, limits),
286 limitsLive: await read($, limitsLive),
287 cache: await read($, cache),
288 cacheTtl: await read($, cacheTtl),
289 cost: await read($, cost),
290 turnCost: await read($, turnCost),
291 activeAgents: await read($, activeAgents),
292 rewrite: stats.rewrite?.tokens,
293 model: m,
294 view,
295 }
296}
297
298// Start of session, a reload or the pane: the engine's figures, else the last live quota from the store.
299async function load($: EngineInterface) {
300 await syncSession($)
301 const revision = conversationRevision
302 const { context, rateLimits, cost: ledger } = await $.session.usage()
303 if (revision !== conversationRevision) return
304 await readModel($)
305 if (revision !== conversationRevision) return
306 await takeCost($, ledger?.usd)
307 if (revision !== conversationRevision) return
308 await take($, context, rateLimits, false, false, revision)
309 if (revision !== conversationRevision) return
310 if (pick(rateLimits).length === 0) {
311 const saved = await $.store.get(STORE_LIMITS)
312 if (revision !== conversationRevision) return
313 if (Array.isArray(saved) && !(await read($, limitsLive))) {
314 await update($, limits, () => saved as OverheadLimit[])
315 }
316 }
317}
318
319// `withdrawn`: an empty reading means the windows went away, not that there is no reading yet.
320async function take($: EngineInterface, context: SessionContextUsage | undefined, rateLimits: SessionRateLimit[] | undefined, withdrawn: boolean, recordGrowth = true, revision = conversationRevision) {
321 // Each reading's own breakdown, or none: one the engine refused this time is unknown, never the last one,
322 // which may describe a conversation since compacted or another model.
323 const b = await localBreakdown($)
324 if (revision !== conversationRevision) return
325 await update($, breakdown, () => (b ? slim(b) : null))
326 if (context?.window) {
327 const m = await read($, model)
328 const nextContext: OverheadCtx = { tokens: context.tokens, window: context.window, percent: context.percent, ...(m && { model: m }) }
329 // Where auto-compaction runs; without a breakdown this time, where it ran last time for the same model.
330 const last = await read($, ctx)
331 const kept = last?.model !== undefined && baseModel(last.model) === baseModel(m) ? last.compactAt : undefined
332 const compactAt = b ? (b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? undefined) : undefined) : kept
333 if (compactAt) nextContext.compactAt = compactAt
334 if (context.tokens !== undefined && context.tokens > 0 && recordGrowth) {
335 const t = context.tokens
336 const before = (await read($, timeline)).at(-1)
337 await update($, history, samples => addSample(samples, t))
338 await update($, timeline, samples => addSample(samples, t, TIMELINE))
339 // A drop no compaction event announced (one this plugin did not see): treated as one. Its first request
340 // has already run, so it stays the base the next one is compared with; only a rewrite mark goes.
341 if (before !== undefined && t < before) {
342 await update($, compactions, list => addCompaction(list ?? [], { before, after: t }))
343 await update($, cacheStats, s => clearRewrite(s ?? NO_CACHE_STATS))
344 }
345 } else if (context.tokens === undefined || context.tokens <= 0) {
346 // No response in this window yet (new, cleared or just compacted): /context's local estimate.
347 nextContext.estimate = b?.totalTokens
348 }
349 await update($, ctx, () => nextContext)
350 }
351 if (revision !== conversationRevision) return
352 await takeLimits($, rateLimits, withdrawn)
353}
354
355// Plain local refreshes also adopt quota changes, even when main context did not move.
356async function takeLimits($: EngineInterface, rateLimits: SessionRateLimit[] | undefined, withdrawn: boolean) {
357 const live = pick(rateLimits)
358 if (live.length > 0 || withdrawn) {
359 const before = JSON.stringify(await read($, limits))
360 await update($, limits, () => live)
361 await update($, limitsLive, () => true)
362 // Only this session's own new reading is shared, so an idle session never overwrites a newer one.
363 if (JSON.stringify(live) !== before) {
364 const at = await $.clock.now()
365 await update($, limitsAt, () => at)
366 await $.store.set(STORE_LIMITS, live)
367 }
368 }
369}
370
371// The main loop's model; a host that cannot say leaves the weekly group on the all-models window.
372async function readModel($: EngineInterface) {
373 const revision = conversationRevision
374 let id: string | null = null
375 try {
376 id = await $.session.model()
377 } catch {
378 // keep the all-models window
379 }
380 if (revision === conversationRevision && id !== null) await setModel($, id)
381}
382
383async function setModel($: EngineInterface, id: string) {
384 const previous = await read($, model)
385 if (previous && baseModel(previous) !== baseModel(id)) {
386 await update($, effort, () => null)
387 const at = await $.clock.now()
388 await update($, cache, c => (c ? { at, warm: false } : c))
389 await update($, cacheTtl, () => null)
390 await update($, breakdown, () => null)
391 await update($, ctx, () => null)
392 }
393 await update($, model, () => id)
394}
395
396async function takeCost($: EngineInterface, value: number | undefined) {
397 const previous = await read($, cost)
398 await update($, cost, () => (validCost(value) ? value : null))
399 if (!validCost(value) || (previous !== null && value < previous)) {
400 await update($, turnCost, () => null)
401 if (validCost(value) && previous !== null && value < previous) await update($, turnCostBase, () => null)
402 return
403 }
404 const base = await read($, turnCostBase)
405 if (base !== null) await update($, turnCost, () => (value >= base ? value - base : null))
406}
407
408function themeOf(value: unknown): OverheadTheme {
409 // The host reports the setting, not auto's resolved appearance; use the dark palette for auto.
410 if (value === 'auto') return 'dark'
411 return value === 'light' || value === 'dark' ? value : 'native'
412}
413
414// Settles the theme only from the host's own `theme` row. A failed list or a list without the row (the host not
415// ready yet) leaves it unread, so `ensureTheme` tries again, rather than fixing the band on native colors.
416async function readTheme($: EngineInterface) {
417 const rows = await $.config.list().catch(() => undefined)
418 const row = rows?.find(r => r.key === 'theme')
419 if (row !== undefined) await update($, theme, () => themeOf(row.value))
420}
421
422// Reads the theme only while it is unread.
423async function ensureTheme($: EngineInterface) {
424 if ((await read($, theme)) === null) await readTheme($)
425}
426
427async function resetConversation($: EngineInterface) {
428 conversationRevision++
429 await update($, effort, () => null)
430 await update($, ctx, () => null)
431 await update($, breakdown, () => null)
432 await update($, history, () => [])
433 await update($, timeline, () => [])
434 await update($, compactions, () => [])
435 await update($, cache, () => null)
436 await update($, cacheTtl, () => null)
437 await update($, cacheStats, () => NO_CACHE_STATS)
438 await update($, agents, () => [])
439 await update($, cost, () => null)
440 await update($, turnCostBase, () => null)
441 await update($, turnCost, () => null)
442 await update($, limitsAt, () => null)
443 await update($, activeAgents, () => 0)
444 await update($, limitsLive, () => false)
445}
446
447async function syncSession($: EngineInterface): Promise<boolean> {
448 const revision = conversationRevision
449 const id = await $.session.id().catch(() => null)
450 if (revision !== conversationRevision || id === null) return false
451 const previous = await read($, sessionId)
452 await update($, sessionId, () => id)
453 const changed = previous !== null && previous !== id
454 if (changed) await resetConversation($)
455 return changed
456}
457
458// A cheap local read on the existing countdown tick catches changes no event delivered.
459async function poll($: EngineInterface) {
460 const changed = await syncSession($)
461 const revision = conversationRevision
462 await readActiveAgents($)
463 const beforeModel = await read($, model)
464 await readModel($)
465 const reading = await $.session.usage()
466 if (revision !== conversationRevision) return
467 await takeCost($, reading.cost?.usd)
468 await takeLimits($, reading.rateLimits, await read($, limitsLive))
469 if (revision !== conversationRevision) return
470 const last = await read($, ctx)
471 const nextContext = reading.context
472 if (changed || beforeModel !== (await read($, model)) || !last || last.window !== nextContext.window || last.tokens !== nextContext.tokens || last.percent !== nextContext.percent) await load($)
473}
474
475async function readActiveAgents($: EngineInterface) {
476 const revision = conversationRevision
477 const list = await $.agent.list().catch(() => [])
478 if (revision !== conversationRevision) return
479 const count = list.filter(agent => agent.status === 'running').length
480 if (count !== await read($, activeAgents)) await update($, activeAgents, () => count)
481 // Reuse this one list read for the pane's identity labels, including delayed or updated descriptions.
482 const before = await read($, agents)
483 const changed = before.some(agent => {
484 const info = list.find(a => a.id === agent.id)
485 return info && (agent.label !== info.type || agent.description !== info.description)
486 })
487 if (changed) {
488 // The update runs on the latest state so another agent's response is never lost while this read waits.
489 await update($, agents, current => current.map(agent => {
490 const info = list.find(a => a.id === agent.id)
491 return info ? { ...agent, label: info.type, description: info.description } : agent
492 }))
493 }
494}
495
496// /context's breakdown counted locally (`summary`, which sends no request; `full` would send one per tool).
497// One the engine cannot give (no session bound, a thin client) is none, never a failed measure that would
498// leave the band on the old figures.
499async function localBreakdown($: EngineInterface): Promise<SessionContextBreakdown | undefined> {
500 try {
501 return (await $.session.usage({ breakdown: 'summary' })).context.breakdown
502 } catch {
503 return undefined
504 }
505}
506
507// What the pane keeps of a breakdown: tokens by row and by MCP server, never a file's path or name.
508function slim(b: SessionContextBreakdown): OverheadBreakdown {
509 const servers = new Map<string, number>()
510 for (const t of b.mcpTools ?? []) if (t.isLoaded) servers.set(t.serverName, (servers.get(t.serverName) ?? 0) + t.tokens)
511 return {
512 autoCompact: b.isAutoCompactEnabled,
513 rows: (b.categories ?? []).filter(c => c.kind === 'used' && c.tokens > 0).map(c => ({ name: c.name, tokens: c.tokens })),
514 deferred: (b.categories ?? []).filter(c => c.kind === 'deferred').reduce((n, c) => n + c.tokens, 0),
515 mcp: [...servers].map(([server, tokens]) => ({ server, tokens })),
516 }
517}
518
519// The context with no reading of its own yet, as after a compaction, until the next response reports one.
520function withoutReading({ window, compactAt, model }: OverheadCtx): OverheadCtx {
521 return { window, ...(compactAt !== undefined && { compactAt }), ...(model !== undefined && { model }) }
522}
523
524// A resumed or forked conversation, before its first request. That request re-sends what the transcript last
525// sent, so a lapsed cache makes it a rewrite (`last`). The engine's age of the cache shows it warm or cold
526// already, and an age between the two lifetimes tells which one the account gets.
527async function resumed($: EngineInterface, e: { context_tokens?: number; seconds_since_last_response?: number; prompt_cache_likely_expired?: boolean }) {
528 const { context_tokens: tokens, seconds_since_last_response: age, prompt_cache_likely_expired: expired } = e
529 if (tokens !== undefined && tokens > 0) await update($, cacheStats, s => ({ ...s, last: tokens }))
530 if (age === undefined || expired === undefined) return
531 const at = (await $.clock.now()) - age * 1000
532 await update($, cache, () => ({ at, warm: !expired }))
533 if (age * 1000 > SHORT_TTL_MS && age * 1000 < CACHE_TTL_MS) await update($, cacheTtl, () => (expired ? SHORT_TTL_MS : CACHE_TTL_MS))
534}
535
536// Every window reported, so a model's own weekly window is at hand when the model switches. A reset time that
537// does not parse is treated as none.
538function pick(rateLimits: SessionRateLimit[] | undefined): OverheadLimit[] {
539 return (rateLimits ?? []).map(({ kind, percentUsed, resetsAt }) => ({
540 kind,
541 percentUsed,
542 ...(resetsAt !== undefined && !Number.isNaN(Date.parse(resetsAt)) && { resetsAt }),
543 }))
544}
545
546// The host installs readings after the hook returns; one coalesced refresh reads its figures.
547function refreshSoon($: EngineInterface, previous: Timer | undefined): Timer {
548 previous?.cancel()
549 return $.clock.after(100, async () => {
550 await readActiveAgents($)
551 await load($).catch(() => undefined)
552 })
553}
554hooks/draw.tsx 149 lines1// The band and the pane as element trees. The terminal draws text in a monospace font: block glyphs, each
2// multi-coloured span piece by piece. The other surfaces draw in a proportional font: rows spaced by `gap`,
3// trimmed text, and the graphic spans as Svg.
4import type { Elements } from 'claude-code'
5import type { OverheadTheme } from '../types'
6
7import type { Span } from './format'
8import { SEP, cells, colorOf, items } from './format'
9import { activityCount, activityOverflow } from './activity'
10import type { PaneLine } from './pane'
11import { LABEL } from './pane'
12
13type Term = Pick<Elements['terminal'], 'Box' | 'Text'>
14type AnimatedTerm = Term & Pick<Elements['terminal'], 'Client'>
15type Rich = Pick<Elements['desktop'], 'Box' | 'Text' | 'Svg'>
16
17// Spans as nested Text; one with no text draws nothing.
18function textRun({ Text }: Term, spans: Span[], key: string, theme: OverheadTheme) {
19 return spans
20 .filter(s => s.text)
21 .map((s, j) => {
22 const parts = cells(s, theme)
23 return parts ? (
24 <Text key={`${key}-${j}`}>
25 {parts.map((c, k) => (
26 <Text key={String(k)} color={c.color} dimColor={c.dimColor}>
27 {c.text}
28 </Text>
29 ))}
30 </Text>
31 ) : (
32 <Text key={`${key}-${j}`} color={colorOf(s, theme)} dimColor={s.dimColor}>
33 {s.text}
34 </Text>
35 )
36 })
37}
38
39// Spans as a row's items: text trimmed, graphics as Svg.
40function itemRun({ Box, Text, Svg }: Rich, spans: Span[], key: string, theme: OverheadTheme) {
41 return items(spans).map((it, j) =>
42 it.kind === 'graphic' ? (
43 it.suffix ? <Box key={`${key}-${j}`} flexDirection="row" alignItems="center" gap={0}>
44 <Svg {...it.graphic} />
45 <Text color={colorOf(it.suffix, theme)}>{it.suffix.text}</Text>
46 </Box> : <Svg key={`${key}-${j}`} {...it.graphic} />
47 ) : (
48 <Text key={`${key}-${j}`} color={colorOf(it.span, theme)} dimColor={it.span.dimColor}>
49 {it.span.text}
50 </Text>
51 ),
52 )
53}
54
55// The terminal's band: one line of text, cut at its end if it still does not fit.
56export function bandTerminal(els: AnimatedTerm, gs: Span[][], theme: OverheadTheme = 'dark') {
57 const { Box, Text } = els
58 return (
59 <Box flexDirection="row" paddingX={1}>
60 {gs.map((g, i) => {
61 const activity = g.find(s => s.agentCount !== undefined)
62 const label = <Text key={`text-${i}`} wrap="truncate-end">
63 {i > 0 && <Text dimColor>{SEP}</Text>}
64 {textRun(els, g.filter(s => s !== activity), String(i), theme)}
65 </Text>
66 if (!activity) return label
67 // Keep the Client outside Text, as the host requires. One clock drives every spinner.
68 const count = activity.agentCount!
69 return <Box key="activity" flexDirection="row">
70 {label}
71 <els.Client key="agent-activity" module="./activity-client.ts" width={activityCount(count)} height={1}
72 props={{ color: colorOf(activity, theme), count }} />
73 {activityOverflow(count) && <Text color={colorOf(activity, theme)}>{activityOverflow(count)}</Text>}
74 </Box>
75 })}
76 </Box>
77 )
78}
79
80// The band elsewhere: each group a row, a dim bar leading every group but the first so a wrapped line keeps
81// it with its group. A group never shrinks; a narrow window wraps whole groups onto the next line.
82export function bandRich(els: Rich, gs: Span[][], theme: OverheadTheme = 'dark') {
83 const { Box, Text } = els
84 return (
85 <Box flexDirection="row" flexWrap="wrap" alignItems="center" paddingX={1} columnGap={1}>
86 {gs.map((g, i) => (
87 <Box key={`group-${i}`} flexDirection="row" alignItems="center" flexShrink={0} gap={1}>
88 {i > 0 && <Text dimColor>|</Text>}
89 {itemRun(els, g, String(i), theme)}
90 </Box>
91 ))}
92 </Box>
93 )
94}
95
96// The pane on the terminal: a bold heading per section, then lines of a fixed-width label and its spans.
97export function paneTerminal(els: Term, lines: PaneLine[], theme: OverheadTheme = 'dark') {
98 const { Box, Text } = els
99 return (
100 <Box flexDirection="column" paddingX={1}>
101 {lines.map((l, i) =>
102 'head' in l ? (
103 <Box key={`h-${i}`} marginTop={i > 0 ? 1 : 0}>
104 <Text bold>{l.head}</Text>
105 </Box>
106 ) : (
107 <Box key={`l-${i}`} flexDirection="row" marginTop={l.gapBefore ? 1 : 0}>
108 <Box width={LABEL} flexShrink={0}>
109 <Text color={colorOf(l.label, theme)} dimColor={l.label.dimColor}>
110 {l.label.text}
111 </Text>
112 </Box>
113 <Text wrap={l.wrap ? 'wrap' : 'truncate-end'}>{textRun(els, l.spans, String(i), theme)}</Text>
114 </Box>
115 ),
116 )}
117 </Box>
118 )
119}
120
121// The pane elsewhere: the same sections, each line a row of items beside its label.
122export function paneRich(els: Rich, lines: PaneLine[], theme: OverheadTheme = 'dark') {
123 const { Box, Text } = els
124 return (
125 <Box flexDirection="column" paddingX={1}>
126 {lines.map((l, i) =>
127 'head' in l ? (
128 <Box key={`h-${i}`} marginTop={i > 0 ? 1 : 0}>
129 <Text bold>{l.head}</Text>
130 </Box>
131 ) : (
132 // Spaces do not align in a proportional font: a figure label is right-aligned by its Box, and a line
133 // that belongs to the one above is indented by padding.
134 <Box key={`l-${i}`} flexDirection="row" alignItems="flex-start" gap={1} marginTop={l.gapBefore ? 1 : 0}>
135 <Box width={LABEL} flexShrink={0} justifyContent={l.end ? 'flex-end' : 'flex-start'}>
136 <Text color={colorOf(l.label, theme)} dimColor={l.label.dimColor}>
137 {l.label.text.trim()}
138 </Text>
139 </Box>
140 <Box flexDirection="row" alignItems="center" flexWrap="wrap" gap={1} paddingLeft={l.nested ? 2 : 0} flexShrink={1} minWidth={0}>
141 {itemRun(els, l.spans, String(i), theme)}
142 </Box>
143 </Box>
144 ),
145 )}
146 </Box>
147 )
148}
149hooks/format.ts 432 lines1// Pure formatting for the band: its groups, their widths and colours, and the desktop's Svg graphics.
2import type { OverheadAgent, OverheadCache, OverheadCtx, OverheadLimit, OverheadTheme } from '../types'
3import { activityBody, activityGlyphs, activityOverflow, activityWidth } from './activity'
4
5// The two cache lifetimes Claude Code uses.
6export const CACHE_TTL_MS = 60 * 60 * 1000
7export const SHORT_TTL_MS = 5 * 60 * 1000
8// The cache lifetime in use: what the session has shown (`seen`, from a model switch, a resume or the request
9// traffic), else Claude Code's own rule for the account: a subscription inside its plan usage gets an hour, usage
10// credits or an API key five minutes. A subscription is told by the plan windows (`five_hour`, `seven_day`).
11export function cacheLifetime(seen: number | null, limits: OverheadLimit[]): number {
12 if (seen !== null) return seen
13 const plan = limits.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
14 return plan.length > 0 && plan.every(l => l.percentUsed < 100) ? CACHE_TTL_MS : SHORT_TTL_MS
15}
16// The warm cache's colour for `left` of a `ttl` lifetime: the share of it gone, on the percentage scale, as a
17// quota's share used is. Sky while fresh, one tier per 10% gone from 30%, red in its last tenth.
18export function cacheTier(left: number, ttl: number): number {
19 return pctTier(((ttl - left) * 100) / ttl)
20}
21// Context totals kept for the sparkline: 8 totals, 7 bars.
22export const HISTORY = 8
23const BARS = '▁▂▃▄▅▆▇█'
24// The window growth is tiered by before any is known.
25export const FALLBACK_WINDOW = 1_000_000
26// Model families a rate-limit window may be named after.
27const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku']
28
29// `text` is what the terminal draws and what widths count; `tier` its colour on the band's one scale (`GAIN`),
30// absent for plain text (the labels). A span with `bar` (a used percentage) or `spark` (the gains, with their
31// `tiers`) is a graphic: block glyphs line up only in a
32// monospace font, so the desktop, which draws the band in a proportional one, draws those as an Svg instead
33// (`items`).
34export type Span = {
35 text: string
36 tier?: number
37 dimColor?: boolean
38 bar?: number
39 forecast?: number
40 barLabel?: string
41 spark?: number[]
42 sparkLabel?: 'input'
43 tiers?: number[]
44 money?: boolean
45 agentCount?: number
46 fold?: 'growth' | 'tokens' | 'reset' | 'rewrite' | 'turn-cost'
47}
48
49type Ink = { dark: string; light: string }
50
51// An Svg is drawn as an image, with no theme: each colour has a dark-card and a light-card value, picked by
52// the image's own media query. Text uses the host's configured theme.
53const DIM: Ink = { dark: '#898781', light: '#6f6d68' }
54const MONEY: Ink = { dark: '#dfbc70', light: '#8a6215' }
55const TRACK = 'rgba(137,135,129,0.3)'
56
57// The band's one colour scale, safe to warning: cool for safe (indigo, blue, sky, cyan, teal), caution
58// through lime to yellow, warm to a deep red for warning. The sparkline takes a tier by its gain's share of the
59// window (`gainTier`), quota, context and the cache's lifetime by the share used (`pctTier`).
60// Cool against warm, not green against red, so a red-green colour-blind reader still tells safe from warning
61// (worst ΔE 17 between the two ends).
62const GAIN: Ink[] = [
63 { dark: '#5965cd', light: '#4c55bc' },
64 { dark: '#4087de', light: '#266ec3' },
65 { dark: '#37aae3', light: '#0076a8' },
66 { dark: '#35c5db', light: '#007a8b' },
67 { dark: '#49d6cc', light: '#007c74' },
68 { dark: '#b8e45c', light: '#567a00' },
69 { dark: '#f9e149', light: '#856d00' },
70 { dark: '#fea92f', light: '#a05f00' },
71 { dark: '#fd7933', light: '#bc4c00' },
72 { dark: '#ed4b43', light: '#bb0916' },
73]
74
75// A gain's tier: 0 below 0.1% of the window, then one more for each doubling (0.2%, 0.4%, … 25.6% and over),
76// since one change adds anywhere from a few hundred tokens to a quarter of the window: 1k, 2k, 4k … 256k of
77// a 1M window, 200 … 51k of a 200k one. Integer comparisons, so a gain on a boundary lands on its own tier.
78export function gainTier(gain: number, window: number): number {
79 let t = 0
80 while (t < GAIN.length - 1 && gain * 1000 >= window * 2 ** t) t++
81 return t
82}
83
84// A used percentage's tier: one per 10%, from tier 2, since the two quietest tiers are dimmer than the band's
85// dim text and too faint for a figure. 0–29% sky, 30s cyan, 40s teal (safe); 50s lime, 60s yellow (caution);
86// 70s amber, 80s orange, 90% and over red (warning).
87export function pctTier(p: number): number {
88 return Math.min(Math.max(Math.floor(p / 10), 2), GAIN.length - 1)
89}
90
91// A span's text colour: its tier's dark-card value, none for plain text.
92export function colorOf(s: Span, theme: OverheadTheme = 'dark'): string | undefined {
93 if (s.money) return theme === 'native' ? 'warning' : MONEY[theme]
94 if (s.tier === undefined) return undefined
95 if (theme === 'native') return s.tier < 5 ? 'permission' : s.tier < 8 ? 'warning' : 'error'
96 return GAIN[s.tier]?.[theme]
97}
98
99export type Cell = { text: string; color?: string; dimColor?: boolean }
100
101// A span the terminal draws in more than one colour, piece by piece: the sparkline, one glyph per gain in its
102// tier's colour. Undefined for a span of one colour.
103export function cells(s: Span, theme: OverheadTheme = 'dark'): Cell[] | undefined {
104 const glyphs = [...s.text]
105 if (s.forecast !== undefined) return glyphs.map(text => text === '■' ? { text, color: colorOf(s, theme) } : { text, dimColor: true })
106 if (s.spark) return s.spark.map((_, i) => ({ text: glyphs[i] ?? '', color: colorOf({ text: '', tier: s.tiers?.[i] ?? 0 }, theme) }))
107 return undefined
108}
109
110export type Graphic = { source: string; alt: string; width: number; height: number; isInteractive?: boolean }
111
112// The Svg's colours as classes: each its dark-card fill, and its light-card one under the media query.
113function inks(classes: [name: string, ink: Ink][]): string {
114 const rules = (mode: keyof Ink) => classes.map(([name, ink]) => `.${name}{fill:${ink[mode]}}`).join('')
115 return `<style>${rules('dark')}@media (prefers-color-scheme: light){${rules('light')}}</style>`
116}
117
118function svg(width: number, height: number, style: string, body: string): string {
119 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${style}${body}</svg>`
120}
121
122// A graphic span as an Svg: the bar a 60×6 rounded track filled to the exact percentage; the sparkline one 4 px column per gain on a shared baseline,
123// 14 px at the largest and 3 px at the least so the smallest still shows its colour, each in its tier's
124// colour.
125export function svgOf(s: Span): Graphic | undefined {
126 if (s.agentCount !== undefined) return {
127 source: svg(activityWidth(s.agentCount), 14, inks([['k', GAIN[5]!]]), activityBody(s.agentCount)),
128 alt: `${s.agentCount} running agents`, width: activityWidth(s.agentCount), height: 14, isInteractive: true,
129 }
130 if (s.bar !== undefined) {
131 const ink = s.dimColor ? DIM : (GAIN[s.tier ?? -1] ?? DIM)
132 const w = Math.round((Math.min(Math.max(s.bar, 0), 100) * 60) / 100)
133 const track = `<rect x="0" y="0" width="60" height="6" rx="3" fill="${TRACK}"/>`
134 const done = w > 0 ? `<rect class="k" x="0" y="0" width="${w}" height="6" rx="3"/>` : ''
135 const projected = s.forecast === undefined ? w : Math.round(Math.min(Math.max(s.forecast, s.bar), 100) * 0.6)
136 const future = projected > w ? `<rect class="f" x="${w}" y="0" width="${projected - w}" height="6" opacity="0.4"/>` : ''
137 return {
138 source: svg(60, 6, inks([['k', ink], ['f', DIM]]), track + future + done),
139 alt: `${s.barLabel ?? 'context'} ${s.bar}% used${s.forecast === undefined ? '' : `; approximately ${Math.round(s.forecast)}% by reset`}`,
140 width: 60, height: 6,
141 }
142 }
143 if (s.spark && s.spark.length > 0) {
144 const top = Math.max(...s.spark, 1)
145 const width = s.spark.length * 6 - 2
146 const cols = s.spark
147 .map((v, i) => {
148 const h = Math.max(Math.round((v * 14) / top), 3)
149 return `<rect class="t${s.tiers?.[i] ?? 0}" x="${i * 6}" y="${14 - h}" width="4" height="${h}" rx="1"/>`
150 })
151 .join('')
152 return {
153 source: svg(width, 14, inks(GAIN.map((ink, t) => [`t${t}`, ink])), cols),
154 alt: `${s.sparkLabel === 'input' ? 'input increase' : 'context added'} in each of the last ${s.spark.length} changes`,
155 width,
156 height: 14,
157 }
158 }
159 return undefined
160}
161
162// Countdown: 3d4h, 2h10m, 41m (floored, never negative).
163export function dur(ms: number): string {
164 const s = Math.max(Math.floor(ms / 1000), 0)
165 if (s >= 86_400) return `${Math.floor(s / 86_400)}d${Math.floor((s % 86_400) / 3_600)}h`
166 if (s >= 3_600) return `${Math.floor(s / 3_600)}h${Math.floor((s % 3_600) / 60)}m`
167 return `${Math.floor(s / 60)}m`
168}
169
170// Whole k/M, floored: 443k, 1M.
171export function ktok(t: number): string {
172 if (t >= 1_000_000) return `${Math.floor(t / 1_000_000)}M`
173 if (t >= 1_000) return `${Math.floor(t / 1_000)}k`
174 return String(t)
175}
176
177// One-decimal k/M for deltas: 12.3k, 1.2M, 41k.
178export function kshort(t: number): string {
179 let x: number
180 let u: string
181 if (t >= 1_000_000) {
182 x = Math.floor((t + 50_000) / 100_000)
183 u = 'M'
184 } else if (t >= 1_000) {
185 x = Math.floor((t + 50) / 100)
186 u = 'k'
187 } else return String(t)
188 return x % 10 === 0 ? `${x / 10}${u}` : `${Math.floor(x / 10)}.${x % 10}${u}`
189}
190
191// 10 cells, rounded to the nearest tenth: ■■■□□□□□□□.
192export function bar(p: number): string {
193 const filled = Math.max(Math.min(Math.floor((p + 5) / 10), 10), 0)
194 return '■'.repeat(filled) + '□'.repeat(10 - filled)
195}
196
197// Same quota scale: solid is used, shaded is projected additional use, hollow is unfilled.
198export function forecastBar(p: number, projected: number): string {
199 const used = Math.max(0, Math.min(10, Math.round(p / 10)))
200 const end = Math.max(used, Math.min(10, Math.round(projected / 10)))
201 return '■'.repeat(used) + '▧'.repeat(end - used) + '□'.repeat(10 - end)
202}
203
204export function gains(history: number[]): number[] {
205 return history.slice(1).map((t, i) => Math.max(t - (history[i] ?? t), 0))
206}
207
208export function sparkline(values: number[]): string {
209 const top = Math.max(...values, 1)
210 return values.map(v => BARS[Math.floor((v * 7) / top)]).join('')
211}
212
213// The family a model id belongs to (`claude-fable-5-1` → `fable`), if it names one.
214export function modelFamily(model: string | null): string | undefined {
215 const id = (model ?? '').toLowerCase()
216 return FAMILIES.find(f => id.includes(f))
217}
218
219// The weekly window to show: the main model's own when Claude Code reports one, else the all-models week.
220// Not verified: no model's own window has reached a plugin yet, so its kind is matched, not known. A kind
221// naming the family counts; for Fable so does a `scoped` one, since Anthropic's usage data calls a
222// model's own weekly window `weekly_scoped` (the model in a separate scope a rate limit here does not
223// carry) and the one seen was Fable's.
224export function weeklyWindow(limits: OverheadLimit[], model: string | null): { limit?: OverheadLimit; label: string } {
225 const family = modelFamily(model)
226 const others = limits.filter(l => l.kind !== 'seven_day')
227 const own =
228 family === undefined
229 ? undefined
230 : (others.find(l => l.kind.toLowerCase().includes(family)) ??
231 (family === 'fable' ? others.find(l => l.kind.toLowerCase().includes('scoped')) : undefined))
232 return own ? { limit: own, label: `7d ${family}` } : { limit: limits.find(l => l.kind === 'seven_day'), label: '7d' }
233}
234
235// The subagent whose transcript is on screen, when one is: its figures (absent before its first request)
236// and its window, known only when it runs the main loop's model.
237export type AgentView = { agent?: OverheadAgent; window?: number }
238
239export type BandInput = {
240 now: number
241 ctx: OverheadCtx | null
242 history: number[]
243 limits: OverheadLimit[]
244 limitsLive: boolean
245 cache: OverheadCache | null
246 cacheTtl: number | null
247 cost?: number | null
248 turnCost?: number | null
249 activeAgents?: number
250 // Tokens the main conversation's latest rewrite wrote to the cache instead of reading them, until a later
251 // turn reads the cache.
252 rewrite?: number
253 model: string | null
254 view?: AgentView
255}
256
257// The growth sparkline and the latest gain, each bar in its gain's tier of `window`.
258function growth(history: number[], window: number, sparkLabel?: Span['sparkLabel']): Span[] {
259 const gs = gains(history)
260 return [
261 { text: ' ', fold: 'growth' },
262 { text: sparkline(gs), spark: gs, sparkLabel, tiers: gs.map(v => gainTier(v, window)), fold: 'growth' },
263 { text: ` ↑${kshort(gs.at(-1) ?? 0)}`, dimColor: true, fold: 'growth' },
264 ]
265}
266
267// The main conversation's context: bar, percentage, tokens and growth.
268function contextGroup(b: BandInput, ctx: OverheadCtx): Span[] {
269 const { tokens, window, estimate } = ctx
270 if (tokens !== undefined && tokens > 0) {
271 const p = Math.trunc(ctx.percent ?? (tokens * 100) / window)
272 const g: Span[] = [
273 { text: 'ctx' },
274 { text: ' ' },
275 { text: bar(p), tier: pctTier(p), bar: p },
276 { text: ` ${p}%`, tier: pctTier(p) },
277 ]
278 g.push({ text: ` ${ktok(tokens)}/${ktok(window)}`, dimColor: true, fold: 'tokens' })
279 if (b.history.length >= 2) g.push(...growth(b.history, window))
280 return g
281 }
282 if (estimate !== undefined && estimate > 0) {
283 // Before the window's first response: /context's estimate, dim and marked ~.
284 const p = Math.trunc((estimate * 100) / window)
285 const g: Span[] = [
286 { text: 'ctx' },
287 { text: ' ' },
288 { text: bar(p), dimColor: true, bar: p },
289 { text: ` ~${p}%`, dimColor: true },
290 ]
291 g.push({ text: ` ~${ktok(estimate)}/${ktok(window)}`, dimColor: true, fold: 'tokens' })
292 return g
293 }
294 // Neither a response nor an estimate yet.
295 return [{ text: 'ctx' }, { text: ` -- /${ktok(window)}`, dimColor: true }]
296}
297
298// A subagent's context while its transcript is on screen: its last request's input total, against its
299// window when that is known, else the tokens alone; its growth bars by the main window's tiers.
300function agentGroup(b: BandInput, view: AgentView): Span[] {
301 const totals = view.agent?.totals ?? []
302 const tokens = totals.at(-1)
303 if (tokens === undefined) return [{ text: 'agent' }, { text: ' --', dimColor: true }]
304 const g: Span[] = [{ text: 'agent' }]
305 if (view.window) {
306 const p = Math.trunc((tokens * 100) / view.window)
307 g.push({ text: ' ' }, { text: bar(p), tier: pctTier(p), bar: p }, { text: ` ${p}%`, tier: pctTier(p) })
308 g.push({ text: ` ${ktok(tokens)}/${ktok(view.window)}`, dimColor: true, fold: 'tokens' })
309 } else {
310 g.push({ text: ` ${ktok(tokens)}` })
311 }
312 if (totals.length >= 2) g.push(...growth(totals, view.window ?? b.ctx?.window ?? FALLBACK_WINDOW, 'input'))
313 return g
314}
315
316// Warm and its minutes left in one colour (`cacheTier`), or cold; then the latest request that rewrote
317// the cache instead of reading it, kept until a later turn reads the cache, tiered like a growth bar by its
318// share of the window.
319function cacheGroup(b: BandInput, cache: OverheadCache): Span[] {
320 const g: Span[] = [{ text: 'cache' }]
321 g.push(cacheStatus(cache, cacheLifetime(b.cacheTtl, b.limits), b.now))
322 if (b.rewrite) {
323 const text = ` rewrote ${kshort(b.rewrite)}`
324 g.push({ ...(b.ctx?.window ? { text, tier: gainTier(b.rewrite, b.ctx.window) } : { text, dimColor: true }), fold: 'rewrite' })
325 }
326 return g
327}
328
329// The band's groups, each a run of spans; drawn with a dim " | " between groups. The conversation's own state
330// first (context and its growth, then the cache, which every request renews), then the account's quota,
331// which moves slowest. Narrowing follows this visual order, from right to left.
332export function groups(b: BandInput): Span[][] {
333 const out: Span[][] = []
334
335 if (b.view) out.push(agentGroup(b, b.view))
336 else if (b.ctx && b.ctx.window > 0) out.push(contextGroup(b, b.ctx))
337 if (b.cache) out.push(cacheGroup(b, b.cache))
338
339 const weekly = weeklyWindow(b.limits, b.model)
340 const windows: [l: OverheadLimit | undefined, label: string][] = [
341 [b.limits.find(x => x.kind === 'five_hour'), '5h'],
342 [weekly.limit, weekly.label],
343 // A Claude gateway's spend limit: it may carry no reset, and goes past 100% once exceeded.
344 [b.limits.find(x => x.kind === 'spend_limit'), 'spend'],
345 ]
346 for (const [l, label] of windows) {
347 if (!l) continue
348 const resets = l.resetsAt === undefined ? undefined : Date.parse(l.resetsAt)
349 // A window whose reset has passed is dropped; only a spend limit may have none.
350 if (resets === undefined ? label !== 'spend' : resets <= b.now) continue
351 const p = Math.trunc(l.percentUsed)
352 const reset = resets !== undefined ? ` ↻${dur(resets - b.now)}` : ''
353 out.push([
354 { text: label },
355 { text: ` ${p}%`, ...(b.limitsLive ? { tier: pctTier(p) } : { dimColor: true }) },
356 ...(reset ? [{ text: reset, dimColor: true, fold: 'reset' as const }] : []),
357 ])
358 }
359
360 if ((b.activeAgents ?? 0) > 0) out.push([
361 { text: 'agent' }, { text: ' ' },
362 { text: activityGlyphs(b.activeAgents!) + activityOverflow(b.activeAgents!), tier: 5, agentCount: b.activeAgents },
363 ])
364 if (validCost(b.cost)) out.push([
365 { text: 'cost' }, { text: ` ≈${usd(b.cost)}`, money: true },
366 ...(validCost(b.turnCost) ? [{ text: ` (+${usd(b.turnCost)})`, dimColor: true, fold: 'turn-cost' as const }] : []),
367 ])
368
369 return out
370}
371
372export function validCost(cost: number | null | undefined): cost is number {
373 return cost !== null && cost !== undefined && Number.isFinite(cost) && cost >= 0
374}
375
376export function usd(cost: number): string {
377 return `$${cost.toFixed(2)}`
378}
379
380export function cacheStatus(cache: OverheadCache, ttl: number, now: number): Span {
381 if (!cache.warm) return { text: ' cold', dimColor: true }
382 const left = cache.at + ttl - now
383 return left > 0 ? { text: ` warm ${dur(left)}`, tier: cacheTier(left, ttl) } : { text: ' cold', dimColor: true }
384}
385
386export const SEP = ' | '
387
388export function width(gs: Span[][]): number {
389 const cells = gs.reduce((n, g) => n + g.reduce((m, s) => m + [...s.text].length, 0), 0)
390 return cells + SEP.length * Math.max(gs.length - 1, 0)
391}
392
393// Each level removes only the rightmost group's trailing detail, then that group. Never skip leftward
394// over a group that is still visible. The leftmost group's core is the final, truncatable level.
395export function bandVariants(b: BandInput): Span[][][] {
396 let gs = groups(b)
397 const levels = [gs]
398 while (gs.length > 0) {
399 const last = gs.at(-1)!
400 const fold = last.at(-1)?.fold
401 if (fold) gs = [...gs.slice(0, -1), last.filter(s => s.fold !== fold)]
402 else if (gs.length > 1) gs = gs.slice(0, -1)
403 else break
404 levels.push(gs)
405 }
406 return levels
407}
408
409// The fullest band that fits `columns`; the last level is drawn truncated if even it does not.
410export function fit(b: BandInput, columns: number): Span[][] {
411 const levels = bandVariants(b)
412 return levels.find(gs => width(gs) <= columns) ?? levels.at(-1)!
413}
414
415export type Item = { kind: 'text'; span: Span } | { kind: 'graphic'; graphic: Graphic; suffix?: Span }
416
417// One group as the desktop draws it: a row of items spaced by the Box's gap, not by spaces, which a
418// proportional font draws narrow; the graphic spans become an Svg, the spaces-only ones go.
419export function items(g: Span[]): Item[] {
420 return g.flatMap((s): Item[] => {
421 const graphic = svgOf(s)
422 if (graphic) return [{
423 kind: 'graphic', graphic,
424 ...(s.agentCount !== undefined && activityOverflow(s.agentCount) ? {
425 suffix: { text: activityOverflow(s.agentCount), tier: s.tier },
426 } : {}),
427 }]
428 const text = s.text.trim()
429 return text ? [{ kind: 'text', span: { ...s, text } }] : []
430 })
431}
432hooks/pane.ts 207 lines1// Pure formatting for the /ccoverhead pane: the detail the band has no room for, as headed sections of lines.
2// Each line is a label column and a run of spans, drawn like the band's (block glyphs on the terminal, Svg on
3// the surfaces with a proportional font).
4import type { OverheadAgent, OverheadBreakdown, OverheadCacheStats, OverheadCompaction, OverheadEffort } from '../types'
5import type { BandInput, Span } from './format'
6import { FALLBACK_WINDOW, bar, cacheLifetime, cacheStatus, dur, forecastBar, gainTier, gains, kshort, ktok, pctTier, sparkline, usd, validCost, weeklyWindow } from './format'
7import { AGENTS, hitRate, quotaForecast } from './track'
8
9export type PaneInput = BandInput & {
10 timeline: number[]
11 compactions: OverheadCompaction[]
12 cacheStats: OverheadCacheStats
13 agents: OverheadAgent[]
14 breakdown: OverheadBreakdown | null
15 // The agent whose transcript is on screen, if any.
16 viewing?: string
17 limitsAt?: number | null
18 sessionId?: string | null
19 effort?: OverheadEffort | null
20}
21
22// `end`: the label is a figure, right-aligned in its column; `nested`: the line belongs to the one above.
23export type PaneLine = { head: string } | { label: Span; spans: Span[]; end?: boolean; nested?: boolean; wrap?: boolean; gapBefore?: boolean }
24
25// Cells the label column takes on the terminal.
26export const LABEL = 12
27// A label cut to the column, leaving a cell before the figures.
28const clip = (name: string) => (name.length > LABEL - 1 ? `${name.slice(0, LABEL - 2)}…` : name)
29// MCP servers listed under the breakdown, the costliest first.
30const SERVERS = 5
31const line = (label: string | Span, ...spans: Span[]): PaneLine => ({ label: typeof label === 'string' ? { text: label } : label, spans })
32const figure = (label: Span, ...spans: Span[]): PaneLine => ({ label, spans, end: true })
33const dim = (text: string): Span => ({ text, dimColor: true })
34const pad = (text: string, n: number) => text.padStart(n)
35
36export function paneLines(p: PaneInput): PaneLine[] {
37 return [...context(p), ...breakdown(p), ...growth(p), ...cache(p), ...quota(p), ...cost(p), ...agents(p)]
38}
39
40function context(p: PaneInput): PaneLine[] {
41 const out: PaneLine[] = [{ head: 'Context' }]
42 const ctx = p.ctx
43 if (!ctx || ctx.window <= 0) return [...out, line('window', dim(' no reading yet'))]
44 const { tokens, window, estimate, compactAt } = ctx
45 const now = tokens !== undefined && tokens > 0 ? tokens : undefined
46 if (now !== undefined) {
47 const pc = Math.trunc(ctx.percent ?? (now * 100) / window)
48 out.push(line('window', { text: ' ' }, { text: bar(pc), tier: pctTier(pc), bar: pc }, { text: ` ${pc}%`, tier: pctTier(pc) }, dim(` ${ktok(now)} of ${ktok(window)}`)))
49 } else if (estimate !== undefined && estimate > 0) {
50 const pc = Math.trunc((estimate * 100) / window)
51 out.push(line('window', { text: ' ' }, { text: bar(pc), dimColor: true, bar: pc }, dim(` ~${pc}% ~${ktok(estimate)} of ${ktok(window)}, estimated before the first response`)))
52 } else {
53 out.push(line('window', dim(` -- of ${ktok(window)}`)))
54 }
55 // Where auto-compaction runs and the tokens to go; a threshold at or past the window's end is never reached.
56 if (compactAt !== undefined && compactAt > 0 && compactAt < window) {
57 const used = now ?? estimate
58 const left = used === undefined ? undefined : Math.max(compactAt - used, 0)
59 out.push(
60 line(
61 'compacts',
62 dim(` at ${kshort(compactAt)}`),
63 ...(left === undefined ? [] : [{ text: ` · ${kshort(left)} to go`, tier: pctTier(((used ?? 0) * 100) / compactAt) }]),
64 ),
65 )
66 } else if (p.breakdown && !p.breakdown.autoCompact) {
67 out.push(line('compacts', dim(' never: auto-compaction is off')))
68 }
69 if (p.model) out.push(line('model', dim(` ${p.model}`), ...(p.effort != null ? [dim(` · effort ${p.effort} (requested)`)] : [])))
70 if (p.sessionId) out.push({ ...line('session ID', dim(` ${p.sessionId}`)), wrap: true })
71 return out
72}
73
74// /context's local estimate by category, the largest first, with each one's share of what is in use.
75function breakdown(p: PaneInput): PaneLine[] {
76 const b = p.breakdown
77 if (!b || b.rows.length === 0) return []
78 const window = p.ctx?.window ?? FALLBACK_WINDOW
79 const used = b.rows.reduce((n, r) => n + r.tokens, 0)
80 const out: PaneLine[] = [{ head: 'In the window, as /context estimates it' }]
81 for (const r of [...b.rows].sort((x, y) => y.tokens - x.tokens)) {
82 out.push(figure({ text: pad(kshort(r.tokens), LABEL - 2), tier: pctTier((r.tokens * 100) / window) }, dim(` ${pad(`${Math.round((r.tokens * 100) / Math.max(used, 1))}%`, 4)}`), { text: ` ${r.name}` }))
83 if (r.name.startsWith('MCP tools')) {
84 for (const s of [...b.mcp].sort((x, y) => y.tokens - x.tokens).slice(0, SERVERS)) {
85 out.push({ ...figure(dim(pad(kshort(s.tokens), LABEL - 2)), dim(` ${s.server}`)), nested: true })
86 }
87 if (b.mcp.length > SERVERS) out.push({ ...line('', dim(` and ${b.mcp.length - SERVERS} more servers`)), nested: true })
88 }
89 }
90 if (b.deferred > 0) out.push(figure(dim(pad(kshort(b.deferred), LABEL - 2)), dim(' deferred tool schemas, not in the window')))
91 return out
92}
93
94// The conversation's growth since its last compaction, and its last compactions as the sizes before and after.
95function growth(p: PaneInput): PaneLine[] {
96 const window = p.ctx?.window ?? FALLBACK_WINDOW
97 const out: PaneLine[] = [{ head: 'Growth' }]
98 const gs = gains(p.timeline)
99 if (gs.length === 0) out.push(line('changes', dim(' none yet')))
100 else {
101 const top = Math.max(...gs)
102 const mean = gs.reduce((n, g) => n + g, 0) / gs.length
103 out.push(line(`last ${gs.length}`, { text: ' ' }, { text: sparkline(gs), spark: gs, tiers: gs.map(v => gainTier(v, window)) }, dim(` ↑${kshort(gs.at(-1) ?? 0)}`)))
104 out.push(line('largest', { text: ` ↑${kshort(top)}`, tier: gainTier(top, window) }, dim(` · average ↑${kshort(Math.round(mean))}`)))
105 }
106 for (const c of p.compactions) {
107 const size = (t: number | undefined) => (t === undefined ? '?' : kshort(t))
108 out.push(line('compacted', dim(` ${size(c.before)} → ${size(c.after)}`)))
109 }
110 return out
111}
112
113function cache(p: PaneInput): PaneLine[] {
114 const out: PaneLine[] = [{ head: 'Tokens & cache, main conversation' }]
115 const c = p.cache
116 if (!c) out.push(line('state', dim(' no request yet')))
117 else {
118 const ttl = cacheLifetime(p.cacheTtl, p.limits)
119 const status = cacheStatus(c, ttl, p.now)
120 out.push(line('state', status, ...(status.text.startsWith(' warm') ? [dim(` left of ${dur(ttl)}`)] : [])))
121 }
122 const s = p.cacheStats
123 const input = s.input + s.read + s.write
124 if (s.output !== undefined && (input > 0 || s.output > 0)) {
125 out.push(line('tokens', { text: ` ${kshort(input)} in · ${kshort(s.output)} out` }))
126 out.push(line('', dim(' Observed requests only; input includes cache')))
127 }
128 const hit = hitRate(s)
129 if (hit !== undefined) {
130 out.push(line('hit rate', { text: ` ${Math.trunc(hit)}%`, tier: pctTier(100 - hit) }, dim(` · read ${kshort(s.read)} · written ${kshort(s.write)} · uncached ${kshort(s.input)}`)))
131 }
132 if (p.rewrite) out.push(line('rewrote', { text: ` ${kshort(p.rewrite)}`, tier: gainTier(p.rewrite, p.ctx?.window ?? FALLBACK_WINDOW) }, dim(' latest rewrite, until a later turn reads the cache')))
133 return out
134}
135
136// Every reported window, with a labeled window-average projection only for fresh live readings.
137function quota(p: PaneInput): PaneLine[] {
138 const out: PaneLine[] = [{ head: p.limitsLive ? 'Quota' : 'Quota, as an earlier session last saw it' }]
139 const weekly = weeklyWindow(p.limits, p.model)
140 const shown = p.limits.filter(l => l.resetsAt === undefined || Date.parse(l.resetsAt) > p.now)
141 if (shown.length === 0) return [...out, line('windows', dim(' none reported'))]
142 for (const l of shown) {
143 const pc = Math.trunc(l.percentUsed)
144 const label = clip(
145 l.kind === 'five_hour' ? '5h' : l === weekly.limit ? weekly.label : l.kind === 'seven_day' ? '7d' : l.kind === 'spend_limit' ? 'spend' : l.kind,
146 )
147 const resets = l.resetsAt === undefined ? undefined : Date.parse(l.resetsAt)
148 const forecast = p.limitsLive ? quotaForecast(l, p.limitsAt, p.now) : undefined
149 const ink = (s: Span): Span => (p.limitsLive ? s : { text: s.text, bar: s.bar, dimColor: true })
150 out.push(
151 line(
152 label,
153 { text: ' ' },
154 ink({ text: forecast ? forecastBar(pc, forecast.percentAtReset) : bar(pc), tier: pctTier(pc), bar: Math.min(pc, 100), barLabel: 'quota', forecast: forecast?.percentAtReset }),
155 ink({ text: ` ${pc}%`, tier: pctTier(pc) }),
156 ...(resets === undefined ? [] : [dim(` ↻${dur(resets - p.now)}`)]),
157 ),
158 )
159 if (forecast !== undefined && resets !== undefined) {
160 out[0] = { head: 'Quota, shaded = window-average projection' }
161 const runway = forecast.exhaustsAt < resets ? `limit in ≈${dur(forecast.exhaustsAt - p.now)}` : 'reset comes first'
162 out.push(line('', dim(` ≈${Math.round(forecast.percentAtReset)}% by reset · ${runway}`)))
163 }
164 }
165 return out
166}
167
168function cost(p: PaneInput): PaneLine[] {
169 if (!validCost(p.cost)) return []
170 return [
171 { head: 'Cost, API-price reference' },
172 line('session', { text: ` ≈${usd(p.cost)}`, money: true }),
173 ...(validCost(p.turnCost) ? [line('last turn', dim(` +${usd(p.turnCost)}`))] : []),
174 line('', dim(' API-price reference, not a billing receipt')),
175 ]
176}
177
178// Latest model/effort and last input are separate from cumulative usage. Input includes cache reads/writes;
179// the cache-read figure is a subset, not another amount to add. No missing effort is inferred.
180function agents(p: PaneInput): PaneLine[] {
181 if (p.agents.length === 0) return []
182 const window = p.ctx?.window ?? FALLBACK_WINDOW
183 const out: PaneLine[] = [{ head: p.agents.length < AGENTS ? 'Subagents' : `Subagents, the last ${AGENTS} active` }]
184 for (const [index, a] of p.agents.entries()) {
185 const gs = gains(a.totals)
186 const name = a.label ?? 'agent'
187 out.push(
188 { ...line(clip(name), ...(a.description?.trim() ? [{ text: ` ${a.description.replace(/\s+/g, ' ').trim()}` }] : [])), wrap: true, gapBefore: index > 0 },
189 { ...line('agent ID', dim(` ${a.id}`)), wrap: true },
190 line(
191 'model',
192 { text: ` ${a.model}` },
193 ...(a.effort !== undefined ? [dim(` · effort ${a.effort} (requested)`)] : []),
194 ),
195 line(
196 'last input',
197 { text: ` ${ktok(a.totals.at(-1) ?? 0)}` },
198 ...(gs.length > 0 ? [{ text: ' ' }, { text: sparkline(gs), spark: gs, sparkLabel: 'input' as const, tiers: gs.map(v => gainTier(v, window)) }] : []),
199 ...(a.id === p.viewing ? [dim(' · on screen')] : []),
200 ),
201 ...(a.usage ? [line('tokens', { text: ` ${kshort(a.usage.input)} in · ${kshort(a.usage.output)} out` }, dim(` · ${kshort(a.usage.read)} cache read`))] : []),
202 )
203 }
204 out.push(line('', dim(' Observed requests only; input includes cache')))
205 return out
206}
207hooks/track.ts 121 lines1// Pure updates of the plugin's recorded figures: the context totals, the cache's running counts and each
2// subagent's context. The event hooks in register.tsx apply them to `$.state`.
3import type { OverheadAgent, OverheadCache, OverheadCacheStats, OverheadCompaction, OverheadLimit } from '../types'
4import { CACHE_TTL_MS, HISTORY, SHORT_TTL_MS } from './format'
5
6// Totals kept for the pane's growth chart.
7export const TIMELINE = 48
8// Compactions kept for the pane.
9export const COMPACTIONS = 3
10// Subagents kept, the most recently active last.
11export const AGENTS = 8
12
13// A new total: append when it changed, restart on a drop (compaction), keep the last `keep` (HISTORY for the
14// band, TIMELINE for the pane).
15export function addSample(history: number[], tokens: number, keep = HISTORY): number[] {
16 const last = history.at(-1)
17 if (last === tokens) return history
18 if (last !== undefined && tokens < last) return [tokens]
19 return [...history, tokens].slice(-keep)
20}
21
22// A compaction appended to the last few.
23export function addCompaction(list: OverheadCompaction[], c: OverheadCompaction): OverheadCompaction[] {
24 return [...list, c].slice(-COMPACTIONS)
25}
26
27export type StepUsage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
28
29export const NO_CACHE_STATS: OverheadCacheStats = { input: 0, output: 0, read: 0, write: 0, last: 0 }
30
31// One main-conversation request added to the running counts. It rewrote the cache when it read back less than
32// half of what the request before it sent (the cache had lapsed, the model changed, the prefix changed); a
33// large new tool result is written beside a full read and does not count, nor does the first request after a
34// compaction (`last` 0, see `compacted`). The mark stays until a request of a later turn reads the cache.
35export function addStep(stats: OverheadCacheStats, u: StepUsage, turnId: string): OverheadCacheStats {
36 const input = u.input_tokens ?? 0
37 const read = u.cache_read_input_tokens ?? 0
38 const write = u.cache_creation_input_tokens ?? 0
39 const next: OverheadCacheStats = {
40 input: stats.input + input,
41 output: (stats.output ?? 0) + u.output_tokens,
42 read: stats.read + read,
43 write: stats.write + write,
44 last: input + read + write,
45 }
46 if (stats.last > 0 && write > 0 && read * 2 < stats.last) next.rewrite = { tokens: write, turnId }
47 else if (stats.rewrite && (stats.rewrite.turnId === turnId || read === 0)) next.rewrite = stats.rewrite
48 return next
49}
50
51// The lifetime a main-conversation request shows, given the cache's state before it and the counts before it
52// (`stats`), or undefined when it shows none. A request that read the cache back more than five minutes after
53// the previous one proves the hour; one that rewrote it in between, with the prompt no smaller, says five
54// minutes. Only the model that answered the previous request counts: a switch leaves the cache cold at once.
55export function learnedTtl(prev: OverheadCache | null, started: number, stats: OverheadCacheStats, u: StepUsage): number | undefined {
56 if (!prev || stats.last <= 0) return undefined
57 const gap = started - prev.at
58 if (gap <= SHORT_TTL_MS) return undefined
59 const read = u.cache_read_input_tokens ?? 0
60 const write = u.cache_creation_input_tokens ?? 0
61 const rewrote = write > 0 && read * 2 < stats.last
62 if (!rewrote && read > 0) return CACHE_TTL_MS
63 if (rewrote && gap < CACHE_TTL_MS && (u.input_tokens ?? 0) + read + write >= stats.last) return SHORT_TTL_MS
64 return undefined
65}
66
67// The counts without the rewrite mark.
68export function clearRewrite(stats: OverheadCacheStats): OverheadCacheStats {
69 const { rewrite: _, ...rest } = stats
70 return rest
71}
72
73// The counts after a compaction, before its first request: no rewrite mark, and no previous request to read
74// back, since the next one writes a new conversation rather than finding a lapsed cache.
75export function compacted(stats: OverheadCacheStats): OverheadCacheStats {
76 return { ...clearRewrite(stats), last: 0 }
77}
78
79// The share of the main conversation's input the cache served, 0 to 100; undefined before any input.
80export function hitRate(stats: OverheadCacheStats): number | undefined {
81 const all = stats.input + stats.read + stats.write
82 return all > 0 ? (stats.read * 100) / all : undefined
83}
84
85// A subagent's observed request. Usage adds every response, including repeated input and drops in context;
86// only the growth history deduplicates/restarts. Move the agent to the end, dropping the oldest past AGENTS.
87export function addAgentStep(agents: OverheadAgent[], id: string, model: string, u: StepUsage, effort?: OverheadAgent['effort']): OverheadAgent[] {
88 const total = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
89 const before = agents.find(a => a.id === id)
90 const agent: OverheadAgent = {
91 id, model, totals: addSample(before?.totals ?? [], total),
92 usage: {
93 input: (before?.usage?.input ?? 0) + total,
94 output: (before?.usage?.output ?? 0) + u.output_tokens,
95 read: (before?.usage?.read ?? 0) + u.cache_read_input_tokens,
96 },
97 ...(before?.label !== undefined && { label: before.label }),
98 ...(before?.description !== undefined && { description: before.description }),
99 ...(effort !== undefined && { effort }),
100 }
101 return [...agents.filter(a => a.id !== id), agent].slice(-AGENTS)
102}
103
104// A model id without the window suffix /model may add (`claude-opus-5-5[1m]`).
105export function baseModel(id: string | null | undefined): string {
106 return (id ?? '').replace(/\[[^\]]*\]$/, '')
107}
108
109// The window-average forecast used by WeekToken: used / elapsed is the pace.
110// No history or price table. An old reading or a window just opened has no useful forecast.
111export function quotaForecast(limit: OverheadLimit, observedAt: number | null | undefined, now: number): { exhaustsAt: number; percentAtReset: number } | undefined {
112 const window = limit.kind === 'five_hour' ? 5 * 3_600_000
113 : limit.kind === 'seven_day' || limit.kind.startsWith('seven_day_') || limit.kind.includes('weekly') ? 7 * 86_400_000 : undefined
114 if (!window || !limit.resetsAt || observedAt == null || observedAt > now || now - observedAt > Math.max(15 * 60_000, window * 0.05)) return undefined
115 const reset = Date.parse(limit.resetsAt)
116 const elapsed = window - (reset - now)
117 const used = limit.percentUsed / 100
118 if (!Number.isFinite(reset) || elapsed <= Math.max(300_000, window * 0.001) || elapsed >= window || !Number.isFinite(used) || used <= 0 || used >= 1) return undefined
119 return { exhaustsAt: now + elapsed * (1 - used) / used, percentAtReset: used * window / elapsed * 100 }
120}
121hooks/activity.ts 31 lines1// Full eight-dot braille: two columns, four rows. Five lit dots move clockwise.
2// The vector version uses the same bits and timing, so both surfaces show one animation.
3const CLOCKWISE_BITS = [0, 3, 4, 5, 7, 6, 2, 1]
4const MASKS = CLOCKWISE_BITS.map((_, phase) =>
5 Array.from({ length: 5 }, (_, offset) => 1 << CLOCKWISE_BITS[(phase + offset) % 8]!).reduce((a, b) => a | b, 0),
6)
7export const ACTIVITY_FRAMES = MASKS.map(mask => String.fromCharCode(0x2800 + mask))
8export const ACTIVITY_FRAME_MS = 140
9const ACTIVITY_LIMIT = 3
10
11export const activityCount = (count: number) => Math.min(count, ACTIVITY_LIMIT)
12export const activityOverflow = (count: number) => count > ACTIVITY_LIMIT ? `+${count - ACTIVITY_LIMIT}` : ''
13export const activityWidth = (count: number) => activityCount(count) * 10 - 2
14
15export function activityGlyphs(count: number, phase = 0): string {
16 return ACTIVITY_FRAMES[phase % MASKS.length]!.repeat(activityCount(count))
17}
18
19// Script-free vector dots: each dot advances at the same step as the terminal glyph.
20export function activityBody(count: number): string {
21 const dots = [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]]
22 const draw = (animated: boolean) => Array.from({ length: activityCount(count) }, (_, i) => dots.map(([x, y], bit) => {
23 const values = MASKS.map(mask => mask & (1 << bit) ? 1 : 0.12)
24 const times = Array.from({ length: MASKS.length + 1 }, (_, i) => i / MASKS.length).join(';')
25 const motion = animated ? `<animate attributeName="opacity" values="${[...values, values[0]].join(';')}" keyTimes="${times}" calcMode="discrete" dur="${ACTIVITY_FRAME_MS * MASKS.length}ms" repeatCount="indefinite"/>` : ''
26 return `<circle cx="${2 + i * 10 + x! * 4}" cy="${1.5 + y! * 11 / 3}" r="1.15" opacity="${values[0]}">${motion}</circle>`
27 }).join('')).join('')
28 return `<style>.still{display:none}@media(prefers-reduced-motion:reduce){.moving{display:none}.still{display:inline}}</style>` +
29 `<g class="k moving">${draw(true)}</g><g class="k still">${draw(false)}</g>`
30}
31hooks/activity-client.ts 17 lines1import type { ClientModule } from 'claude-code'
2import { ACTIVITY_FRAMES, ACTIVITY_FRAME_MS, activityGlyphs } from './activity'
3
4// Only this bounded spinner row redraws, with one clock for all spinners. The host owns its lifecycle;
5// zero count or a narrower band removes the Client from the tree.
6const Activity: ClientModule<{ color: string; count: number }, number> = (props, surface) => {
7 if (surface.state === undefined) {
8 surface.setState(0)
9 surface.every(ACTIVITY_FRAME_MS, () => {
10 surface.setState(((surface.state ?? 0) + 1) % ACTIVITY_FRAMES.length)
11 })
12 }
13 return surface.elements.Text({ color: props.color, children: activityGlyphs(props.count, surface.state ?? 0) })
14}
15
16export default Activity
17types/index.d.ts 71 lines1// Context fill as of the latest response: `tokens`/`percent` absent before the first one, when
2// `estimate` holds /context's local count instead. `compactAt` is the total at which auto-compaction
3// runs, absent while it is off or unknown; `model` the main loop's model when the window was read.
4export type OverheadCtx = { tokens?: number; window: number; percent?: number; estimate?: number; compactAt?: number; model?: string }
5// One compaction of the main conversation: its size before and after, as far as Claude Code recorded them.
6export type OverheadCompaction = { before?: number; after?: number }
7export type OverheadLimit = { kind: string; percentUsed: number; resetsAt?: string }
8// Native theme names that cannot use the custom palette keep Claude Code's semantic colors.
9export type OverheadTheme = 'dark' | 'light' | 'native'
10// The main conversation's last request: when it started, and whether it touched the cache.
11export type OverheadCache = { at: number; warm: boolean }
12export type OverheadEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number
13// The main conversation's requests since it started: input tokens neither read from nor written to the cache,
14// read from it and written to it; generated output; the last request's input total; and the last request that rewrote the cache
15// instead of reading it, kept until a later turn reads again.
16export type OverheadCacheStats = { input: number; output: number; read: number; write: number; last: number; rewrite?: { tokens: number; turnId: string } }
17// One subagent's observed requests: the latest responding model, optional requested effort, the last eight
18// changed input totals, and cumulative input (including cache), output and cache-read tokens. Not a ledger
19// of requests before observation or after eviction from the bounded list.
20export type OverheadAgent = {
21 id: string
22 model: string
23 effort?: OverheadEffort
24 totals: number[]
25 usage: { input: number; output: number; read: number }
26 label?: string
27 // Native short task description, never the spawn prompt or transcript.
28 description?: string
29}
30// /context's local breakdown, kept for the pane: whether auto-compaction is on, the rows that occupy the
31// window, the deferred tool schemas outside it, and the loaded MCP tool schemas by server. No paths or names
32// of files are kept.
33export type OverheadBreakdown = {
34 autoCompact: boolean
35 rows: { name: string; tokens: number }[]
36 deferred: number
37 mcp: { server: string; tokens: number }[]
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'ccoverhead': {
43 ctx: OverheadCtx | null
44 history: number[]
45 // The conversation's context totals since its last compaction, for the pane.
46 timeline: number[]
47 // The conversation's last compactions, the latest last.
48 compactions: OverheadCompaction[]
49 limits: OverheadLimit[]
50 limitsLive: boolean
51 cache: OverheadCache | null
52 cacheStats: OverheadCacheStats
53 // The prompt cache's lifetime in ms as the session showed it (a switch, a resume, the request traffic); null until then.
54 cacheTtl: number | null
55 cost: number | null
56 turnCostBase: number | null
57 turnCost: number | null
58 // null until the host's `theme` row has been read; drawn as native colors meanwhile.
59 theme: OverheadTheme | null
60 sessionId: string | null
61 limitsAt: number | null
62 activeAgents: number
63 // The main loop's model, as /model shows it: picks a model's own weekly window when one is reported.
64 model: string | null
65 effort: OverheadEffort | null
66 agents: OverheadAgent[]
67 breakdown: OverheadBreakdown | null
68 }
69 }
70}
71