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…

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

↳.It also sends the model one hidden note when a threshold is crossed, and again only after the figure drops 10 points below it:
| Threshold | What 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.
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.
/context, so they follow CLAUDE_CODE_AUTO_COMPACT_WINDOW if you set it.claude plugin validate .
claude plugin test .
../typecheck.sh session-meterhooks/register.tsx 441 lines1import { 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}
441types/index.d.ts 25 lines1export 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