SLOPSHOPPER

meter

Session meters above the prompt; /meter opens detailed metrics

newpanebandguardcommandtimer
v0.2.0MITupdated 2026-10-06theishandubey/claude-mods/plugins/meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meter
│ ┃ Meter ✕ › fix the failing auth test and add an audit log call │ ┃ Opus 5.5 [ Retry ] │ ┃ up 30m · $0.42 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Context 49% · 97k / 200k ⏺ Update(src/auth.ts) │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────── ⏺ Bash(bun test) │ ┃ Could not count the context ⎿ 3 pass, 1 fail │ ┃ │ ┃ Usage limits ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 5h ━━━━━─────────── 31% │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Prompt cache │ ┃ None yet › /meter │ ┃ │ ┃ Cost $0.42 │ ┃ │ ┃ Tokens │ ┃ None yet │ ┃ │ ┃ Turns 1 │ ┃ Duration avg 42s · longest 42s │ ┃ │ ┃ Tools 9 calls │ ┃ Calls Errors Denied │ ┃ Bash 4 1 0 Context ━━━━━───── 49% 97k / 200k 5h ━━──── 31% $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ━━━━━───── 49% 97k / 200k 5h ━━──── 31% $0.42
Pane · Meter
Opus 5.5 [ Retry ] up 30m · $0.42 Context 49% · 97k / 200k ━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────── Could not count the context Usage limits 5h ━━━━━─────────── 31% Prompt cache None yet Cost $0.42 Tokens None yet Turns 1 Duration avg 42s · longest 42s Tools 9 calls Calls Errors Denied Bash 4 1 0 Write 2 0 0 Read 1 0 0 Grep 1 0 0 Edit 1 0 0 Subagents No subagents this session Compactions None yet Session Id preview-session Model claude-opus-5-5 Started Thu 08:23 · tracking since 08:53 Engine 2.1.289 (built Oct 3) Dir /work/app Prompts 1
README

claude-mods

A Claude Code plugin marketplace of mods: function-hooks plugins that draw bands, panes and other UI inside Claude Code.

Plugins

PluginWhat it does
meterBand 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-graphPane 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-handoffOnce 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.
statuslineA 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.

Install

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

Layout

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

Development

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.

License

MIT

Source 8 files
hooks/register.tsx 433 lines
1import { 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}
433
hooks/band.tsx 245 lines
1import 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}
245
hooks/format.ts 99 lines
1export 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)
99
hooks/metrics.ts 290 lines
1import 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}
290
hooks/pane.tsx 906 lines
1import 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}
906
hooks/draw.tsx 55 lines
1import 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}
55
hooks/svg.ts 105 lines
1import 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}
105
types/index.d.ts 104 lines
1export 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