Session meters above the prompt; /meter opens detailed metrics

A Claude Code plugin marketplace of mods: function-hooks plugins that draw bands, panes and other UI inside Claude Code.
| Plugin | What it does | ||
|---|---|---|---|
meter | Band above the prompt showing context, prompt cache, usage limits and cost; /meter opens a detailed metrics pane. Works in the terminal and the desktop app. | ||
agent-graph | Pane graphing the session's running subagents: a left-to-right card graph in the desktop app, which opens it on the first subagent, and an indented tree in the terminal, opened with /agent-graph. | ||
auto-handoff | Once the context passes 40% of a window over 200k tokens (50% of a smaller one), has a dedicated turn write a nine-section handoff, with the decisions, gotchas, conventions and open questions it names, into an Open Knowledge Format bundle at .auto-handoff/ in the project root, which git ignores by default; then clears the context and continues from the handoff, and shows each new conversation the bundle's index. Safe with several sessions in one project; `/auto-handoff on\ | off\ | status` switches it per session. No UI. |
statusline | A styled row under the prompt's mode line, above the engine's hints: model (warning colour on a fallback), effort, directory, branch with a ● when dirty and ↑N↓N against the upstream. Terminal only; the desktop app shows the model in its own composer. |
git clone <repo-url> claude-mods
claude plugin marketplace add ./claude-mods
claude plugin install meter@claude-mods --scope user
claude plugin install agent-graph@claude-mods --scope user
claude plugin install auto-handoff@claude-mods --scope user
claude plugin install statusline@claude-mods --scope user
claude-mods/
├── .claude-plugin/
│ └── marketplace.json # marketplace name, owner, plugin list
└── plugins/
└── <name>/
├── .claude-plugin/
│ └── plugin.json # name, version, description
├── hooks/ # hooks.json and the function-hooks modules
├── types/ # this plugin's exported types ("types" in plugin.json)
└── tsconfig.json
Installed plugins load in place from this checkout. After editing, run /reload-plugins or restart Claude Code. For live iteration without installing, run claude --plugin-dir plugins/<name>.
Check a plugin with:
claude plugin validate plugins/<name>
cd plugins/<name> && claude plugin test .
Check the marketplace with claude plugin validate . from the repo root.
hooks/register.tsx 433 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 AgentStats,
6 BreakdownState,
7 CompactionStats,
8 Measure,
9 RequestStats,
10 Totals,
11 Trends,
12 TurnStats,
13} from '../types'
14import { drawBand } from './band'
15import { clean } from './format'
16import {
17 EMPTY_MODEL,
18 addAgentStep,
19 addAnswer,
20 addCompaction,
21 addCost,
22 addLimitSamples,
23 addStep,
24 addTool,
25 addTurn,
26 keepSelection,
27 resolveModel,
28 selectModel,
29 toSnap,
30} from './metrics'
31import { drawPane } from './pane'
32
33const cacheAtom = atom({ plugin: 'meter', key: 'cache' } as const, null)
34const measureAtom = atom({ plugin: 'meter', key: 'measure' } as const, null)
35const nowAtom = atom({ plugin: 'meter', key: 'now' } as const, 0)
36
37const EMPTY_TOTALS: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 }
38const EMPTY_REQUESTS: RequestStats = {
39 trackedSince: 0,
40 main: EMPTY_TOTALS,
41 agents: EMPTY_TOTALS,
42 byModel: {},
43 noResponse: 0,
44 history: [],
45 messageCount: 0,
46}
47const EMPTY_TURNS: TurnStats = { count: 0, totalMs: 0, longestMs: 0, aborted: 0, errored: 0, refused: 0, recent: [] }
48const EMPTY_AGENTS: AgentStats = { usage: {}, list: [] }
49const EMPTY_COMPACTIONS: CompactionStats = { count: 0, subagentCount: 0, recent: [] }
50const EMPTY_TRENDS: Trends = { cost: [], limits: {} }
51const IDLE = { state: 'idle' } as const
52const EMPTY_BREAKDOWN: BreakdownState = { snap: null, status: IDLE }
53
54const requestsAtom = atom({ plugin: 'meter', key: 'requests' } as const, EMPTY_REQUESTS)
55const turnsAtom = atom({ plugin: 'meter', key: 'turns' } as const, EMPTY_TURNS)
56const toolsAtom = atom({ plugin: 'meter', key: 'tools' } as const, {})
57const agentsAtom = atom({ plugin: 'meter', key: 'agents' } as const, EMPTY_AGENTS)
58const compactionsAtom = atom({ plugin: 'meter', key: 'compactions' } as const, EMPTY_COMPACTIONS)
59const trendsAtom = atom({ plugin: 'meter', key: 'trends' } as const, EMPTY_TRENDS)
60const breakdownAtom = atom({ plugin: 'meter', key: 'breakdown' } as const, EMPTY_BREAKDOWN)
61const infoAtom = atom({ plugin: 'meter', key: 'info' } as const, null)
62const modelAtom = atom({ plugin: 'meter', key: 'model' } as const, EMPTY_MODEL)
63const expandedAtom = atom({ plugin: 'meter', key: 'expanded' } as const, [])
64const epochAtom = atom({ plugin: 'meter', key: 'epoch' } as const, 0)
65
66const PANE = 'meter'
67const TITLE = 'Meter'
68
69const STALE_LOADING_MS = 60_000
70const AGENTS_LIST_MAX = 50
71
72let needsRegister = false
73let needsSummary = false
74let modelTicket = 0
75
76const toMeasure = (m: Measure): Measure => ({
77 context: { tokens: m.context.tokens, window: m.context.window, percent: m.context.percent },
78 rateLimits: m.rateLimits.map(l => ({ kind: clean(l.kind), percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
79 cost: m.cost && { usd: m.cost.usd },
80})
81
82const registerCommand = ($: EngineInterface) => {
83 void $.command
84 .register({ name: 'meter', description: 'Open detailed session metrics', immediate: true })
85 .catch(() => {})
86}
87
88async function openMeter($: EngineInterface) {
89 return $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, rows: 30, columns: 80 })
90}
91
92const text = (r: PromiseSettledResult<string>, fallback: string) =>
93 r.status === 'fulfilled' ? clean(r.value) : fallback
94
95async function refreshModel($: EngineInterface) {
96 const ticket = ++modelTicket
97 const at = await $.clock.now()
98 const id = clean(await $.session.model())
99 if (id.trim() === '' || ticket !== modelTicket) return
100 await update($, modelAtom, m => selectModel(m, id, at))
101}
102
103async function refreshAgents($: EngineInterface, epoch: number) {
104 try {
105 const list = await $.agent.list()
106 if ((await read($, epochAtom)) !== epoch) return
107 await update($, agentsAtom, a => ({
108 ...a,
109 list: list.slice(-AGENTS_LIST_MAX).map(row => ({
110 id: clean(row.id),
111 type: clean(row.type),
112 description: clean(row.description),
113 status: clean(row.status),
114 ...(row.name !== undefined && { name: clean(row.name) }),
115 })),
116 }))
117 } catch {}
118}
119
120async function refreshDetails($: EngineInterface, detail: 'full' | 'summary') {
121 const started = await $.clock.now()
122 let go = false
123 await update($, breakdownAtom, b => {
124 go = !(b.status.state === 'loading' && started - b.status.since < STALE_LOADING_MS)
125
126 return go ? { ...b, status: { state: 'loading' as const, since: started } } : b
127 })
128 if (!go) return
129
130 try {
131 const epoch = await read($, epochAtom)
132 const [id, version, turns, cwd, first] = await Promise.allSettled([
133 $.session.id(),
134 $.session.version(),
135 $.session.turns(),
136 $.session.cwd(),
137 $.session.usage({ breakdown: detail }),
138 ])
139 let usage = first
140 let counted = detail
141 if (detail === 'full' && !(usage.status === 'fulfilled' && usage.value.context.breakdown)) {
142 [usage] = await Promise.allSettled([$.session.usage({ breakdown: 'summary' })])
143 counted = 'summary'
144 }
145 await refreshAgents($, epoch)
146
147 if ((await read($, epochAtom)) !== epoch) {
148 await update($, breakdownAtom, b => ({ ...b, status: IDLE }))
149
150 return
151 }
152
153 const at = await $.clock.now()
154 await update($, infoAtom, prev => ({
155 id: text(id, prev?.id ?? '-'),
156 version: version.status === 'fulfilled' ? clean(version.value.version) : (prev?.version ?? '-'),
157 ...(version.status === 'fulfilled' && version.value.builtAt !== undefined && { builtAt: clean(version.value.builtAt) }),
158 startedAt: usage.status === 'fulfilled' ? usage.value.startedAt : (prev?.startedAt ?? at),
159 prompts: turns.status === 'fulfilled' ? turns.value : (prev?.prompts ?? 0),
160 cwd: text(cwd, prev?.cwd ?? '-'),
161 at,
162 }))
163
164 const breakdown = usage.status === 'fulfilled' ? usage.value.context.breakdown : undefined
165 if (usage.status === 'fulfilled' && breakdown) {
166 const snap = toSnap(breakdown, usage.value.context.tokens, at, counted)
167 await update($, breakdownAtom, () => ({ snap, status: IDLE }))
168 } else {
169 await update($, breakdownAtom, b => ({ ...b, status: { state: 'error' as const } }))
170 }
171 } catch (err) {
172 await update($, breakdownAtom, b => ({ ...b, status: { state: 'error' as const } }))
173 throw err
174 }
175}
176
177async function resetSession($: EngineInterface) {
178 const now = await $.clock.now()
179 await update($, epochAtom, n => n + 1)
180 await update($, cacheAtom, () => null)
181 await update($, measureAtom, () => null)
182 await update($, requestsAtom, () => ({ ...EMPTY_REQUESTS, trackedSince: now }))
183 await update($, turnsAtom, () => EMPTY_TURNS)
184 await update($, toolsAtom, () => ({}))
185 await update($, agentsAtom, () => EMPTY_AGENTS)
186 await update($, compactionsAtom, () => EMPTY_COMPACTIONS)
187 await update($, trendsAtom, t => ({ ...EMPTY_TRENDS, limits: t.limits }))
188 await update($, breakdownAtom, () => EMPTY_BREAKDOWN)
189 await update($, infoAtom, () => null)
190 await update($, modelAtom, keepSelection)
191}
192
193async function countAfterReset($: EngineInterface) {
194 if (!needsSummary) return
195 needsSummary = false
196 if ((await $.ui.panes()).some(p => p.id === PANE)) void refreshDetails($, 'summary').catch(() => {})
197}
198
199async function recordTool(
200 $: EngineInterface,
201 tool: string,
202 t0: number,
203 isFromAgent: boolean,
204 result: { deny?: string; isError?: true } | null,
205) {
206 const ms = (await $.clock.now()) - t0
207 await update($, toolsAtom, tools =>
208 addTool(tools, tool, {
209 isDenied: result?.deny !== undefined,
210 isError: result === null || result.isError === true,
211 ms,
212 isFromAgent,
213 }),
214 )
215}
216
217export const register: Register = on => {
218 on('session.start', async ($, e, next) => {
219 const usage = await $.session.usage()
220 await update($, measureAtom, () => toMeasure(usage))
221 const now = await $.clock.now()
222 await update($, requestsAtom, r => (r.trackedSince ? r : { ...r, trackedSince: now }))
223 await update($, breakdownAtom, b => ({ ...b, status: IDLE }))
224 $.clock.every(30_000, async () => {
225 const t = await $.clock.now()
226 await update($, nowAtom, () => t)
227 })
228 registerCommand($)
229 void refreshModel($).catch(() => {})
230
231 return next(e)
232 })
233
234 on('session.end', async ($, e, next) => {
235 const isReset = e.reason === 'clear' || e.reason === 'resume'
236 if (isReset) {
237 needsRegister = true
238 await resetSession($)
239 }
240 const result = await next(e)
241 if (isReset) {
242 needsSummary = true
243 void refreshModel($).catch(() => {})
244 }
245
246 return result
247 })
248
249 on('turn.start', async ($, e, next) => {
250 if (needsRegister) {
251 needsRegister = false
252 registerCommand($)
253 }
254 void countAfterReset($).catch(() => {})
255 void refreshModel($).catch(() => {})
256
257 return next(e)
258 })
259
260 on('command.run', { command: 'model' }, async ($, e, next) => {
261 try {
262 return await next(e)
263 } finally {
264 void refreshModel($).catch(() => {})
265 }
266 })
267
268 on('command.run', { command: 'meter' }, async $ => {
269 const opened = await openMeter($)
270 void refreshDetails($, 'full').catch(() => {})
271
272 return opened.isPlaced ? {} : { text: clean(`Meter pane waits: ${opened.reason}`) }
273 })
274
275 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
276 if (e.surface !== 'desktop' && e.surface !== 'terminal') return next(e)
277 const data = {
278 measure: await read($, measureAtom),
279 cache: await read($, cacheAtom),
280 requests: await read($, requestsAtom),
281 turns: await read($, turnsAtom),
282 tools: await read($, toolsAtom),
283 agents: await read($, agentsAtom),
284 compactions: await read($, compactionsAtom),
285 trends: await read($, trendsAtom),
286 breakdown: await read($, breakdownAtom),
287 info: await read($, infoAtom),
288 model: resolveModel(await read($, modelAtom)),
289 expanded: await read($, expandedAtom),
290 }
291 await read($, nowAtom)
292 const now = await $.clock.now()
293 const actions = {
294 refresh: () => refreshDetails($, 'full').catch(() => {}),
295 toggleExpanded: async (name: string) => {
296 await update($, expandedAtom, list => (list.includes(name) ? list.filter(n => n !== name) : [...list, name]))
297 },
298 close: () => $.ui.close({ id: PANE }),
299 }
300
301 return drawPane($.ui.resolve(e), data, actions, { surface: e.surface, bodyColumns: e.props.bodyColumns, now })
302 })
303
304 on('session.measure', async ($, e, next) => {
305 await update($, measureAtom, () => toMeasure(e))
306 void countAfterReset($).catch(() => {})
307 const cost = e.changed.includes('cost') ? e.cost : undefined
308 const hasLimits = e.changed.includes('rateLimits')
309 if (cost || hasLimits) {
310 const at = await $.clock.now()
311 await update($, trendsAtom, t => {
312 const withCost = cost ? addCost(t, at, cost.usd) : t
313
314 return hasLimits ? addLimitSamples(withCost, at, e.rateLimits) : withCost
315 })
316 }
317
318 return next(e)
319 })
320
321 on('turn.step', async function* ($, e, next) {
322 const agentId = e.agentId
323 const epoch = agentId === undefined ? 0 : await read($, epochAtom)
324 const t0 = await $.clock.now()
325 const result = yield* next(e)
326 const t1 = await $.clock.now()
327 const usage = result.usage
328 if (agentId === undefined && usage) {
329 await update($, cacheAtom, prev => ({
330 lastRead: usage.cache_read_input_tokens,
331 lastWrite: usage.cache_creation_input_tokens,
332 lastUncached: usage.input_tokens,
333 totalRead: (prev?.totalRead ?? 0) + usage.cache_read_input_tokens,
334 totalWrite: (prev?.totalWrite ?? 0) + usage.cache_creation_input_tokens,
335 totalUncached: (prev?.totalUncached ?? 0) + usage.input_tokens,
336 lastAt: t1,
337 }))
338 await update($, modelAtom, m =>
339 addAnswer(m, {
340 answered: usage.model,
341 requested: e.model,
342 effort: e.effort === undefined ? null : String(e.effort),
343 requestedAt: t0,
344 }),
345 )
346 }
347 if (agentId !== undefined && usage) {
348 const id = clean(agentId)
349 const seen = await read($, agentsAtom)
350 const isNew = !Object.hasOwn(seen.usage, id) && !seen.list.some(row => row.id === id)
351 await update($, agentsAtom, a => addAgentStep(a, agentId, usage, t1))
352 if (isNew) void refreshAgents($, epoch)
353 }
354 await update($, requestsAtom, r =>
355 addStep(r, {
356 at: t1,
357 ms: t1 - t0,
358 usage,
359 messageCount: e.messageCount,
360 isAgent: agentId !== undefined,
361 }),
362 )
363
364 return result
365 })
366
367 on('turn.complete', async ($, e, next) => {
368 const epoch = await read($, epochAtom)
369 if (e.agentId === undefined) {
370 const at = await $.clock.now()
371 await update($, turnsAtom, t => addTurn(t, at, e.durationMs, e.reason))
372 }
373 const result = await next(e)
374 if (e.agentId !== undefined) void refreshAgents($, epoch)
375
376 return result
377 })
378
379 on('tool.call', async ($, e, next) => {
380 const t0 = await $.clock.now()
381 const isFromAgent = e.agentId !== undefined
382 const epoch = await read($, epochAtom)
383 let result
384 try {
385 result = await next(e)
386 } catch (err) {
387 await recordTool($, e.tool, t0, isFromAgent, null)
388 throw err
389 } finally {
390 if (e.tool === 'Agent') void refreshAgents($, epoch)
391 }
392 await recordTool($, e.tool, t0, isFromAgent, result)
393
394 return result
395 })
396
397 on('session.compact', async ($, e, next) => {
398 const result = await next(e)
399 if (e.trigger === 'precompute' || result.skip !== undefined) return result
400 const at = await $.clock.now()
401 const trigger = e.trigger
402 await update($, compactionsAtom, c =>
403 addCompaction(c, {
404 at,
405 trigger,
406 before: result.tokensBefore,
407 after: result.tokensAfter,
408 isSubagent: e.agentId !== undefined,
409 }),
410 )
411
412 return result
413 })
414
415 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
416 if (e.surface !== 'desktop' && e.surface !== 'terminal') return next(e)
417 if (e.props.hasSurvey) return next(e)
418
419 const measure = await read($, measureAtom)
420 const cache = await read($, cacheAtom)
421 const model = await read($, modelAtom)
422 await read($, nowAtom)
423 const now = await $.clock.now()
424 const tree = drawBand(
425 $.ui.resolve(e),
426 { measure, cache, model: resolveModel(model) },
427 { surface: e.surface, bodyColumns: e.props.bodyColumns, now, isWorking: e.props.isWorking },
428 )
429
430 return tree ?? next(e)
431 })
432}
433hooks/band.tsx 245 lines1import type { RenderElement } from 'claude-code'
2
3import type { CacheStats, Measure, ResolvedModel } from '../types'
4import { type Els, type Run, type Surface, dimRun, drawGauge, runsText, runsWidth, toneRun } from './draw'
5import { type Tone, cacheState, fmtDuration, fmtTokens, hitRate, hitTone, limitLabel, loadTone } from './format'
6
7type BandData = { measure: Measure | null; cache: CacheStats | null; model: ResolvedModel | null }
8type BandOpts = { surface: Surface; bodyColumns: number; now: number; isWorking: boolean }
9
10type Item =
11 | { key: string; runs: Run[] }
12 | { key: string; gauge: { pct: number; tone: Tone; cells: number; px: number }; alt: string }
13type Segment = { key: string; items: Item[] }
14
15type Detail = {
16 tokens: boolean
17 limitGauges: boolean
18 cacheWords: boolean
19 resets: boolean
20 gap: number
21 ctxCells: number
22 ctxLabel: string
23}
24
25const CTX_PX = 56
26const LIMIT_PX = 36
27const LIMIT_CELLS = 6
28const DESKTOP_LIMIT_GAUGES = 80
29
30const FULL: Detail = {
31 tokens: true,
32 limitGauges: true,
33 cacheWords: true,
34 resets: true,
35 gap: 3,
36 ctxCells: 10,
37 ctxLabel: 'Context',
38}
39
40const STEPS: Partial<Detail>[] = [
41 {},
42 { tokens: false },
43 { limitGauges: false },
44 { cacheWords: false },
45 { resets: false },
46 { gap: 2 },
47 { ctxCells: 6 },
48 { ctxCells: 0 },
49 { ctxLabel: 'Ctx' },
50]
51
52const BAND_LEVELS: Detail[] = STEPS.reduce<Detail[]>((levels, step) => [...levels, { ...(levels.at(-1) ?? FULL), ...step }], [])
53
54const desktopDetail = (cols: number): Detail => ({
55 tokens: false,
56 limitGauges: cols >= DESKTOP_LIMIT_GAUGES,
57 cacheWords: true,
58 resets: true,
59 gap: 3,
60 ctxCells: 10,
61 ctxLabel: 'Context',
62})
63
64const text = (key: string, ...runs: Run[]): Item => ({ key, runs })
65const label = (name: string) => text('label', dimRun(name))
66
67const buildSegments = (data: BandData, detail: Detail, opts: BandOpts): Segment[] => {
68 const { measure, cache } = data
69 const segments: Segment[] = []
70
71 const ctx = measure?.context
72 if (ctx) {
73 const pct = ctx.percent
74 const items: Item[] = [text('label', dimRun(detail.ctxLabel))]
75 if (pct === undefined) {
76 items.push(text('v', dimRun(`${fmtTokens(ctx.window)} window`)))
77 } else {
78 const tone = loadTone(pct)
79 if (detail.ctxCells > 0) {
80 items.push({ key: 'bar', gauge: { pct, tone, cells: detail.ctxCells, px: CTX_PX }, alt: `Context ${pct}%` })
81 }
82 items.push(text('v', toneRun(`${pct}%`, tone)))
83 if (detail.tokens && ctx.tokens !== undefined) {
84 items.push(text('t', dimRun(`${fmtTokens(ctx.tokens)} / ${fmtTokens(ctx.window)}`)))
85 }
86 }
87 segments.push({ key: 'ctx', items })
88 }
89
90 if (cache) {
91 const hit = hitRate(cache.lastRead, cache.lastWrite, cache.lastUncached)
92 const state = cacheState(cache.lastAt, opts.now, opts.isWorking)
93 const value =
94 hit === null
95 ? text('v', dimRun('-'))
96 : text('v', ...(detail.cacheWords ? [toneRun(`${hit}%`, hitTone(hit)), dimRun(' hit')] : [toneRun(`${hit}%`, hitTone(hit))]))
97 segments.push({
98 key: 'cache',
99 items: [label('Cache'), value, text('s', { text: '●', color: state.dot }, dimRun(` ${detail.cacheWords ? state.text : state.short}`))],
100 })
101 }
102
103 for (const l of measure?.rateLimits ?? []) {
104 const name = limitLabel(l.kind)
105 const pct = Math.round(l.percentUsed)
106 const resetIn = l.resetsAt ? Date.parse(l.resetsAt) - opts.now : NaN
107 const tone: Tone = Number.isFinite(resetIn) && resetIn <= 0 ? 'idle' : loadTone(pct)
108 const items: Item[] = [label(name)]
109 if (detail.limitGauges) {
110 items.push({ key: 'bar', gauge: { pct, tone, cells: LIMIT_CELLS, px: LIMIT_PX }, alt: `${name} limit ${pct}%` })
111 }
112 items.push(text('v', toneRun(`${pct}%`, tone)))
113 if (detail.resets && resetIn > 0) items.push(text('r', dimRun(`↻ ${fmtDuration(resetIn)}`)))
114 segments.push({ key: `limit-${l.kind}`, items })
115 }
116
117 if (measure?.cost) segments.push({ key: 'cost', items: [text('v', { text: `$${measure.cost.usd.toFixed(2)}` })] })
118
119 return segments
120}
121
122const itemWidth = (item: Item) => ('gauge' in item ? item.gauge.cells : runsWidth(item.runs))
123
124const rowWidth = (segments: readonly Segment[], gap: number) =>
125 segments.reduce((n, s) => n + s.items.reduce((w, item) => w + itemWidth(item), 0) + s.items.length - 1, 0) +
126 gap * Math.max(0, segments.length - 1)
127
128const TERMINAL_INSET = 2
129
130const pickTerminal = (data: BandData, opts: BandOpts) => {
131 for (const detail of BAND_LEVELS) {
132 const segments = buildSegments(data, detail, opts)
133 if (rowWidth(segments, detail.gap) <= opts.bodyColumns - TERMINAL_INSET) return { detail, segments, isFloor: false }
134 }
135 const detail = BAND_LEVELS.at(-1)!
136
137 return { detail, segments: buildSegments(data, detail, opts), isFloor: true }
138}
139
140const bandDetails = (data: BandData, now: number): [string, string][] => {
141 const { measure, cache, model } = data
142 const details: [string, string][] = []
143 const ctx = measure?.context
144 if (model) {
145 const parts = [model.name]
146 if (model.fallbackFrom) parts.push(`fallback from ${model.fallbackFrom}`)
147 if (model.effort) parts.push(`${model.effort} effort`)
148 if (model.id !== model.name) parts.push(model.id)
149 details.push(['Model', parts.join(' · ')])
150 }
151 if (ctx?.tokens !== undefined) {
152 details.push(['Context', `${ctx.tokens.toLocaleString()} of ${ctx.window.toLocaleString()} tokens`])
153 }
154 if (cache) {
155 const { idle, ttlLeft } = cacheState(cache.lastAt, now, false)
156 details.push(['Last request', `${fmtTokens(cache.lastRead)} read · ${fmtTokens(cache.lastWrite)} written · ${fmtTokens(cache.lastUncached)} uncached`])
157 const sess = hitRate(cache.totalRead, cache.totalWrite, cache.totalUncached)
158 details.push(['Session cache', `${sess ?? '-'}% hit · ${fmtTokens(cache.totalRead)} read · ${fmtTokens(cache.totalWrite)} written`])
159 details.push(['Cache TTL', ttlLeft > 0 ? `~${fmtDuration(ttlLeft)} left (idle ${fmtDuration(idle)}, assumes 1h)` : `expired ${fmtDuration(-ttlLeft)} ago`])
160 }
161 for (const l of measure?.rateLimits ?? []) {
162 if (l.resetsAt && Date.parse(l.resetsAt) > now) {
163 const at = new Date(l.resetsAt)
164 details.push([`${limitLabel(l.kind)} limit`, `${Math.round(l.percentUsed)}% used · resets ${at.toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })}`])
165 }
166 }
167
168 return details
169}
170
171export const drawBand = (els: Els, data: BandData, opts: BandOpts): RenderElement | null => {
172 const { Box, Text } = els
173 const isTerminal = opts.surface === 'terminal'
174 if (!data.measure && !data.cache) return null
175
176 const { detail, segments, isFloor } = isTerminal
177 ? pickTerminal(data, opts)
178 : (() => {
179 const detail = desktopDetail(opts.bodyColumns)
180
181 return { detail, segments: buildSegments(data, detail, opts), isFloor: false }
182 })()
183 if (segments.length === 0) return null
184
185 const details = bandDetails(data, opts.now)
186 const labelWidth = Math.max(...details.map(([k]) => k.length))
187
188 const card =
189 details.length === 0 ? null : isTerminal ? (
190 <Box
191 key="card"
192 position="absolute"
193 top={-(details.length + 2)}
194 left={TERMINAL_INSET}
195 display="none"
196 hover={{ display: 'flex', scope: 'meter' }}
197 flexDirection="column"
198 borderStyle="round"
199 borderDimColor
200 paddingX={1}
201 >
202 {details.map(([k, v]) => (
203 <Box key={k}>
204 <Text dimColor>{k.padEnd(labelWidth + 2)}</Text>
205 <Text wrap="truncate">{v}</Text>
206 </Box>
207 ))}
208 </Box>
209 ) : (
210 <Box key="card" display="none" hover={{ display: 'flex', scope: 'meter' }} flexDirection="column" marginBottom={1}>
211 {details.map(([k, v]) => (
212 <Box key={k} columnGap={2}>
213 <Box width={labelWidth + 1}>
214 <Text dimColor>{k}</Text>
215 </Box>
216 <Text>{v}</Text>
217 </Box>
218 ))}
219 </Box>
220 )
221
222 const draw = (item: Item) =>
223 'gauge' in item
224 ? drawGauge(els, opts.surface, item.key, item.gauge.pct, item.gauge.tone, item.gauge, item.alt)
225 : runsText(Text, item.key, item.runs)
226
227 return (
228 <Box
229 key="meter"
230 flexDirection="column"
231 marginTop={isTerminal ? 1 : undefined}
232 paddingLeft={isTerminal ? TERMINAL_INSET : undefined}
233 hover={{ scope: 'meter' }}>
234 {card}
235 <Box key="row" flexWrap={isFloor || !isTerminal ? 'wrap' : 'nowrap'} alignItems="center" columnGap={detail.gap}>
236 {segments.map(s => (
237 <Box key={s.key} alignItems="center" columnGap={1}>
238 {s.items.map(draw)}
239 </Box>
240 ))}
241 </Box>
242 </Box>
243 )
244}
245hooks/format.ts 99 lines1export const SVG_COLORS: Record<'warning' | 'error', string> = { warning: '#c98a1b', error: '#d0544a' }
2
3export const fmtTokens = (n: number) =>
4 Math.round(n / 1000) >= 1000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
5
6export const fmtTokens1 = (n: number) =>
7 Math.round(n / 100) >= 10_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
8
9export const fmtDuration = (ms: number) => {
10 const m = Math.max(0, Math.round(ms / 60000))
11 if (m < 60) return `${m}m`
12 const h = Math.floor(m / 60)
13 if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`
14 return h % 24 ? `${Math.floor(h / 24)}d ${h % 24}h` : `${Math.floor(h / 24)}d`
15}
16
17export type Tone = 'rest' | 'idle' | 'warning' | 'error'
18
19export const loadTone = (pct: number): Tone => (pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'rest')
20export const hitTone = (pct: number): Tone => (pct < 40 ? 'error' : pct < 80 ? 'warning' : 'rest')
21
22const CACHE_TTL_MS = 60 * 60 * 1000
23const CACHE_WARN_MS = 10 * 60000
24
25export const cacheState = (lastAt: number, now: number, isWorking: boolean) => {
26 const idle = Math.max(0, now - lastAt)
27 const ttlLeft = CACHE_TTL_MS - idle
28 const state = isWorking
29 ? { dot: 'success', text: 'live', short: 'live' }
30 : ttlLeft > 0
31 ? { dot: ttlLeft < CACHE_WARN_MS ? 'warning' : 'success', text: `warm ${fmtDuration(ttlLeft)}`, short: fmtDuration(ttlLeft) }
32 : { dot: 'error', text: 'cold', short: 'cold' }
33
34 return { idle, ttlLeft, ...state }
35}
36
37export const hitRate = (read: number, write: number, uncached: number) => {
38 const total = read + write + uncached
39 return total === 0 ? null : Math.round((read / total) * 100)
40}
41
42export const limitLabel = (kind: string) =>
43 kind === 'five_hour' ? '5h' : kind === 'seven_day' ? 'Week' : kind === 'spend_limit' ? 'Spend' : kind
44
45const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
46const SPARK = '▁▂▃▄▅▆▇█'
47const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
48const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
49
50export const clean = (s: string) => {
51 const cut = s.replace(CONTROL, ' ').slice(0, 200)
52 const last = cut.charCodeAt(cut.length - 1)
53 return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
54}
55
56export const pushCapped = <T>(arr: readonly T[], item: T, max: number): T[] => [...arr, item].slice(-max)
57
58export const sparkGlyphs = (values: readonly number[], max: number) =>
59 values.map(v => (max <= 0 ? SPARK[0]! : SPARK[Math.min(7, Math.max(0, Math.round((v / max) * 7)))]!))
60
61const pad2 = (n: number) => String(n).padStart(2, '0')
62
63export const fmtClock = (ms: number, withDay = false) => {
64 const d = new Date(ms)
65 const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
66 return withDay ? `${DAYS[d.getDay()]} ${time}` : time
67}
68
69export const fmtDate = (ms: number) => {
70 const d = new Date(ms)
71 return `${MONTHS[d.getMonth()]} ${d.getDate()}`
72}
73
74export const fmtMs = (ms: number) => {
75 if (ms < 100) return `${Math.round(ms)}ms`
76 if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
77 const s = Math.round(ms / 1000)
78 if (s < 60) return `${s}s`
79 const m = Math.floor(s / 60)
80 return s % 60 ? `${m}m ${s % 60}s` : `${m}m`
81}
82
83export const fmtAgo = (ms: number) => (ms < 60_000 ? 'just now' : `${fmtDuration(ms)} ago`)
84
85const capitalize = (s: string) => `${s[0]!.toUpperCase()}${s.slice(1).toLowerCase()}`
86
87export const modelName = (id: string) => {
88 const versioned = /(opus|sonnet|haiku|fable)-(\d{1,2})(?:-(\d{1,2})(?!\d)|(?=$|\[|-(?:\d{8}(?!\d)|v\d)))/i.exec(id)
89 if (versioned) return `${capitalize(versioned[1]!)} ${versioned[2]}${versioned[3] === undefined ? '' : `.${versioned[3]}`}`
90 const alias = /^(opus|sonnet|haiku|fable)(?:\[[^\]]*\])?$/i.exec(id)
91 if (alias) return capitalize(alias[1]!)
92
93 return id.replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '')
94}
95
96export const modelId = (id: string) => id.replace(/^claude-/, '')
97
98export const shortId = (id: string) => (id.length > 20 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id)
99hooks/metrics.ts 290 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type {
4 AgentStats,
5 BreakdownSnap,
6 CompactionStats,
7 LimitSample,
8 ModelState,
9 RateLimit,
10 RequestStats,
11 ResolvedModel,
12 ToolStat,
13 Totals,
14 Trends,
15 TurnReason,
16 TurnStats,
17} from '../types'
18import { clean, hitRate, modelName, pushCapped } from './format'
19
20const HISTORY_MAX = 60
21const TURNS_MAX = 30
22const COST_MAX = 40
23const LIMIT_SAMPLES_MAX = 24
24const COMPACTIONS_MAX = 10
25const TOOLS_MAX = 40
26const AGENTS_MAX = 50
27const MODELS_MAX = 8
28const SNAP_MEMORY_MAX = 20
29const SNAP_MCP_MAX = 20
30const SNAP_SKILLS_MAX = 15
31const SNAP_AGENTS_MAX = 15
32const PACE_MIN_SPAN_MS = 60_000
33const OTHER = '(other)'
34
35export type StepUsage = {
36 input_tokens: number
37 output_tokens: number
38 cache_read_input_tokens: number
39 cache_creation_input_tokens: number
40 model: string
41}
42
43export type StepInput = {
44 at: number
45 ms: number
46 usage: StepUsage | null
47 messageCount: number
48 isAgent: boolean
49}
50
51const ZERO: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 }
52
53const addTotals = (t: Totals, u: StepUsage): Totals => ({
54 input: t.input + u.input_tokens,
55 output: t.output + u.output_tokens,
56 cacheRead: t.cacheRead + u.cache_read_input_tokens,
57 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
58 requests: t.requests + 1,
59})
60
61const addModel = (byModel: Record<string, Totals>, model: string, u: StepUsage) => {
62 const key = model in byModel || Object.keys(byModel).length < MODELS_MAX ? model : OTHER
63
64 return { ...byModel, [key]: addTotals(byModel[key] ?? ZERO, u) }
65}
66
67export const addStep = (r: RequestStats, s: StepInput): RequestStats => {
68 if (!s.usage) return s.isAgent ? r : { ...r, noResponse: r.noResponse + 1 }
69 const model = clean(s.usage.model)
70 const byModel = addModel(r.byModel, model, s.usage)
71 if (s.isAgent) return { ...r, agents: addTotals(r.agents, s.usage), byModel }
72
73 return {
74 ...r,
75 main: addTotals(r.main, s.usage),
76 byModel,
77 messageCount: s.messageCount,
78 history: pushCapped(
79 r.history,
80 {
81 at: s.at,
82 ms: s.ms,
83 hit: hitRate(s.usage.cache_read_input_tokens, s.usage.cache_creation_input_tokens, s.usage.input_tokens),
84 output: s.usage.output_tokens,
85 },
86 HISTORY_MAX,
87 ),
88 }
89}
90
91export const EMPTY_MODEL: ModelState = {
92 selected: null,
93 selectedAt: 0,
94 answered: null,
95 requested: null,
96 effort: null,
97 answeredRequestedAt: 0,
98}
99
100export const selectModel = (m: ModelState, id: string, at: number): ModelState =>
101 m.selected === id ? m : { ...m, selected: id, selectedAt: at }
102
103export type Answer = { answered: string; requested: string; effort: string | null; requestedAt: number }
104
105export const addAnswer = (m: ModelState, a: Answer): ModelState => ({
106 ...m,
107 answered: clean(a.answered),
108 requested: clean(a.requested),
109 effort: a.effort === null ? null : clean(a.effort),
110 answeredRequestedAt: a.requestedAt,
111})
112
113export const keepSelection = (m: ModelState): ModelState => ({
114 ...EMPTY_MODEL,
115 selected: m.selected,
116 selectedAt: m.selectedAt,
117})
118
119const answerWins = (m: ModelState) =>
120 m.answered !== null &&
121 (m.selected === null ||
122 m.answeredRequestedAt >= m.selectedAt ||
123 (m.requested !== null && modelName(m.requested) === modelName(m.selected)))
124
125export const resolveModel = (m: ModelState): ResolvedModel | null => {
126 if (m.answered !== null && answerWins(m)) {
127 const isFallback = m.requested !== null && modelName(m.requested) !== modelName(m.answered)
128
129 return {
130 name: modelName(m.answered),
131 id: m.answered,
132 fallbackFrom: isFallback ? modelName(m.requested!) : null,
133 effort: m.effort,
134 }
135 }
136
137 return m.selected === null ? null : { name: modelName(m.selected), id: m.selected, fallbackFrom: null, effort: null }
138}
139
140export const addAgentStep = (a: AgentStats, agentId: string, u: StepUsage, at: number): AgentStats => {
141 const id = clean(agentId)
142 const prev = a.usage[id]
143 const usage = {
144 ...a.usage,
145 [id]: { ...addTotals(prev ?? ZERO, u), model: clean(u.model), lastAt: at },
146 }
147 const ids = Object.keys(usage)
148 if (ids.length > AGENTS_MAX) {
149 const oldest = ids.reduce((m, k) => (usage[k]!.lastAt < usage[m]!.lastAt ? k : m))
150 delete usage[oldest]
151 }
152
153 return { ...a, usage }
154}
155
156export const addTurn = (t: TurnStats, at: number, ms: number, reason: TurnReason): TurnStats => ({
157 count: t.count + 1,
158 totalMs: t.totalMs + ms,
159 longestMs: Math.max(t.longestMs, ms),
160 aborted: t.aborted + (reason === 'aborted' ? 1 : 0),
161 errored: t.errored + (reason === 'error' ? 1 : 0),
162 refused: t.refused + (reason === 'refusal' ? 1 : 0),
163 recent: pushCapped(t.recent, { at, ms, reason }, TURNS_MAX),
164})
165
166export type ToolOutcome = { isDenied: boolean; isError: boolean; ms: number; isFromAgent: boolean }
167
168export const addTool = (tools: Record<string, ToolStat>, tool: string, o: ToolOutcome): Record<string, ToolStat> => {
169 const name = clean(tool)
170 const key = name in tools || Object.keys(tools).length < TOOLS_MAX ? name : OTHER
171 const prev = tools[key] ?? { calls: 0, errors: 0, denied: 0, totalMs: 0, fromAgents: 0 }
172
173 return {
174 ...tools,
175 [key]: {
176 calls: prev.calls + 1,
177 errors: prev.errors + (o.isError ? 1 : 0),
178 denied: prev.denied + (o.isDenied ? 1 : 0),
179 totalMs: prev.totalMs + o.ms,
180 fromAgents: prev.fromAgents + (o.isFromAgent ? 1 : 0),
181 },
182 }
183}
184
185export type CompactionInput = {
186 at: number
187 trigger: 'manual' | 'auto' | 'plugin'
188 before?: number
189 after?: number
190 isSubagent: boolean
191}
192
193export const addCompaction = (c: CompactionStats, o: CompactionInput): CompactionStats =>
194 o.isSubagent
195 ? { ...c, subagentCount: c.subagentCount + 1 }
196 : {
197 ...c,
198 count: c.count + 1,
199 recent: pushCapped(c.recent, { at: o.at, trigger: o.trigger, before: o.before, after: o.after }, COMPACTIONS_MAX),
200 }
201
202export const addCost = (t: Trends, at: number, usd: number): Trends => ({
203 ...t,
204 cost: pushCapped(t.cost, { at, usd }, COST_MAX),
205})
206
207export const addLimitSamples = (t: Trends, at: number, limits: readonly RateLimit[]): Trends => {
208 const next = { ...t.limits }
209 for (const l of limits) {
210 const kind = clean(l.kind)
211 let samples = next[kind] ?? []
212 const last = samples.at(-1)
213 if (last && last.resetsAt !== l.resetsAt) samples = []
214 if (samples.at(-1)?.pct === l.percentUsed) continue
215 const sample: LimitSample = { at, pct: l.percentUsed, resetsAt: l.resetsAt }
216 next[kind] = pushCapped(samples, sample, LIMIT_SAMPLES_MAX)
217 }
218
219 return { ...t, limits: next }
220}
221
222export const pace = (samples: readonly LimitSample[], now: number) => {
223 const first = samples[0]
224 const last = samples.at(-1)
225 if (!first || !last || first === last) return null
226 const span = last.at - first.at
227 const gain = last.pct - first.pct
228 if (span < PACE_MIN_SPAN_MS || gain <= 0) return null
229
230 const elapsed = Math.max(now, last.at) - first.at
231
232 return { msTo100: ((100 - last.pct) / gain) * elapsed }
233}
234
235const top = <T>(items: readonly T[], by: (item: T) => number, max: number) =>
236 [...items].sort((a, b) => by(b) - by(a)).slice(0, max)
237
238export const toSnap = (
239 b: SessionContextBreakdown,
240 contextTokens: number | undefined,
241 at: number,
242 detail: 'full' | 'summary',
243): BreakdownSnap => {
244 const kinds = new Map(b.categories.map(c => [c.name, c.kind]))
245 const servers = new Map<string, { tools: number; loaded: number; tokens: number }>()
246 for (const t of b.mcpTools) {
247 const name = clean(t.serverName)
248 const s = servers.get(name) ?? { tools: 0, loaded: 0, tokens: 0 }
249 servers.set(name, { tools: s.tools + 1, loaded: s.loaded + (t.isLoaded ? 1 : 0), tokens: s.tokens + t.tokens })
250 }
251
252 return {
253 at,
254 detail,
255 contextTokens,
256 totalTokens: b.totalTokens,
257 rawMaxTokens: b.rawMaxTokens,
258 percentage: b.percentage,
259 autocompactSource: clean(b.autocompactSource),
260 autoCompactThreshold: b.autoCompactThreshold,
261 isAutoCompactEnabled: b.isAutoCompactEnabled,
262 categories: b.categories.map(c => ({ name: clean(c.name), tokens: c.tokens, color: clean(c.color), kind: c.kind })),
263 squares: b.gridRows.flat().map(q => ({
264 category: clean(q.categoryName),
265 color: clean(q.color),
266 kind: kinds.get(q.categoryName) ?? 'used',
267 fullness: q.squareFullness,
268 })),
269 memoryFiles: top(b.memoryFiles, f => f.tokens, SNAP_MEMORY_MAX).map(f => ({
270 path: clean(f.path),
271 type: clean(f.type),
272 tokens: f.tokens,
273 })),
274 memoryTotal: { count: b.memoryFiles.length, tokens: b.memoryFiles.reduce((n, f) => n + f.tokens, 0) },
275 mcpServers: top([...servers], ([, s]) => s.tokens, SNAP_MCP_MAX).map(([server, s]) => ({ server, ...s })),
276 skills: b.skills && {
277 total: b.skills.totalSkills,
278 included: b.skills.includedSkills,
279 tokens: b.skills.tokens,
280 top: top(b.skills.skillFrontmatter, s => s.tokens, SNAP_SKILLS_MAX).map(s => ({ name: clean(s.name), tokens: s.tokens })),
281 },
282 agents: top(b.agents, a => a.tokens, SNAP_AGENTS_MAX).map(a => ({ agentType: clean(a.agentType), tokens: a.tokens })),
283 slashCommands: b.slashCommands && {
284 total: b.slashCommands.totalCommands,
285 included: b.slashCommands.includedCommands,
286 tokens: b.slashCommands.tokens,
287 },
288 }
289}
290hooks/pane.tsx 906 lines1import type { Elements, RenderChildren } from 'claude-code'
2
3import type {
4 AgentStats,
5 BreakdownState,
6 CacheStats,
7 CompactionStats,
8 Measure,
9 RequestStats,
10 ResolvedModel,
11 SessionInfo,
12 SnapCategory,
13 ToolStat,
14 Trends,
15 TurnStats,
16} from '../types'
17import {
18 cacheState,
19 clean,
20 fmtAgo,
21 fmtClock,
22 fmtDate,
23 fmtDuration,
24 fmtMs,
25 fmtTokens,
26 fmtTokens1,
27 type Tone,
28 hitRate,
29 hitTone,
30 limitLabel,
31 loadTone,
32 modelId,
33 shortId,
34 sparkGlyphs,
35} from './format'
36import { pace } from './metrics'
37import { type Els, type Run, dimRun, drawGauge, runText, textWidth, toneRun } from './draw'
38import { type SvgDoc, barsSvg, gridSvg, memoized, slotMap, swatchSvg } from './svg'
39
40export type PaneData = {
41 measure: Measure | null
42 cache: CacheStats | null
43 requests: RequestStats
44 turns: TurnStats
45 tools: Record<string, ToolStat>
46 agents: AgentStats
47 compactions: CompactionStats
48 trends: Trends
49 breakdown: BreakdownState
50 info: SessionInfo | null
51 model: ResolvedModel | null
52 expanded: string[]
53}
54
55export type PaneActions = {
56 refresh: () => Promise<void>
57 toggleExpanded: (section: string) => Promise<void>
58 close: () => Promise<void>
59}
60
61export type PaneOpts = { surface: 'terminal' | 'desktop'; bodyColumns: number; now: number }
62
63const STALE_FRACTION = 0.02
64const TOP = { tools: 10, subagents: 10, memory: 5, mcp: 5 }
65const LABEL = 14
66const PERCENT = 5
67const LIMIT_MIN_CELLS = 4
68const COMPACTION_AGO_MIN = 40
69const GAUGE_PX = { wide: 280, medium: 200, narrow: 140 }
70const LIMIT_SIZE = {
71 wide: { cells: 24, px: 160 },
72 medium: { cells: 16, px: 112 },
73 narrow: { cells: 10, px: 72 },
74}
75
76const plural = (n: number, one: string) => (n === 1 ? one : `${one}s`)
77
78type Col = { label: string; width?: number; align?: 'right' }
79
80export const drawPane = (els: Els, data: PaneData, actions: PaneActions, opts: PaneOpts) => {
81 const { Box, Text, Button } = els
82 const isTerminal = opts.surface === 'terminal'
83 const Svg = isTerminal ? null : (els as Elements['desktop']).Svg
84 const cols = opts.bodyColumns
85 const now = opts.now
86 const tier = cols >= 80 ? 'wide' : cols >= 56 ? 'medium' : 'narrow'
87 const { measure, cache, requests, turns, tools, agents, compactions, trends, breakdown, info, model, expanded } = data
88 const { snap, status } = breakdown
89 const ctx = measure?.context
90 const isLoading = status.state === 'loading'
91
92 const svg = (key: string, doc: SvgDoc) =>
93 Svg ? <Svg key={key} source={doc.source} width={doc.width} height={doc.height} alt={doc.alt} /> : null
94
95 const dim = (key: string, text: string) => (
96 <Text key={key} dimColor wrap="truncate">
97 {text}
98 </Text>
99 )
100
101 const line = (key: string, text: string) => (
102 <Text key={key} wrap="truncate">
103 {text}
104 </Text>
105 )
106
107 const headRow = (title: string, isBold: boolean, summary: RenderChildren | undefined, isDim: boolean) => (
108 <Box key={`h-${title}`} columnGap={2} justifyContent="space-between">
109 <Box key="title" flexShrink={0}>
110 <Text bold={isBold}>{title}</Text>
111 </Box>
112 {summary ? (
113 <Box key="summary" flexShrink={1}>
114 <Text dimColor={isDim} wrap="truncate">
115 {summary}
116 </Text>
117 </Box>
118 ) : null}
119 </Box>
120 )
121
122 const heading = (title: string, right?: RenderChildren) => headRow(title, true, right, false)
123
124 const rich = (parts: (Run | string)[]): RenderChildren =>
125 parts.map((p, i) => (typeof p === 'string' ? p : runText(Text, `p${i}`, p)))
126
127 const subheading = (title: string, right?: string) => headRow(title, false, right, true)
128
129 const kv = (key: string, label: string, value: RenderChildren, wrap: 'truncate' | 'truncate-start' = 'truncate') => (
130 <Box key={key}>
131 <Box key="k" width={LABEL} flexShrink={0}>
132 <Text dimColor wrap="truncate">
133 {label}
134 </Text>
135 </Box>
136 <Box key="v" flexGrow={1}>
137 {typeof value === 'string' ? <Text wrap={wrap}>{value}</Text> : value}
138 </Box>
139 </Box>
140 )
141
142 const section = (key: string, children: RenderChildren[]) => (
143 <Box key={key} flexDirection="column" marginTop={1}>
144 {children.filter(c => c !== null && c !== undefined && c !== false)}
145 </Box>
146 )
147
148 const cell = (col: Col, text: string, key: string, isHeader: boolean, isLast: boolean) =>
149 col.width === undefined ? (
150 <Box key={key} flexGrow={1} marginRight={isLast ? 0 : 1}>
151 <Text dimColor={isHeader} wrap="truncate-middle">
152 {text}
153 </Text>
154 </Box>
155 ) : (
156 <Box key={key} width={col.width} flexShrink={0} justifyContent={col.align === 'right' ? 'flex-end' : 'flex-start'}>
157 <Text dimColor={isHeader} wrap="truncate">
158 {text}
159 </Text>
160 </Box>
161 )
162
163 const table = (key: string, columns: Col[], rows: string[][], hasHeader = true) => (
164 <Box key={key} flexDirection="column">
165 {hasHeader ? (
166 <Box key="head">{columns.map((c, i) => cell(c, c.label, `c${i}`, true, i === columns.length - 1))}</Box>
167 ) : null}
168 {rows.map((row, r) => (
169 <Box key={`r${r}`}>{columns.map((c, i) => cell(c, row[i] ?? '', `c${i}`, false, i === columns.length - 1))}</Box>
170 ))}
171 </Box>
172 )
173
174 const moreButton = (name: string, total: number, limit: number) =>
175 total > limit ? (
176 <Button
177 key={`more-${name}`}
178 dimColor
179 label={expanded.includes(name) ? 'Show fewer' : `+${total - limit} more`}
180 onPress={() => {
181 void actions.toggleExpanded(name)
182 }}
183 />
184 ) : null
185
186 const limited = <T,>(name: string, items: T[], limit: number) => (expanded.includes(name) ? items : items.slice(0, limit))
187
188 const valueCols = Math.max(1, cols - LABEL)
189 const sparkWidth = Math.min(valueCols, 40, Math.max(8, cols - LABEL - 20))
190
191 const sparkline = (
192 key: string,
193 values: number[],
194 max: number,
195 toneOf: ((v: number) => Tone) | undefined,
196 alt: string,
197 label?: string,
198 ) => {
199 if (!isTerminal) {
200 const bars = barsSvg(values, max, toneOf ? values.map(toneOf) : undefined, alt)
201
202 return (
203 <Box key={key} columnGap={2} alignItems="flex-end">
204 {svg('bars', bars)}
205 {label ? <Text dimColor>{label}</Text> : null}
206 </Box>
207 )
208 }
209 const runs: { tone: Tone; text: string }[] = []
210 sparkGlyphs(values, max).forEach((g, i) => {
211 const tone = toneOf?.(values[i]!) ?? 'rest'
212 const last = runs.at(-1)
213 if (last && last.tone === tone) last.text += g
214 else runs.push({ tone, text: g })
215 })
216
217 return (
218 <Box key={key} columnGap={2}>
219 <Text key="bars">
220 {runs.map((run, i) =>
221 runText(Text, `run${i}`, run.tone === 'rest' ? dimRun(run.text) : { text: run.text, color: run.tone }),
222 )}
223 </Text>
224 {label && values.length + 2 + textWidth(label) <= valueCols ? <Text dimColor>{label}</Text> : null}
225 </Box>
226 )
227 }
228
229 const refreshLabel = isLoading ? 'Refreshing' : status.state === 'error' ? 'Retry' : snap ? 'Refresh' : 'Count'
230
231 const header = () => {
232 const facts = [
233 info ? `up ${fmtDuration(now - info.startedAt)}` : null,
234 measure?.cost ? `$${measure.cost.usd.toFixed(2)}` : null,
235 ].filter(Boolean)
236
237 const refreshWidth = textWidth(refreshLabel) + 4
238 const whoWidth = Math.max(0, cols - refreshWidth - 2)
239 const shownName = model?.name ?? 'Model not known yet'
240 const effortText = model?.effort ? `${model.effort} effort` : null
241 const fallbackText = model?.fallbackFrom ? `fallback from ${model.fallbackFrom}` : null
242 const withoutEffort = textWidth(shownName) + (fallbackText ? 2 + textWidth(fallbackText) : 0)
243 const isEffortShown = effortText !== null && withoutEffort + 2 + textWidth(effortText) <= whoWidth
244
245 return (
246 <Box key="header" flexDirection="column">
247 <Box key="top" columnGap={2} alignItems="center">
248 <Box key="who" flexGrow={1} flexShrink={1} columnGap={2}>
249 {model ? (
250 <Text key="name" bold wrap="truncate">
251 {model.name}
252 </Text>
253 ) : (
254 <Text key="name" dimColor>
255 Model not known yet
256 </Text>
257 )}
258 {model?.fallbackFrom ? (
259 <Text key="fallback" color="warning" wrap="truncate">
260 {`fallback from ${model.fallbackFrom}`}
261 </Text>
262 ) : null}
263 {isEffortShown ? (
264 <Text key="effort" dimColor>
265 {effortText}
266 </Text>
267 ) : null}
268 </Box>
269 <Box key="refresh-box" flexShrink={0}>
270 <Button
271 key="refresh"
272 hotkey="r"
273 label={refreshLabel}
274 onPress={
275 isLoading
276 ? () => {}
277 : () => {
278 void actions.refresh()
279 }
280 }
281 />
282 </Box>
283 </Box>
284 {facts.length ? (
285 <Text key="facts" dimColor wrap="truncate">
286 {facts.join(' · ')}
287 </Text>
288 ) : null}
289 </Box>
290 )
291 }
292
293 const isStale = () => {
294 if (!snap || snap.contextTokens === undefined) return false
295 const lastCompaction = compactions.recent.at(-1)
296 if (lastCompaction && lastCompaction.at > snap.at) return true
297 if (ctx?.tokens === undefined) return false
298
299 return Math.abs(ctx.tokens - snap.contextTokens) > STALE_FRACTION * ctx.window
300 }
301
302 const contextSection = () => {
303 const out: RenderChildren[] = []
304 const pct = ctx?.percent
305 const hasLive = ctx?.tokens !== undefined && pct !== undefined
306 const tone = pct === undefined ? 'rest' : loadTone(pct)
307 out.push(
308 heading(
309 'Context',
310 hasLive
311 ? rich([toneRun(`${pct}%`, tone), ` · ${fmtTokens(ctx.tokens!)} / ${fmtTokens(ctx.window)}`])
312 : ctx
313 ? `${fmtTokens(ctx.window)} window`
314 : undefined,
315 ),
316 )
317 if (hasLive) {
318 out.push(
319 drawGauge(els, opts.surface, 'gauge', pct, tone, { cells: Math.min(64, cols), px: GAUGE_PX[tier], height: 8 }, `Context ${pct}%`),
320 )
321 }
322 if (snap) {
323 out.push(
324 kv(
325 'auto-compact',
326 'Auto-compact',
327 snap.isAutoCompactEnabled && snap.autoCompactThreshold !== undefined
328 ? `at ${fmtTokens(snap.autoCompactThreshold)}${
329 ctx?.tokens !== undefined ? `, ${fmtTokens(Math.max(0, snap.autoCompactThreshold - ctx.tokens))} to go` : ''
330 }`
331 : 'off',
332 ),
333 )
334 }
335 if (requests.messageCount > 0) out.push(kv('messages', 'Messages', String(requests.messageCount)))
336
337 if (!snap) {
338 out.push(
339 isLoading
340 ? dim('state', 'Counting…')
341 : status.state === 'error'
342 ? line('state', 'Could not count the context')
343 : hasLive
344 ? line('state', 'Breakdown not counted')
345 : dim('state', 'No reply yet. Figures appear after the first response.'),
346 )
347
348 return section('context', out)
349 }
350
351 out.push(breakdownBody())
352 const stateParts = [`${snap.detail === 'summary' ? 'Estimated' : 'Counted'} ${fmtAgo(now - snap.at)}`]
353 if (isStale()) stateParts.push('context changed since; Refresh')
354 out.push(dim('counted', stateParts.join(' · ')))
355 if (isLoading) out.push(dim('state', 'Counting…'))
356 if (status.state === 'error') out.push(line('state', 'Could not count the context'))
357 out.push(...listings())
358
359 return section('context', out)
360 }
361
362 const breakdownBody = () => {
363 const s = snap!
364 const squares = s.squares
365 let perRow = squares.length > 100 ? 20 : 10
366 if (perRow * 2 > cols - 2) perRow = 10
367 if (perRow * 2 > cols - 2) perRow = 5
368 const slots = slotMap(s.categories)
369 const legendCategories = s.categories.filter(c => c.kind !== 'deferred')
370 const pctOf = (c: SnapCategory) => Math.round((c.tokens / Math.max(1, s.rawMaxTokens)) * 100)
371 const isSideBySide = isTerminal ? cols >= perRow * 2 + 34 : cols >= 80
372
373 const used = s.categories
374 .filter(c => c.kind === 'used')
375 .sort((a, b) => b.tokens - a.tokens)
376 const alt = `Context ${Math.round(s.percentage)}%: ${used
377 .slice(0, 6)
378 .map(c => `${c.name} ${fmtTokens(c.tokens)}`)
379 .join(', ')}${used.length > 6 ? ', …' : ''}`
380
381 const rows: RenderChildren[] = []
382 if (isTerminal) {
383 for (let r = 0; r * perRow < squares.length; r++) {
384 const row = squares.slice(r * perRow, (r + 1) * perRow)
385 const runs: { category: string; color: string; kind: string; text: string }[] = []
386 for (const q of row) {
387 const glyph = q.kind === 'used' ? (q.fullness >= 0.7 ? '■ ' : '□ ') : q.kind === 'buffer' ? '▪ ' : '· '
388 const last = runs.at(-1)
389 if (last && last.category === q.category) last.text += glyph
390 else runs.push({ category: q.category, color: q.color, kind: q.kind, text: glyph })
391 }
392 rows.push(
393 <Text key={`grid-${r}`}>
394 {runs.map((run, i) => (
395 <Text key={`grid-${r}-${i}`} color={run.color} dimColor={run.kind !== 'used'}>
396 {run.text}
397 </Text>
398 ))}
399 </Text>,
400 )
401 }
402 }
403
404 const docs = isTerminal
405 ? null
406 : memoized(
407 [s.at, cols, squares.length, s.rawMaxTokens, s.percentage, ...s.categories.map(c => `${c.name}:${c.kind}:${c.tokens}`)].join('|'),
408 () => ({
409 grid: gridSvg(squares, perRow, slots, alt),
410 marks: legendCategories.map(c =>
411 swatchSvg(c.kind === 'used' ? slots[c.name] : undefined, `${c.name} ${fmtTokens1(c.tokens)}, ${pctOf(c)}%`),
412 ),
413 }),
414 )
415
416 const legendRow = (c: SnapCategory, i: number) => (
417 <Box key={`legend-${i}`} columnGap={1} alignItems="center">
418 {isTerminal ? (
419 <Text key="mark" color={c.color} dimColor={c.kind !== 'used'}>
420 {c.kind === 'used' ? '■' : c.kind === 'buffer' ? '▪' : '·'}
421 </Text>
422 ) : (
423 docs && svg('mark', docs.marks[i]!)
424 )}
425 <Box key="name" flexGrow={1}>
426 <Text wrap="truncate-middle">{c.name}</Text>
427 </Box>
428 <Box key="tokens" width={7} justifyContent="flex-end">
429 <Text>{fmtTokens1(c.tokens)}</Text>
430 </Box>
431 <Box key="pct" width={PERCENT} justifyContent="flex-end">
432 <Text>{`${pctOf(c)}%`}</Text>
433 </Box>
434 </Box>
435 )
436
437 const grid = isTerminal ? (
438 <Box key="grid" flexDirection="column" width={perRow * 2}>
439 {rows}
440 </Box>
441 ) : (
442 <Box key="grid" flexDirection="column">
443 {docs && svg('grid-svg', docs.grid)}
444 </Box>
445 )
446 const legend = (
447 <Box key="legend" flexDirection="column" flexGrow={1} marginLeft={isSideBySide ? 2 : 0} marginTop={isSideBySide ? 0 : 1}>
448 {legendCategories.map(legendRow)}
449 </Box>
450 )
451
452 return (
453 <Box key="breakdown" flexDirection={isSideBySide ? 'row' : 'column'} marginTop={1}>
454 {grid}
455 {legend}
456 </Box>
457 )
458 }
459
460 const listings = () => {
461 const s = snap!
462 const out: RenderChildren[] = []
463 const memoryRows = limited('memory', s.memoryFiles, TOP.memory)
464 out.push(
465 <Box key="memory" flexDirection="column" marginTop={1}>
466 {subheading('Memory files', s.memoryTotal.count > 0 ? `${s.memoryTotal.count} · ${fmtTokens1(s.memoryTotal.tokens)}` : undefined)}
467 <Box key="rows" flexDirection="column" marginLeft={2}>
468 {s.memoryTotal.count === 0
469 ? dim('none', 'No memory files')
470 : table(
471 'memory-rows',
472 [{ label: '' }, { label: '', width: 9 }, { label: '', width: 7, align: 'right' }],
473 memoryRows.map(f => [f.path, f.type, fmtTokens1(f.tokens)]),
474 false,
475 )}
476 {moreButton('memory', s.memoryFiles.length, TOP.memory)}
477 </Box>
478 </Box>,
479 )
480 const mcpTools = s.mcpServers.reduce((n, m) => n + m.tools, 0)
481 const mcpTokens = s.mcpServers.reduce((n, m) => n + m.tokens, 0)
482 out.push(
483 <Box key="mcp" flexDirection="column" marginTop={1}>
484 {subheading(
485 'MCP',
486 s.mcpServers.length > 0
487 ? `${s.mcpServers.length} ${plural(s.mcpServers.length, 'server')} · ${mcpTools} ${plural(mcpTools, 'tool')} · ${fmtTokens1(mcpTokens)}`
488 : undefined,
489 )}
490 <Box key="rows" flexDirection="column" marginLeft={2}>
491 {s.mcpServers.length === 0
492 ? dim('none', 'No MCP tools')
493 : table(
494 'mcp-rows',
495 [{ label: '' }, { label: '', width: 10, align: 'right' }, { label: '', width: 7, align: 'right' }],
496 limited('mcp', s.mcpServers, TOP.mcp).map(m => [
497 m.server,
498 `${m.loaded < m.tools ? `${m.loaded}/` : ''}${m.tools} ${plural(m.tools, 'tool')}`,
499 fmtTokens1(m.tokens),
500 ]),
501 false,
502 )}
503 {moreButton('mcp', s.mcpServers.length, TOP.mcp)}
504 </Box>
505 </Box>,
506 )
507 const extras = [
508 s.skills ? `skills ${s.skills.included} of ${s.skills.total} listed (${fmtTokens1(s.skills.tokens)})` : null,
509 s.agents.length > 0 ? `agents ${s.agents.length} (${fmtTokens1(s.agents.reduce((n, a) => n + a.tokens, 0))})` : null,
510 s.slashCommands ? `slash commands ${s.slashCommands.included} of ${s.slashCommands.total} (${fmtTokens1(s.slashCommands.tokens)})` : null,
511 ].filter(Boolean)
512 if (extras.length > 0) {
513 out.push(
514 <Box key="extras" flexDirection="column" marginTop={1}>
515 {dim('extras', `Also in context: ${extras.join(' · ')}`)}
516 </Box>,
517 )
518 }
519
520 return out
521 }
522
523 const cacheSection = () => {
524 const hits = requests.history.flatMap(s => (s.hit === null ? [] : [s.hit])).slice(-sparkWidth)
525 if (!cache && requests.history.length === 0) return section('cache', [heading('Prompt cache'), dim('none', 'None yet')])
526
527 const out: RenderChildren[] = []
528 let right: RenderChildren
529 if (cache) {
530 const hit = hitRate(cache.lastRead, cache.lastWrite, cache.lastUncached)
531 const state = cacheState(cache.lastAt, now, false)
532 right = rich([
533 hit === null ? dimRun('- hit') : toneRun(`${hit}% hit`, hitTone(hit)),
534 ' · ',
535 { text: '●', color: state.dot },
536 ` ${state.text}`,
537 ])
538 }
539 out.push(heading('Prompt cache', right))
540 if (cache) {
541 const sess = hitRate(cache.totalRead, cache.totalWrite, cache.totalUncached)
542 out.push(
543 kv(
544 'session',
545 'Session',
546 `${sess === null ? '-' : `${sess}%`} hit · ${fmtTokens(cache.totalRead)} read · ${fmtTokens(cache.totalWrite)} written · ${fmtTokens(cache.totalUncached)} uncached`,
547 ),
548 )
549 }
550 if (hits.length > 0) {
551 const sorted = [...hits].sort((a, b) => a - b)
552 const median = sorted[Math.floor(sorted.length / 2)]!
553 out.push(
554 kv(
555 'trend',
556 'Trend',
557 sparkline(
558 'hits',
559 hits,
560 100,
561 hitTone,
562 `Cache hit, last ${hits.length} ${plural(hits.length, 'request')}: latest ${hits.at(-1)}%, median ${median}%, min ${sorted[0]}%`,
563 `last ${hits.length} ${plural(hits.length, 'request')}`,
564 ),
565 ),
566 )
567 }
568 const timed = requests.history
569 if (timed.length > 0) {
570 const totalMs = timed.reduce((n, s) => n + s.ms, 0)
571 const totalOut = timed.reduce((n, s) => n + s.output, 0)
572 const avg = fmtMs(totalMs / timed.length)
573 out.push(kv('speed', 'Speed', `avg ${avg} per request${totalMs > 0 ? ` · ${Math.round(totalOut / (totalMs / 1000))} tok/s` : ''}`))
574 }
575
576 return section('cache', out)
577 }
578
579 const limitsSection = () => {
580 const limits = measure?.rateLimits ?? []
581 if (limits.length === 0) {
582 return section('limits', [heading('Usage limits'), dim('none', 'No usage-limit readings (API key, or no reply yet)')])
583 }
584 const rows = limits.map(l => {
585 const kind = clean(l.kind)
586 const pct = Math.round(l.percentUsed)
587 const resetMs = l.resetsAt ? Date.parse(l.resetsAt) : NaN
588 const resetIn = Number.isFinite(resetMs) ? resetMs - now : NaN
589 const isPast = Number.isFinite(resetIn) && resetIn <= 0
590 const tone: Tone = isPast ? 'idle' : loadTone(pct)
591 const reset =
592 resetIn > 0
593 ? `resets ${fmtClock(resetMs, new Date(resetMs).toDateString() !== new Date(now).toDateString())} (in ${fmtDuration(resetIn)})`
594 : null
595 const burn = resetIn > 0 ? pace(trends.limits[kind] ?? [], now) : null
596 const pacing = burn && burn.msTo100 < resetIn ? `At this pace 100% in ${fmtDuration(burn.msTo100)}, before reset` : null
597 const size =
598 tier === 'narrow'
599 ? { ...LIMIT_SIZE.narrow, cells: Math.max(LIMIT_MIN_CELLS, Math.min(LIMIT_SIZE.narrow.cells, cols - LABEL - PERCENT)) }
600 : LIMIT_SIZE[tier]
601 const inRow = tier === 'wide' ? reset : tier === 'medium' && resetIn > 0 ? `in ${fmtDuration(resetIn)}` : null
602
603 return (
604 <Box key={`limit-${kind}`} flexDirection="column">
605 <Box alignItems="center">
606 <Box key="label" width={LABEL} flexShrink={0}>
607 <Text dimColor wrap="truncate">
608 {limitLabel(kind)}
609 </Text>
610 </Box>
611 {drawGauge(els, opts.surface, 'gauge', pct, tone, size, `${limitLabel(kind)} limit ${pct}%`)}
612 <Box key="pct" width={5} justifyContent="flex-end">
613 {runText(Text, 'pct', toneRun(`${pct}%`, tone))}
614 </Box>
615 {inRow ? (
616 <Box key="reset" marginLeft={2}>
617 {dim('reset', inRow)}
618 </Box>
619 ) : null}
620 </Box>
621 {tier === 'narrow' && reset ? (
622 <Box key="reset" marginLeft={LABEL}>
623 {dim('reset', reset)}
624 </Box>
625 ) : null}
626 {pacing ? (
627 <Box key="pace" marginLeft={LABEL}>
628 {dim('pace', pacing)}
629 </Box>
630 ) : null}
631 </Box>
632 )
633 })
634
635 return section('limits', [heading('Usage limits'), ...rows])
636 }
637
638 const tokensSection = () => {
639 const total = {
640 input: requests.main.input + requests.agents.input,
641 output: requests.main.output + requests.agents.output,
642 cacheRead: requests.main.cacheRead + requests.agents.cacheRead,
643 cacheWrite: requests.main.cacheWrite + requests.agents.cacheWrite,
644 requests: requests.main.requests + requests.agents.requests,
645 }
646 if (total.requests === 0 && requests.noResponse === 0) return section('tokens', [heading('Tokens'), dim('none', 'None yet')])
647
648 const out: RenderChildren[] = [heading('Tokens', `${total.requests} ${plural(total.requests, 'request')}`)]
649 if (tier === 'narrow') {
650 out.push(
651 table(
652 'totals',
653 [{ label: '' }, { label: '', width: 8, align: 'right' }],
654 [
655 ['Input', fmtTokens(total.input)],
656 ['Output', fmtTokens(total.output)],
657 ['Cache read', fmtTokens(total.cacheRead)],
658 ['Cache write', fmtTokens(total.cacheWrite)],
659 ],
660 false,
661 ),
662 )
663 } else {
664 const row = (name: string, t: typeof total) => [
665 name,
666 fmtTokens(t.input),
667 fmtTokens(t.output),
668 fmtTokens(t.cacheRead),
669 fmtTokens(t.cacheWrite),
670 String(t.requests),
671 ]
672 out.push(
673 table(
674 'totals',
675 [
676 { label: '' },
677 { label: 'Input', width: 8, align: 'right' },
678 { label: 'Output', width: 8, align: 'right' },
679 { label: 'Cache read', width: 12, align: 'right' },
680 { label: 'Cache write', width: 13, align: 'right' },
681 { label: 'Req', width: 6, align: 'right' },
682 ],
683 [row('Main', requests.main), row('Subagents', requests.agents), row('Total', total)],
684 ),
685 )
686 }
687 const models = Object.entries(requests.byModel).sort((a, b) => b[1].requests - a[1].requests)
688 if (models.length > 0) {
689 out.push(kv('models', 'Models', models.map(([m, t]) => `${modelId(m)} ${t.requests}`).join(' · ')))
690 }
691 if (requests.noResponse > 0) out.push(dim('no-response', `${requests.noResponse} ${plural(requests.noResponse, 'request')} got no response`))
692
693 return section('tokens', out)
694 }
695
696 const costSection = () => {
697 if (!measure?.cost) return section('cost', [heading('Cost'), dim('none', 'No cost ledger')])
698 const deltas = trends.cost.slice(1).map((s, i) => s.usd - trends.cost[i]!.usd)
699 const out: RenderChildren[] = [heading('Cost', `$${measure.cost.usd.toFixed(2)}`)]
700 if (deltas.length > 0) {
701 out.push(
702 kv(
703 'per-update',
704 'Per update',
705 `last $${deltas.at(-1)!.toFixed(2)} · avg $${(deltas.reduce((n, d) => n + d, 0) / deltas.length).toFixed(2)}`,
706 ),
707 )
708 }
709 const shown = deltas.slice(-sparkWidth)
710 if (shown.length > 1) {
711 out.push(
712 kv('trend', 'Trend', sparkline('trend', shown, Math.max(...shown), undefined, `Cost per update, last ${shown.length}: latest $${shown.at(-1)!.toFixed(2)}`)),
713 )
714 }
715
716 return section('cost', out)
717 }
718
719 const turnsSection = () => {
720 if (turns.count === 0) return section('turns', [heading('Turns'), dim('none', 'None yet')])
721 const out: RenderChildren[] = [
722 heading('Turns', String(turns.count)),
723 kv('duration', 'Duration', `avg ${fmtMs(turns.totalMs / turns.count)} · longest ${fmtMs(turns.longestMs)}`),
724 ]
725 const outcomes: string[] = []
726 if (turns.aborted > 0) outcomes.push(`${turns.aborted} interrupted`)
727 if (turns.refused > 0) outcomes.push(`${turns.refused} refused`)
728 if (turns.errored > 0) outcomes.push(`${turns.errored} failed`)
729 if (outcomes.length > 0) out.push(kv('outcomes', 'Outcomes', outcomes.join(' · ')))
730 const shown = turns.recent.map(t => t.ms).slice(-sparkWidth)
731 if (shown.length > 1) {
732 out.push(
733 kv('trend', 'Trend', sparkline('trend', shown, Math.max(...shown), undefined, `Turn durations, last ${shown.length}: latest ${fmtMs(shown.at(-1)!)}, longest ${fmtMs(Math.max(...shown))}`)),
734 )
735 }
736
737 return section('turns', out)
738 }
739
740 const toolsSection = () => {
741 const entries = Object.entries(tools).sort((a, b) => b[1].calls - a[1].calls)
742 if (entries.length === 0) return section('tools', [heading('Tools'), dim('none', 'No tool calls yet')])
743 const columns: Col[] = [
744 { label: '' },
745 { label: 'Calls', width: 7, align: 'right' },
746 { label: 'Errors', width: 8, align: 'right' },
747 ]
748 if (tier !== 'narrow') columns.push({ label: 'Denied', width: 8, align: 'right' })
749 if (tier === 'wide') columns.push({ label: 'Avg', width: 8, align: 'right' })
750 const rows = limited('tools', entries, TOP.tools).map(([name, t]) =>
751 [name, String(t.calls), String(t.errors), String(t.denied), fmtMs(t.totalMs / Math.max(1, t.calls))].slice(0, columns.length),
752 )
753 const fromAgents = entries.reduce((n, [, t]) => n + t.fromAgents, 0)
754
755 const calls = entries.reduce((n, [, t]) => n + t.calls, 0)
756
757 return section('tools', [
758 heading('Tools', `${calls} ${plural(calls, 'call')}`),
759 table('tools-rows', columns, rows),
760 moreButton('tools', entries.length, TOP.tools),
761 fromAgents > 0 ? dim('from-agents', `${fromAgents} calls came from subagents`) : null,
762 ])
763 }
764
765 const subagentsSection = () => {
766 const known = new Set(agents.list.map(a => a.id))
767 const orphans = Object.entries(agents.usage).filter(([id]) => !known.has(id))
768 if (agents.list.length === 0 && orphans.length === 0) {
769 return section('subagents', [heading('Subagents'), dim('none', 'No subagents this session')])
770 }
771 const running = agents.list.filter(a => a.status === 'running').length
772 const items = agents.list
773 .map(a => ({ name: a.name ?? a.type, status: a.status, usage: agents.usage[a.id], isRunning: a.status === 'running' }))
774 .sort((a, b) => Number(b.isRunning) - Number(a.isRunning) || (b.usage?.lastAt ?? 0) - (a.usage?.lastAt ?? 0))
775 const forks = orphans.reduce(
776 (n, [, u]) => ({ requests: n.requests + u.requests, input: n.input + u.input, output: n.output + u.output }),
777 { requests: 0, input: 0, output: 0 },
778 )
779 const rowOf = (name: string, status: string, u?: { requests: number; input: number; output: number; model?: string }) => [
780 name,
781 status,
782 u ? String(u.requests) : '-',
783 u ? fmtTokens(u.input) : '-',
784 u ? fmtTokens(u.output) : '-',
785 u?.model ? modelId(u.model) : '-',
786 ]
787 const rows = limited('subagents', items, TOP.subagents).map(a => rowOf(a.name, a.status, a.usage))
788 if (orphans.length > 0) rows.push(rowOf('Engine forks', '-', forks))
789 const pick = (indexes: number[]) => rows.map(r => indexes.map(i => r[i] ?? ''))
790 const table6 =
791 tier === 'wide'
792 ? table(
793 'agents-rows',
794 [
795 { label: '' },
796 { label: 'Status', width: 11 },
797 { label: 'Req', width: 6, align: 'right' },
798 { label: 'In', width: 7, align: 'right' },
799 { label: 'Out', width: 7, align: 'right' },
800 { label: 'Model', width: 14, align: 'right' },
801 ],
802 rows,
803 )
804 : tier === 'medium'
805 ? table(
806 'agents-rows',
807 [
808 { label: '' },
809 { label: 'Status', width: 11 },
810 { label: 'Req', width: 6, align: 'right' },
811 { label: 'In', width: 7, align: 'right' },
812 { label: 'Out', width: 7, align: 'right' },
813 ],
814 pick([0, 1, 2, 3, 4]),
815 )
816 : table(
817 'agents-rows',
818 [{ label: '' }, { label: 'Status', width: 10 }, { label: 'Out', width: 7, align: 'right' }],
819 pick([0, 1, 4]),
820 )
821
822 return section('subagents', [
823 heading('Subagents', `${running} running · ${agents.list.length} total`),
824 table6,
825 moreButton('subagents', items.length, TOP.subagents),
826 ])
827 }
828
829 const compactionsSection = () => {
830 if (compactions.count === 0 && compactions.subagentCount === 0) {
831 const none = snap
832 ? snap.isAutoCompactEnabled && snap.autoCompactThreshold !== undefined
833 ? `None. Auto-compact at ${fmtTokens(snap.autoCompactThreshold)}${
834 ctx?.tokens !== undefined ? ` (${fmtTokens(Math.max(0, snap.autoCompactThreshold - ctx.tokens))} to go)` : ''
835 }`
836 : 'None. Auto-compact is off'
837 : 'None yet'
838
839 return section('compactions', [heading('Compactions'), dim('none', none)])
840 }
841 const title = `${compactions.count}${compactions.subagentCount > 0 ? ` · ${compactions.subagentCount} in subagents` : ''}`
842 const showAgo = cols >= COMPACTION_AGO_MIN
843 const rows = [...compactions.recent].reverse().map(c => {
844 const range = c.before !== undefined && c.after !== undefined ? `${fmtTokens(c.before)} → ${fmtTokens(c.after)}` : '-'
845
846 return showAgo ? [c.trigger, range, fmtAgo(now - c.at)] : [c.trigger, range]
847 })
848
849 return section('compactions', [
850 heading('Compactions', title),
851 table(
852 'compaction-rows',
853 showAgo ? [{ label: '', width: 10 }, { label: '', width: 18 }, { label: '' }] : [{ label: '', width: 8 }, { label: '' }],
854 rows,
855 false,
856 ),
857 ])
858 }
859
860 const sessionSection = () => {
861 if (!info) return section('session', [heading('Session'), dim('none', 'Not counted yet')])
862 const started = `${fmtClock(info.startedAt, true)}${
863 requests.trackedSince > info.startedAt + 60_000 ? ` · tracking since ${fmtClock(requests.trackedSince)}` : ''
864 }`
865 const builtAt = info.builtAt ? Date.parse(info.builtAt) : NaN
866
867 return section('session', [
868 heading('Session'),
869 kv('id', 'Id', shortId(info.id)),
870 model ? kv('model', 'Model', model.id) : null,
871 kv('started', 'Started', started),
872 kv('engine', 'Engine', `${info.version}${Number.isFinite(builtAt) ? ` (built ${fmtDate(builtAt)})` : ''}`),
873 kv('dir', 'Dir', info.cwd, 'truncate-start'),
874 kv('prompts', 'Prompts', String(info.prompts)),
875 ])
876 }
877
878 return (
879 <Box key="meter" flexDirection="column">
880 {header()}
881 {contextSection()}
882 {limitsSection()}
883 {cacheSection()}
884 {costSection()}
885 {tokensSection()}
886 {turnsSection()}
887 {toolsSection()}
888 {subagentsSection()}
889 {compactionsSection()}
890 {sessionSection()}
891 {isTerminal ? null : (
892 <Box key="close-row" marginTop={1}>
893 <Button
894 key="close"
895 role="dismiss"
896 label="Close"
897 onPress={() => {
898 void actions.close()
899 }}
900 />
901 </Box>
902 )}
903 </Box>
904 )
905}
906hooks/draw.tsx 55 lines1import type { Color, Elements, RenderChildren } from 'claude-code'
2
3import type { Tone } from './format'
4import { gaugeSvg } from './svg'
5
6export type Els = Elements['terminal'] | Elements['desktop']
7export type Surface = 'terminal' | 'desktop'
8export type Run = { text: string; color?: Color; dim?: boolean; bold?: boolean }
9
10export const textWidth = (s: string) => [...s].length
11export const runsWidth = (runs: readonly Run[]) => runs.reduce((n, r) => n + textWidth(r.text), 0)
12export const dimRun = (text: string): Run => ({ text, dim: true })
13export const toneRun = (text: string, tone: Tone): Run =>
14 tone === 'rest' ? { text, bold: false } : tone === 'idle' ? { text, dim: true, bold: false } : { text, color: tone, bold: true }
15
16export const filledCells = (pct: number, cells: number) => {
17 const ratio = Math.min(Math.max(pct, 0), 100) / 100
18
19 return ratio === 0 ? 0 : Math.max(1, Math.round(ratio * cells))
20}
21
22export const gaugeRuns = (pct: number, tone: Tone, cells: number): Run[] => {
23 const filled = filledCells(pct, cells)
24 const runs: Run[] = []
25 if (filled > 0) runs.push(toneRun('━'.repeat(filled), tone))
26 if (cells - filled > 0) runs.push(dimRun('─'.repeat(cells - filled)))
27
28 return runs
29}
30
31export const runText = (Text: Els['Text'], key: string, r: Run) => (
32 <Text key={key} color={r.color} dimColor={r.dim} bold={r.bold}>
33 {r.text}
34 </Text>
35)
36
37export const runsText = (Text: Els['Text'], key: string, runs: readonly Run[]) =>
38 runs.length === 1 ? runText(Text, key, runs[0]!) : <Text key={key}>{runs.map((r, i) => runText(Text, `r${i}`, r))}</Text>
39
40export const drawGauge = (
41 els: Els,
42 surface: Surface,
43 key: string,
44 pct: number,
45 tone: Tone,
46 size: { cells: number; px: number; height?: number },
47 alt: string,
48): RenderChildren => {
49 if (surface === 'terminal') return runsText(els.Text, key, gaugeRuns(pct, tone, size.cells))
50 const { Svg } = els as Elements['desktop']
51 const doc = gaugeSvg(pct, tone, alt, size.px, size.height ?? 6)
52
53 return <Svg key={key} source={doc.source} width={doc.width} height={doc.height} alt={doc.alt} />
54}
55hooks/svg.ts 105 lines1import type { SnapCategory, SnapSquare } from '../types'
2import { SVG_COLORS, type Tone } from './format'
3
4export type SvgDoc = { source: string; width: number; height: number; alt: string }
5
6const LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']
7const DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']
8const NEUTRAL = 'rgba(128,128,128,0.28)'
9const IDLE_FILL = 'rgba(128,128,128,0.6)'
10const SQUARE = 10
11const GAP = 3
12const BAR = 4
13const BAR_GAP = 2
14const BAR_HEIGHT = 20
15
16const PALETTE_SLOTS = LIGHT.length
17
18const PALETTE_STYLE = `<style>${LIGHT.map((c, i) => `.s${i + 1}{fill:${c}}`).join('')}@media (prefers-color-scheme: dark){${DARK.map((c, i) => `.s${i + 1}{fill:${c}}`).join('')}}</style>`
19
20let memo: { key: string; value: unknown } | undefined
21
22export const memoized = <T>(key: string, build: () => T): T => {
23 if (memo?.key === key) return memo.value as T
24 const value = build()
25 memo = { key, value }
26
27 return value
28}
29
30const wrap = (width: number, height: number, body: string, hasPalette: boolean): string =>
31 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${hasPalette ? PALETTE_STYLE : ''}${body}</svg>`
32
33export const slotMap = (categories: readonly SnapCategory[]) => {
34 const slots: Record<string, number> = {}
35 let next = 1
36 for (const c of categories) {
37 if (c.kind !== 'used') continue
38 slots[c.name] = next <= PALETTE_SLOTS ? next++ : 0
39 }
40
41 return slots
42}
43
44const toneFill = (tone: Tone) =>
45 tone === 'rest' ? ' class="s1"' : tone === 'idle' ? ` fill="${IDLE_FILL}"` : ` fill="${SVG_COLORS[tone]}"`
46
47export const gaugeSvg = (pct: number, tone: Tone, alt: string, width = 160, height = 6): SvgDoc => {
48 const ratio = Math.min(Math.max(pct, 0), 100) / 100
49 const fill = ratio === 0 ? 0 : Math.max(3, Math.round(ratio * width))
50 const rx = height / 2
51 const body = `<rect width="${width}" height="${height}" rx="${rx}" fill="${NEUTRAL}"/><rect width="${fill}" height="${height}" rx="${rx}"${toneFill(tone)}/>`
52
53 return { source: wrap(width, height, body, tone === 'rest'), width, height, alt }
54}
55
56const squareClass = (slot: number | undefined) => (slot ? ` class="s${slot}"` : ` fill="${NEUTRAL}"`)
57
58export const gridSvg = (
59 squares: readonly SnapSquare[],
60 perRow: number,
61 slots: Record<string, number>,
62 alt: string,
63): SvgDoc => {
64 const rows = Math.ceil(squares.length / perRow)
65 const width = perRow * (SQUARE + GAP) - GAP
66 const height = rows * (SQUARE + GAP) - GAP
67 const body = squares
68 .map((q, i) => {
69 const x = (i % perRow) * (SQUARE + GAP)
70 const y = Math.floor(i / perRow) * (SQUARE + GAP)
71 const slot = q.kind === 'used' ? slots[q.category] : undefined
72 const opacity = q.kind === 'used' && q.fullness < 0.7 ? ' fill-opacity="0.45"' : ''
73
74 return `<rect x="${x}" y="${y}" width="${SQUARE}" height="${SQUARE}" rx="2"${squareClass(slot)}${opacity}/>`
75 })
76 .join('')
77
78 return { source: wrap(width, height, body, true), width, height, alt }
79}
80
81export const swatchSvg = (slot: number | undefined, alt: string): SvgDoc => {
82 const body = `<rect width="${SQUARE}" height="${SQUARE}" rx="2"${squareClass(slot)}/>`
83
84 return { source: wrap(SQUARE, SQUARE, body, Boolean(slot)), width: SQUARE, height: SQUARE, alt }
85}
86
87export const barsSvg = (
88 values: readonly number[],
89 max: number,
90 tones: readonly (Tone | undefined)[] | undefined,
91 alt: string,
92): SvgDoc => {
93 const width = Math.max(1, values.length * (BAR + BAR_GAP) - BAR_GAP)
94 const toneAt = (i: number): Tone => tones?.[i] ?? 'rest'
95 const body = values
96 .map((v, i) => {
97 const h = max <= 0 ? 2 : Math.min(BAR_HEIGHT, Math.max(2, Math.round((v / max) * BAR_HEIGHT)))
98
99 return `<rect x="${i * (BAR + BAR_GAP)}" y="${BAR_HEIGHT - h}" width="${BAR}" height="${h}" rx="1"${toneFill(toneAt(i))}/>`
100 })
101 .join('')
102
103 return { source: wrap(width, BAR_HEIGHT, body, values.some((_, i) => toneAt(i) === 'rest')), width, height: BAR_HEIGHT, alt }
104}
105types/index.d.ts 104 lines1export type CacheStats = {
2 lastRead: number
3 lastWrite: number
4 lastUncached: number
5 totalRead: number
6 totalWrite: number
7 totalUncached: number
8 lastAt: number
9}
10
11export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
12
13export type Measure = {
14 context: { tokens?: number; window: number; percent?: number }
15 rateLimits: RateLimit[]
16 cost?: { usd: number }
17}
18
19export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
20export type Totals = Tokens & { requests: number }
21export type RequestSample = { at: number; ms: number; hit: number | null; output: number }
22export type RequestStats = {
23 trackedSince: number
24 main: Totals
25 agents: Totals
26 byModel: Record<string, Totals>
27 noResponse: number
28 history: RequestSample[]
29 messageCount: number
30}
31export type ModelState = { selected: string | null; selectedAt: number; answered: string | null; requested: string | null; effort: string | null; answeredRequestedAt: number }
32export type ResolvedModel = { name: string; id: string; fallbackFrom: string | null; effort: string | null }
33export type TurnReason = 'answer' | 'aborted' | 'refusal' | 'error'
34export type TurnStats = {
35 count: number
36 totalMs: number
37 longestMs: number
38 aborted: number
39 errored: number
40 refused: number
41 recent: { at: number; ms: number; reason: TurnReason }[]
42}
43export type ToolStat = { calls: number; errors: number; denied: number; totalMs: number; fromAgents: number }
44export type AgentUsage = Totals & { model: string; lastAt: number }
45export type AgentRow = { id: string; type: string; description: string; status: string; name?: string }
46export type AgentStats = { usage: Record<string, AgentUsage>; list: AgentRow[] }
47export type Compaction = { at: number; trigger: 'manual' | 'auto' | 'plugin'; before?: number; after?: number }
48export type CompactionStats = { count: number; subagentCount: number; recent: Compaction[] }
49export type LimitSample = { at: number; pct: number; resetsAt?: string }
50export type Trends = { cost: { at: number; usd: number }[]; limits: Record<string, LimitSample[]> }
51export type SnapCategory = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' | 'deferred' }
52export type SnapSquare = { category: string; color: string; kind: SnapCategory['kind']; fullness: number }
53export type BreakdownSnap = {
54 at: number
55 detail: 'full' | 'summary'
56 contextTokens?: number
57 totalTokens: number
58 rawMaxTokens: number
59 percentage: number
60 autocompactSource: string
61 autoCompactThreshold?: number
62 isAutoCompactEnabled: boolean
63 categories: SnapCategory[]
64 squares: SnapSquare[]
65 memoryFiles: { path: string; type: string; tokens: number }[]
66 memoryTotal: { count: number; tokens: number }
67 mcpServers: { server: string; tools: number; loaded: number; tokens: number }[]
68 skills?: { total: number; included: number; tokens: number; top: { name: string; tokens: number }[] }
69 agents: { agentType: string; tokens: number }[]
70 slashCommands?: { total: number; included: number; tokens: number }
71}
72export type BreakdownStatus = { state: 'idle' } | { state: 'loading'; since: number } | { state: 'error' }
73export type BreakdownState = { snap: BreakdownSnap | null; status: BreakdownStatus }
74export type SessionInfo = {
75 id: string
76 version: string
77 builtAt?: string
78 startedAt: number
79 prompts: number
80 cwd: string
81 at: number
82}
83
84declare module 'claude-code' {
85 interface PluginState {
86 'meter': {
87 cache: CacheStats | null
88 measure: Measure | null
89 now: number
90 requests: RequestStats
91 turns: TurnStats
92 tools: Record<string, ToolStat>
93 agents: AgentStats
94 compactions: CompactionStats
95 trends: Trends
96 breakdown: BreakdownState
97 info: SessionInfo | null
98 model: ModelState
99 expanded: string[]
100 epoch: number
101 }
102 }
103}
104