SLOPSHOPPER

session-meter

Shows a live session pane (/ctx): context breakdown, usage limits with pace, tool calls and model requests; sends the model a one-time note when limits or…

newpaneguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-meter
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for the first context reading. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ctx │ ⎿ session-meter: Session pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
Waiting for the first context reading.
README

session-meter

A docked pane that shows what is happening in a Claude Code session while you work.

The session-meter pane with context, limits, tools and requests

What it shows

  • Context: what fills the context window by category, where auto-compact kicks in, the context size over turns, and the three heaviest items (memory files, MCP servers, the skills listing, slash commands, agents).
  • Limits: the 5-hour and 7-day usage windows, time to reset, and whether the current pace lasts until the reset.
  • Tools: recent tool calls with their target and duration; running calls stay on top, subagent calls are marked ↳.
  • Requests: each model request with its loop (main or subagent type), input and output tokens, cache hit rate and duration. A low cache rate turns yellow or red.

The note to the model

It also sends the model one hidden note when a threshold is crossed, and again only after the figure drops 10 points below it:

ThresholdWhat the model is told
5h window at 80%Mention it in one line and keep going
7d window at 80% and 90%Warn clearly and ask before starting a large task
Context at 60%When new work starts, suggest /compact, a fresh session, or a short handoff, whichever fits

The note goes in as prompt context, not as a system prompt change, so the prompt cache is not invalidated. The pane lists the last five notes it sent.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install session-meter@claude-code-mods

Restart Claude Code. The pane opens on its own in a terminal 144 columns or wider; otherwise type /ctx. It docks beside the transcript in fullscreen mode, from 110 columns.

To keep the screen clear, turn off Open the pane at start in /config (the autoOpen option). /ctx still opens the pane, and the notes to the model still go out.

Notes

  • Context percentages come from the same breakdown as /context, so they follow CLAUDE_CODE_AUTO_COMPACT_WINDOW if you set it.
  • Usage limits show after the first model reply of a session; pace needs about 10 minutes of history in the current window.
  • Everything stays local. The pane reads Claude Code's own session data and makes no network calls.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh session-meter
