SLOPSHOPPER

ccOverhead

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

newpanebandcommandtimeragents
v1.5.5MITupdated 2026-10-09shengyy/ccoverhead/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccoverhead
│ ┃ ccOverhead ✕ › fix the failing auth test and add an audit log call │ ┃ Context │ ┃ window ■■■■■□□□□□ 49% 97k of 200k ⏺ Read(src/auth.ts) │ ┃ model claude-opus-5-5 ⎿ Read 6 lines │ ┃ session ID preview-session ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Growth ⏺ Bash(bun test) │ ┃ changes none yet ⎿ 3 pass, 1 fail │ ┃ │ ┃ Tokens & cache, main conversation ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ state no request yet │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Quota │ ┃ 5h ■■■□□□□□□□ 31% › /ccoverhead │ ┃ │ ┃ Cost, API-price reference │ ┃ session ≈$0.42 │ ┃ last turn +$0.00 │ ┃ API-price reference, not a bil │ ctx ■■■■■□□□□□ 49% 97k/200k | cost ≈$0.42 (+$0.00) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx ■■■■■□□□□□ 49% 97k/200k | cost ≈$0.42 (+$0.00) ⟨Claude Code's own drawing⟩
Pane · ccOverhead
Context window ■■■■■□□□□□ 49% 97k of 200k model claude-opus-5-5 session ID preview-session Growth changes none yet Tokens & cache, main conversation state no request yet Quota 5h ■■■□□□□□□□ 31% Cost, API-price reference session ≈$0.42 last turn +$0.00 API-price reference, not a billing receipt
README

ccOverhead

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.

Install and use

Choose one installation source and enable only one copy:

  • Recommended — Anthropic Directory: in Claude desktop, open Settings → Plugins → Discover, search ccOverhead, and install the entry from Anthropic Directory. No marketplace setup or terminal commands are needed. Open the directory listing.
  • Alternative — our GitHub marketplace: use these commands instead of the directory installation:
/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.

Design rationale

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.

Layout and color: desktop and terminal drawing, narrowing stages, palettes and thresholds

States and data: estimates, saved quota, cache warmth, lifecycle and verification boundaries

Privacy and license

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.

Source 8 files
hooks/register.tsx 554 lines
1// 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}
554
hooks/draw.tsx 149 lines
1// 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}
149
hooks/format.ts 432 lines
1// 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}
432
hooks/pane.ts 207 lines
1// 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}
207
hooks/track.ts 121 lines
1// 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}
121
hooks/activity.ts 31 lines
1// 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}
31
hooks/activity-client.ts 17 lines
1import 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
17
types/index.d.ts 71 lines
1// 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