Source 2 files
hooks/register.tsx 441 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextBreakdown } from 'claude-code'
3
4import type { MeterBreakdown, MeterItem, MeterNote, MeterReading, MeterRequest, MeterSample, MeterTool } from '../types'
5
6const PANE = 'ctx'
7// A level fires again only once its figure has fallen this far below it: a window reset or a /compact.
8const REARM = 10
9// Pace needs this much history in the current window before it projects anything.
10const PACE_MIN_MS = 10 * 60_000
11const EIGHTHS = ' ▏▎▍▌▋▊▉'
12const KEEP = 30
13
14const LEVELS = [
15  { id: 'five_hour:80', kind: 'five_hour', at: 80 },
16  { id: 'seven_day:80', kind: 'seven_day', at: 80 },
17  { id: 'seven_day:90', kind: 'seven_day', at: 90 },
18  { id: 'context:60', kind: 'context', at: 60 },
19]
20type Level = (typeof LEVELS)[number]
21const WINDOWS = [
22  { kind: 'five_hour', label: '5h' },
23  { kind: 'seven_day', label: '7d' },
24]
25
26const reading = atom({ plugin: 'session-meter', key: 'reading' } as const, { context: null, limits: [] } as MeterReading)
27const fired = atom({ plugin: 'session-meter', key: 'fired' } as const, [] as string[])
28const breakdown = atom({ plugin: 'session-meter', key: 'breakdown' } as const, null as MeterBreakdown | null)
29const history = atom({ plugin: 'session-meter', key: 'history' } as const, [] as number[])
30const samples = atom({ plugin: 'session-meter', key: 'samples' } as const, [] as MeterSample[])
31const notes = atom({ plugin: 'session-meter', key: 'notes' } as const, [] as MeterNote[])
32const tools = atom({ plugin: 'session-meter', key: 'tools' } as const, [] as MeterTool[])
33const requests = atom({ plugin: 'session-meter', key: 'requests' } as const, [] as MeterRequest[])
34
35// Subagent ids never change type, so one lookup per id is enough.
36const agentTypes = new Map<string, string>()
37
38const valueOf = (r: MeterReading, kind: string) =>
39  kind === 'context' ? (r.context ?? undefined) : r.limits.find(l => l.kind === kind)?.percentUsed
40
41export const cross = (r: MeterReading, firedIds: readonly string[]) => {
42  const kept = firedIds.filter(id => {
43    const level = LEVELS.find(l => l.id === id)
44    const value = level && valueOf(r, level.kind)
45    return level !== undefined && (value === undefined || value >= level.at - REARM)
46  })
47  const fresh = LEVELS.filter(l => !kept.includes(l.id) && (valueOf(r, l.kind) ?? 0) >= l.at)
48  return { fired: [...kept, ...fresh.map(l => l.id)], fresh }
49}
50
51export const duration = (ms: number) => {
52  const min = Math.max(0, Math.floor(ms / 60_000))
53  if (min >= 1440) return `${Math.floor(min / 1440)}d${Math.floor((min % 1440) / 60)}h`
54  if (min >= 60) return `${Math.floor(min / 60)}h${min % 60}m`
55  return `${min}m`
56}
57
58export const elapsed = (ms: number) => {
59  const s = Math.max(0, Math.round(ms / 1000))
60  return ms < 60_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)}s` : `${Math.floor(s / 60)}m${s % 60}s`
61}
62
63const resetsIn = (resetsAt: string | undefined, now: number) => (resetsAt ? Date.parse(resetsAt) - now : 0)
64
65export const note = (r: MeterReading, fresh: readonly Level[], now: number) => {
66  const kinds = [...new Set(fresh.map(l => l.kind))]
67  const lines = kinds.map(kind => {
68    const pct = Math.round(valueOf(r, kind) ?? 0)
69    const resets = duration(resetsIn(r.limits.find(l => l.kind === kind)?.resetsAt, now))
70    if (kind === 'five_hour')
71      return `- The 5-hour usage window is at ${pct}% (resets in ${resets}). Tell the user in one short line. Do not stop or slow down: the session continues automatically at the usage limit, so hitting it only pauses work until the reset.`
72    if (kind === 'seven_day')
73      return `- The 7-day usage window is at ${pct}% (resets in ${resets}). Warn the user clearly: if it runs out, work stops and does NOT resume on its own when the reset is over 24 hours away. Before starting any large new task, ask the user whether to proceed.`
74    return `- The context window is ${pct}% full. From now on, when a request starts new work: if it continues the current work, suggest /compact at a natural break; if it is unrelated and needs nothing from this conversation, suggest a fresh session (/clear or a new terminal); if it is new but needs a few facts from here, suggest a short handoff and then a fresh session. If the current request already starts new work, apply this now. Suggest it once; the user decides.`
75  })
76  return ['session-meter: an automatic note the user does not see. Mention it once; do not repeat it in later replies.', ...lines].join('\n')
77}
78
79const noteLabel = (fresh: readonly Level[]) => {
80  const top = new Map(fresh.map(l => [l.kind, l.at]))
81  const name: Record<string, string> = { five_hour: '5h window', seven_day: '7d window', context: 'context' }
82  return [...top].map(([kind, at]) => `${name[kind]} crossed ${at}%`).join(', ')
83}
84
85// Projects when a window fills, from the samples taken since its last reset; idle time counts, so it errs late, not early.
86export const pace = (list: readonly MeterSample[], kind: string, now: number) => {
87  const mine = list.filter(s => s.kind === kind)
88  const last = mine.at(-1)
89  if (!last) return { kind: 'measuring' as const }
90  const first = mine.find(s => s.resetsAt === last.resetsAt)
91  if (!first || now - first.at < PACE_MIN_MS || last.pct <= first.pct) return { kind: 'measuring' as const }
92  const perMs = (last.pct - first.pct) / (now - first.at)
93  const fullIn = (100 - last.pct) / perMs
94  const resets = resetsIn(last.resetsAt, now)
95  return fullIn < resets ? { kind: 'full' as const, inMs: fullIn } : { kind: 'fine' as const }
96}
97
98// A bar chart in braille, two values per cell and four dots of height per row, bottom row first in the dot math.
99export const braille = (values: readonly number[], max: number, cols: number, rows: number) => {
100  const LEFT = [0x40, 0x04, 0x02, 0x01]
101  const RIGHT = [0x80, 0x20, 0x10, 0x08]
102  const heights = values.slice(-cols * 2).map(v => Math.round((Math.min(v, max) / Math.max(1, max)) * rows * 4))
103  if (heights.length % 2) heights.unshift(0)
104  return Array.from({ length: rows }, (_, r) => {
105    const floor = (rows - 1 - r) * 4
106    let line = ''
107    for (let i = 0; i < heights.length; i += 2) {
108      const dots = (h: number, bits: number[]) => bits.slice(0, Math.max(0, Math.min(4, h - floor))).reduce((a, b) => a | b, 0)
109      line += String.fromCharCode(0x2800 + dots(heights[i] ?? 0, LEFT) + dots(heights[i + 1] ?? 0, RIGHT))
110    }
111    return line
112  })
113}
114
115export const cells = (part: number, whole: number, width: number) => (whole > 0 ? Math.round((part / whole) * width) : 0)
116
117export const tokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n))
118
119export const heaviest = (b: Pick<SessionContextBreakdown, 'memoryFiles' | 'mcpTools' | 'skills' | 'slashCommands' | 'agents'>) => {
120  const servers = new Map<string, number>()
121  for (const t of b.mcpTools) if (t.isLoaded) servers.set(t.serverName, (servers.get(t.serverName) ?? 0) + t.tokens)
122  const items: MeterItem[] = [
123    ...b.memoryFiles.map(f => ({ name: f.path.split('/').pop() ?? f.path, tokens: f.tokens })),
124    ...[...servers].map(([name, n]) => ({ name: `${name} MCP`, tokens: n })),
125    ...(b.skills ? [{ name: 'Skills listing', tokens: b.skills.tokens }] : []),
126    ...(b.slashCommands ? [{ name: 'Slash commands', tokens: b.slashCommands.tokens }] : []),
127    ...b.agents.map(a => ({ name: `${a.agentType} agent`, tokens: a.tokens })),
128  ]
129  return items.sort((x, y) => y.tokens - x.tokens).slice(0, 3)
130}
131
132// What a tool call is about, in a few words: the command, the file's name, the pattern, the URL.
133export const target = (input: Record<string, unknown>) => {
134  for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'description', 'query', 'prompt', 'skill']) {
135    const value = input[key]
136    if (typeof value !== 'string' || !value) continue
137    const text = key === 'file_path' || key === 'path' ? (value.split('/').pop() ?? value) : value
138    return text.replace(/\s+/g, ' ').trim()
139  }
140  return ''
141}
142
143export const toolName = (tool: string) => (tool.startsWith('mcp__') ? (tool.split('__').at(-1) ?? tool) : tool)
144
145// Rows are runs of styled text, so their width is known before drawing: frames pad and cut them exactly.
146export type Seg = { t: string; c?: string; d?: boolean; b?: boolean }
147type Row = Seg[]
148
149const width = (row: Row) => row.reduce((n, s) => n + s.t.length, 0)
150
151export const fit = (row: Row, w: number): Row => {
152  const out: Row = []
153  let n = 0
154  for (const s of row) {
155    const room = w - n
156    if (room <= 0) break
157    if (s.t.length <= room) {
158      out.push(s)
159      n += s.t.length
160    } else {
161      out.push({ ...s, t: `${s.t.slice(0, room - 1)}…` })
162      n = w
163    }
164  }
165  if (n < w) out.push({ t: ' '.repeat(w - n) })
166  return out
167}
168
169const spread = (left: Row, right: Row, w: number): Row => [...left, { t: ' '.repeat(Math.max(1, w - width(left) - width(right))) }, ...right]
170
171const zone = (pct: number) => (pct >= 80 ? 'error' : pct >= 60 ? 'warning' : 'success')
172
173// A meter with eighth-cell precision; filled cells take the colour of the zone they sit in, so the bar warms as it fills.
174export const meter = (pct: number, w: number): Row => {
175  const eighths = Math.round((Math.min(100, Math.max(0, pct)) / 100) * w * 8)
176  const out: Row = []
177  for (let i = 0; i < w; i++) {
178    const left = eighths - i * 8
179    const ch = left >= 8 ? '█' : left > 0 ? (EIGHTHS[left] ?? ' ') : '·'
180    const seg = left > 0 ? { t: ch, c: zone(((i + 1) / w) * 100) } : { t: ch, d: true }
181    const prev = out.at(-1)
182    if (prev && prev.c === seg.c && prev.d === seg.d) prev.t += ch
183    else out.push(seg)
184  }
185  return out
186}
187
188const refresh = async ($: EngineInterface) => {
189  const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
190  const b = context.breakdown
191  // The breakdown measures against the compaction window (CLAUDE_CODE_AUTO_COMPACT_WINDOW when set); `context.percent` uses the model's full window.
192  await update($, reading, () => ({ context: b?.percentage ?? context.percent ?? null, limits: [...rateLimits] }))
193  if (!b) return
194  await update($, breakdown, () => ({
195    percent: b.percentage,
196    tokens: b.totalTokens,
197    window: b.rawMaxTokens,
198    threshold: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
199    categories: b.categories.filter(c => !c.isDeferred).map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
200    heaviest: heaviest(b),
201  }))
202}
203
204const sample = async ($: EngineInterface, limits: MeterReading['limits']) => {
205  const at = await $.clock.now()
206  await update($, samples, list => [...list, ...limits.map(l => ({ kind: l.kind, at, pct: l.percentUsed, resetsAt: l.resetsAt }))].slice(-200))
207}
208
209const agentLabel = async ($: EngineInterface, id: string | undefined) => {
210  if (!id) return 'main'
211  // The engine's own forks (compaction, memory) carry ids no list names, so they are never cached.
212  const type = agentTypes.get(id) ?? (await $.agent.list()).find(a => a.id === id)?.type
213  if (type) agentTypes.set(id, type)
214  return type ?? 'fork'
215}
216
217export const register: Register = (on, options) => {
218  on('session.start', async ($, e, next) => {
219    await $.command.register({ name: 'ctx', description: 'Show what fills the context window and how fast limits run down' })
220    await refresh($)
221    await sample($, (await read($, reading)).limits)
222    if (options.autoOpen !== false) void $.ui.open({ id: PANE, title: 'Session' })
223    return next(e)
224  })
225
226  on('command.run', { command: 'ctx' }, async $ => {
227    await refresh($)
228    const opened = await $.ui.open({ id: PANE, title: 'Session' })
229    return { text: opened.isPlaced ? 'Session pane opened.' : `Session pane is waiting: ${opened.reason}` }
230  })
231
232  on('session.measure', async ($, e, next) => {
233    await update($, reading, r => ({ context: r.context, limits: [...e.rateLimits] }))
234    if (e.changed.includes('rateLimits')) await sample($, e.rateLimits)
235    if (e.changed.includes('context')) {
236      if (e.context.tokens !== undefined) await update($, history, list => [...list, e.context.tokens ?? 0].slice(-120))
237      await refresh($)
238    }
239    return next(e)
240  })
241
242  on('prompt.submit', async ($, e, next) => {
243    const r = await read($, reading)
244    const crossed = cross(r, await read($, fired))
245    await update($, fired, () => crossed.fired)
246    if (crossed.fresh.length === 0) return next(e)
247    const now = await $.clock.now()
248    await update($, notes, list => [...list, { at: now, text: noteLabel(crossed.fresh) }].slice(-5))
249    return next({ ...e, context: [...(e.context ?? []), note(r, crossed.fresh, now)] })
250  })
251
252  on('tool.call', async ($, e, next) => {
253    const at = await $.clock.now()
254    const id = e.tool_use_id ?? `${e.tool}:${at}`
255    const entry: MeterTool = { id, name: toolName(e.tool), target: target(e as unknown as Record<string, unknown>), sub: e.agentId !== undefined, at }
256    await update($, tools, list => [entry, ...list.filter(t => t.id !== id)].slice(0, KEEP))
257    let ok = false
258    try {
259      const result = await next(e)
260      ok = result.deny === undefined && !result.isError
261      return result
262    } finally {
263      const ms = (await $.clock.now()) - at
264      await update($, tools, list => list.map(t => (t.id === id ? { ...t, ms, ok } : t)))
265    }
266  })
267
268  on('turn.step', async function* ($, e, next) {
269    const at = await $.clock.now()
270    const result = yield* next(e)
271    const usage = result.usage
272    if (usage) {
273      try {
274        const ms = (await $.clock.now()) - at
275        const agent = await agentLabel($, e.agentId)
276        const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
277        const row = { at, agent, input, cached: usage.cache_read_input_tokens, output: usage.output_tokens, ms, model: usage.model }
278        await update($, requests, list => [row, ...list].slice(0, KEEP))
279      } catch {
280        // The log is a convenience: a failed write must never break the model's response.
281      }
282    }
283    return result
284  })
285
286  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
287    const { Box, Text } = $.ui.resolve(e)
288    const b = await read($, breakdown)
289    const r = await read($, reading)
290    const turns = await read($, history)
291    const list = await read($, samples)
292    const sent = await read($, notes)
293    const calls = await read($, tools)
294    const asks = await read($, requests)
295    const now = await $.clock.now()
296    const w = Math.max(28, e.props.bodyColumns - 1)
297    const inner = w - 6
298    if (!b) return <Text dimColor>Waiting for the first context reading.</Text>
299
300    const draw = (row: Row, width: number) =>
301      fit(row, width).map((s, j) => <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b}>{s.t}</Text>)
302    const frame = (title: string, tone: string, rows: Row[], right = '') => {
303      const rule = Math.max(0, w - 6 - title.length - (right ? right.length + 2 : 0))
304      return (
305        <Box key={title} flexDirection="column" marginTop={1}>
306          <Text>
307            <Text color={tone} dimColor>╭─ </Text>
308            <Text color={tone} bold>{title}</Text>
309            <Text color={tone} dimColor> {'─'.repeat(rule)}</Text>
310            {right ? <Text dimColor> {right} </Text> : null}
311            <Text color={tone} dimColor>─╮</Text>
312          </Text>
313          {rows.map((row, i) => (
314            <Text key={String(i)}>
315              <Text color={tone} dimColor>│  </Text>
316              {draw(row, inner)}
317              <Text color={tone} dimColor>  │</Text>
318            </Text>
319          ))}
320          <Text color={tone} dimColor>╰{'─'.repeat(w - 2)}╯</Text>
321        </Box>
322      )
323    }
324
325    // Context: one stacked bar of what fills it, the compaction mark, a legend, the size over turns, the heaviest items.
326    const used = b.categories.filter(c => c.kind === 'used' && c.tokens > 0)
327    const bar: Row = used.map(c => ({ t: '█'.repeat(cells(c.tokens, b.window, inner)), c: c.color }))
328    const filled = width(bar)
329    const mark = b.threshold === undefined ? -1 : Math.min(inner - 1, cells(b.threshold, b.window, inner))
330    for (let i = filled; i < inner; i++) bar.push(i === mark ? { t: '┊', c: 'warning' } : { t: '·', d: true, c: mark >= 0 && i > mark ? 'error' : undefined })
331    // The legend is a two-column grid, names left and counts right-aligned in each column.
332    const colW = Math.floor((inner - 3) / 2)
333    const keys = [
334      ...used.map(c => ({ mark: '■', c: c.color, name: c.name, n: tokens(c.tokens) })),
335      ...(b.threshold === undefined ? [] : [{ mark: '┊', c: 'warning', name: 'compacts at', n: tokens(b.threshold) }]),
336    ]
337    const key = (k: (typeof keys)[number]): Row => spread([{ t: `${k.mark} `, c: k.c }, { t: k.name.slice(0, colW - k.n.length - 3) }], [{ t: k.n, d: true }], colW)
338    const legend: Row[] = []
339    for (let i = 0; i < keys.length; i += 2) {
340      const [a, z] = [keys[i], keys[i + 1]]
341      if (a) legend.push([...key(a), ...(z ? [{ t: '   ' }, ...key(z)] : [])])
342    }
343    const last = turns.at(-1)
344    const prev = turns.at(-2)
345    const delta = last !== undefined && prev !== undefined ? last - prev : undefined
346    const chartCols = inner - 12
347    const chart = turns.length < 2 ? [] : braille(turns, b.window, chartCols, 2)
348    const contextRows: Row[] = [
349      spread([{ t: tokens(b.tokens), b: true }, { t: ` of ${tokens(b.window)}`, d: true }], [{ t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true }], inner),
350      [],
351      bar,
352      [],
353      ...legend,
354      [],
355      ...(chart.length
356        ? [
357            [{ t: chart[0] ?? '', c: 'suggestion' }, { t: '  ' }, { t: `${delta !== undefined && delta >= 0 ? '+' : '-'}${tokens(Math.abs(delta ?? 0))}`, b: true }],
358            [{ t: chart[1] ?? '', c: 'suggestion' }, { t: '  last turn', d: true }],
359          ]
360        : [[{ t: 'Size over turns shows after two turns.', d: true }]]),
361      ...(b.heaviest.length ? [[] as Row, ...b.heaviest.map(item => spread([{ t: '▸ ', d: true }, { t: item.name }], [{ t: tokens(item.tokens), d: true }], inner))] : []),
362    ]
363
364    // Limits: each window's meter, its reset, and whether the current pace lasts until then.
365    const limitRows: Row[] = []
366    const tones: string[] = []
367    const glance: Row = [{ t: 'ctx ', d: true }, { t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true }]
368    for (const win of WINDOWS) {
369      const limit = r.limits.find(l => l.kind === win.kind)
370      if (!limit) continue
371      const p = pace(list, win.kind, now)
372      const tone = limit.percentUsed >= 80 || (p.kind === 'full' && p.inMs < 3_600_000) ? 'error' : p.kind === 'full' ? 'warning' : 'success'
373      tones.push(tone)
374      const pct = `${Math.round(limit.percentUsed)}%`.padStart(4)
375      const reset = `↻${duration(resetsIn(limit.resetsAt, now))}`.padEnd(7)
376      if (limitRows.length) limitRows.push([])
377      limitRows.push([{ t: `${win.label} `, b: true }, { t: '▕', d: true }, ...meter(limit.percentUsed, inner - 18), { t: '▏', d: true }, { t: ` ${pct} `, c: tone, b: true }, { t: reset, d: true }])
378      limitRows.push(
379        p.kind === 'measuring'
380          ? [{ t: '   measuring pace…', d: true }]
381          : p.kind === 'full'
382            ? [{ t: `   full in ~${duration(p.inMs)} at this pace`, c: tone }]
383            : [{ t: '   lasts until the reset at this pace', d: true }],
384      )
385      glance.push({ t: `   ${win.label} `, d: true }, { t: `${Math.round(limit.percentUsed)}%`, c: tone, b: true })
386    }
387    // toTimeString starts with the local HH:MM:SS.
388    const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
389    if (sent.length) limitRows.push([], ...sent.map(n => [{ t: `${hhmm(n.at)} `, d: true }, { t: `note sent: ${n.text}` }]))
390    const limitTone = tones.includes('error') ? 'error' : tones.includes('warning') ? 'warning' : 'success'
391
392    // Tools and Requests share the rows left below; each keeps at least three.
393    // The top padding and glance row, each frame's two borders and top margin, and the Requests column header.
394    const fixed = 2 + (contextRows.length + 3) + (limitRows.length ? limitRows.length + 3 : 0) + 3 + 4
395    const left = Math.max(6, e.props.scroll.bodyRows - fixed)
396    const toolCount = Math.max(3, Math.ceil(left / 2))
397    const askCount = Math.max(3, left - toolCount)
398
399    const shown = [...calls.filter(t => t.ok === undefined), ...calls.filter(t => t.ok !== undefined)].slice(0, toolCount)
400    const running = calls.filter(t => t.ok === undefined).length
401    const toolRows: Row[] = shown.length
402      ? shown.map(t => {
403          const icon = t.ok === undefined ? { t: '◌', c: 'warning' } : t.ok ? { t: '✓', c: 'success' } : { t: '✗', c: 'error' }
404          const time = t.ms === undefined ? '…' : elapsed(t.ms)
405          return spread([icon, { t: ` ${(t.sub ? `↳${t.name}` : t.name).padEnd(7)} `, b: !t.sub, d: t.sub }, { t: t.target.slice(0, inner - 16), d: true }], [{ t: time, d: true }], inner)
406        })
407      : [[{ t: 'No tool calls yet.', d: true }]]
408    const toolTone = shown.find(t => t.ok !== undefined)?.ok === false ? 'error' : 'success'
409
410    const askRows: Row[] = asks.length
411      ? [
412          [{ t: 'loop          in   out  cache   time', d: true }],
413          ...asks.slice(0, askCount).map(q => {
414            const hit = q.input ? Math.round((q.cached / q.input) * 100) : 0
415            const cacheTone = hit < 20 ? 'error' : hit < 50 ? 'warning' : undefined
416            return [
417              { t: q.agent.padEnd(10).slice(0, 10), b: q.agent === 'main', d: q.agent !== 'main' },
418              { t: tokens(q.input).padStart(6) + tokens(q.output).padStart(6) },
419              { t: `${hit}%`.padStart(7), c: cacheTone, d: cacheTone === undefined },
420              { t: elapsed(q.ms).padStart(7), d: true },
421            ]
422          }),
423        ]
424      : [[{ t: 'No model requests yet.', d: true }]]
425    const lastHit = asks[0] && asks[0].input ? asks[0].cached / asks[0].input : 1
426    const model = asks[0]?.model.replace(/^claude-/, '') ?? ''
427
428    return (
429      <Box flexDirection="column" paddingTop={1}>
430        <Text>
431          {draw(glance, w - 3)}
432        </Text>
433        {frame('Context', zone(b.percent), contextRows)}
434        {limitRows.length ? frame('Limits', limitTone, limitRows) : null}
435        {frame('Tools', toolTone, toolRows, running ? `${running} running` : '')}
436        {frame('Requests', lastHit < 0.5 ? 'warning' : 'success', askRows, model)}
437      </Box>
438    )
439  })
440}
441
types/index.d.ts 25 lines
1export type MeterLimit = { kind: string; percentUsed: number; resetsAt?: string }
2export type MeterReading = { context: number | null; limits: MeterLimit[] }
3export type MeterCategory = { name: string; tokens: number; color: string; kind: string }
4export type MeterItem = { name: string; tokens: number }
5export type MeterBreakdown = { percent: number; tokens: number; window: number; threshold?: number; categories: MeterCategory[]; heaviest: MeterItem[] }
6export type MeterSample = { kind: string; at: number; pct: number; resetsAt?: string }
7export type MeterNote = { at: number; text: string }
8export type MeterTool = { id: string; name: string; target: string; sub: boolean; at: number; ms?: number; ok?: boolean }
9export type MeterRequest = { at: number; agent: string; input: number; cached: number; output: number; ms: number; model: string }
10
11declare module 'claude-code' {
12  interface PluginState {
13    'session-meter': {
14      reading: MeterReading
15      fired: string[]
16      breakdown: MeterBreakdown | null
17      history: number[]
18      samples: MeterSample[]
19      notes: MeterNote[]
20      tools: MeterTool[]
21      requests: MeterRequest[]
22    }
23  }
24}
25