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 503 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. The terminal draws them on a grid of cells, so frames pad and cut them exactly.
146// Other surfaces draw text in a proportional font, where spaces line nothing up: there a row is a flex line.
147// `fill` stretches, `w` is a column that many characters wide (`right` aligns it), a Bar draws `parts` as real
148// bars in place of its `cells`, a Spark draws a bar per value, and a Group takes an equal share of the line.
149export type Seg = { t: string; c?: string; d?: boolean; b?: boolean; fill?: boolean; w?: number; right?: boolean }
150type Bar = { cells: Seg[]; parts: { n: number; c?: string }[] }
151type Spark = { spark: readonly number[]; max: number; c: string }
152type Group = { group: Row }
153type Row = (Seg | Bar | Spark | Group)[]
154
155// The empty part of a bar where the surface draws real bars.
156const TRACK = 'userMessageBackgroundHover'
157
158const segs = (row: Row): Seg[] => row.flatMap(s => ('t' in s ? [s] : 'cells' in s ? s.cells : 'group' in s ? segs(s.group) : []))
159
160const width = (row: Row) => segs(row).reduce((n, s) => n + s.t.length, 0)
161
162export const fit = (row: Row, w: number): Seg[] => {
163 const out: Seg[] = []
164 let n = 0
165 for (const s of segs(row)) {
166 const room = w - n
167 if (room <= 0) break
168 if (s.t.length <= room) {
169 out.push(s)
170 n += s.t.length
171 } else {
172 out.push({ ...s, t: `${s.t.slice(0, room - 1)}…` })
173 n = w
174 }
175 }
176 if (n < w) out.push({ t: ' '.repeat(w - n) })
177 return out
178}
179
180const spread = (left: Row, right: Row, w: number): Row => [...left, { t: ' '.repeat(Math.max(1, w - width(left) - width(right))), fill: true }, ...right]
181
182const zone = (pct: number) => (pct >= 80 ? 'error' : pct >= 60 ? 'warning' : 'success')
183
184// The meter's fill split at the zone edges, so a real bar warms as it fills, as the terminal's cells do.
185export const zoneParts = (pct: number) => {
186 const p = Math.min(100, Math.max(0, pct))
187 return [{ n: Math.min(p, 60), c: 'success' }, { n: Math.min(Math.max(p - 60, 0), 20), c: 'warning' }, { n: Math.max(p - 80, 0), c: 'error' }, { n: 100 - p }]
188}
189
190// A meter with eighth-cell precision; filled cells take the colour of the zone they sit in, so the bar warms as it fills.
191export const meter = (pct: number, w: number): Seg[] => {
192 const eighths = Math.round((Math.min(100, Math.max(0, pct)) / 100) * w * 8)
193 const out: Seg[] = []
194 for (let i = 0; i < w; i++) {
195 const left = eighths - i * 8
196 const ch = left >= 8 ? '█' : left > 0 ? (EIGHTHS[left] ?? ' ') : '·'
197 const seg = left > 0 ? { t: ch, c: zone(((i + 1) / w) * 100) } : { t: ch, d: true }
198 const prev = out.at(-1)
199 if (prev && prev.c === seg.c && prev.d === seg.d) prev.t += ch
200 else out.push(seg)
201 }
202 return out
203}
204
205const refresh = async ($: EngineInterface) => {
206 const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
207 const b = context.breakdown
208 // The breakdown measures against the compaction window (CLAUDE_CODE_AUTO_COMPACT_WINDOW when set); `context.percent` uses the model's full window.
209 await update($, reading, () => ({ context: b?.percentage ?? context.percent ?? null, limits: [...rateLimits] }))
210 if (!b) return
211 await update($, breakdown, () => ({
212 percent: b.percentage,
213 tokens: b.totalTokens,
214 window: b.rawMaxTokens,
215 threshold: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
216 categories: b.categories.filter(c => !c.isDeferred).map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
217 heaviest: heaviest(b),
218 }))
219}
220
221const sample = async ($: EngineInterface, limits: MeterReading['limits']) => {
222 const at = await $.clock.now()
223 await update($, samples, list => [...list, ...limits.map(l => ({ kind: l.kind, at, pct: l.percentUsed, resetsAt: l.resetsAt }))].slice(-200))
224}
225
226const agentLabel = async ($: EngineInterface, id: string | undefined) => {
227 if (!id) return 'main'
228 // The engine's own forks (compaction, memory) carry ids no list names, so they are never cached.
229 const type = agentTypes.get(id) ?? (await $.agent.list()).find(a => a.id === id)?.type
230 if (type) agentTypes.set(id, type)
231 return type ?? 'fork'
232}
233
234export const register: Register = (on, options) => {
235 on('session.start', async ($, e, next) => {
236 await $.command.register({ name: 'ctx', description: 'Show what fills the context window and how fast limits run down' })
237 await refresh($)
238 await sample($, (await read($, reading)).limits)
239 if (options.autoOpen !== false) void $.ui.open({ id: PANE, title: 'Session' })
240 return next(e)
241 })
242
243 on('command.run', { command: 'ctx' }, async $ => {
244 await refresh($)
245 const opened = await $.ui.open({ id: PANE, title: 'Session' })
246 return { text: opened.isPlaced ? 'Session pane opened.' : `Session pane is waiting: ${opened.reason}` }
247 })
248
249 on('session.measure', async ($, e, next) => {
250 await update($, reading, r => ({ context: r.context, limits: [...e.rateLimits] }))
251 if (e.changed.includes('rateLimits')) await sample($, e.rateLimits)
252 if (e.changed.includes('context')) {
253 if (e.context.tokens !== undefined) await update($, history, list => [...list, e.context.tokens ?? 0].slice(-120))
254 await refresh($)
255 }
256 return next(e)
257 })
258
259 on('prompt.submit', async ($, e, next) => {
260 const r = await read($, reading)
261 const crossed = cross(r, await read($, fired))
262 await update($, fired, () => crossed.fired)
263 if (crossed.fresh.length === 0) return next(e)
264 const now = await $.clock.now()
265 await update($, notes, list => [...list, { at: now, text: noteLabel(crossed.fresh) }].slice(-5))
266 return next({ ...e, context: [...(e.context ?? []), note(r, crossed.fresh, now)] })
267 })
268
269 on('tool.call', async ($, e, next) => {
270 const at = await $.clock.now()
271 const id = e.tool_use_id ?? `${e.tool}:${at}`
272 const entry: MeterTool = { id, name: toolName(e.tool), target: target(e as unknown as Record<string, unknown>), sub: e.agentId !== undefined, at }
273 await update($, tools, list => [entry, ...list.filter(t => t.id !== id)].slice(0, KEEP))
274 let ok = false
275 try {
276 const result = await next(e)
277 ok = result.deny === undefined && !result.isError
278 return result
279 } finally {
280 const ms = (await $.clock.now()) - at
281 await update($, tools, list => list.map(t => (t.id === id ? { ...t, ms, ok } : t)))
282 }
283 })
284
285 on('turn.step', async function* ($, e, next) {
286 const at = await $.clock.now()
287 const result = yield* next(e)
288 const usage = result.usage
289 if (usage) {
290 try {
291 const ms = (await $.clock.now()) - at
292 const agent = await agentLabel($, e.agentId)
293 const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
294 const row = { at, agent, input, cached: usage.cache_read_input_tokens, output: usage.output_tokens, ms, model: usage.model }
295 await update($, requests, list => [row, ...list].slice(0, KEEP))
296 } catch {
297 // The log is a convenience: a failed write must never break the model's response.
298 }
299 }
300 return result
301 })
302
303 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
304 const { Box, Text } = $.ui.resolve(e)
305 const b = await read($, breakdown)
306 const r = await read($, reading)
307 const turns = await read($, history)
308 const list = await read($, samples)
309 const sent = await read($, notes)
310 const calls = await read($, tools)
311 const asks = await read($, requests)
312 const now = await $.clock.now()
313 const w = Math.max(28, e.props.bodyColumns - 1)
314 const inner = w - 6
315 const isGrid = e.surface === 'terminal'
316 if (!b) return <Text dimColor>Waiting for the first context reading.</Text>
317
318 const draw = (row: Row, width: number) =>
319 fit(row, width).map((s, j) => <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b}>{s.t}</Text>)
320 // A row as a flex line, for surfaces that draw text in a proportional font.
321 const items = (row: Row) =>
322 row.map((s, j) => {
323 if ('group' in s) return <Box flexGrow={1} width={0} alignItems="center">{items(s.group)}</Box>
324 if ('cells' in s) {
325 // The engine takes a flexGrow up to 10000, so parts are weighed per thousand.
326 const total = Math.max(1, s.parts.reduce((n, p) => n + p.n, 0))
327 return <Box flexGrow={1} height={0.5}>{s.parts.map(p => <Box flexGrow={(p.n / total) * 1000} backgroundColor={p.c ?? TRACK} />)}</Box>
328 }
329 if ('spark' in s)
330 return (
331 <Box flexGrow={1} height={2} alignItems="flex-end">
332 {s.spark.map(v => <Box flexGrow={1} height={`${Math.max(1, Math.round((Math.min(v, s.max) / Math.max(1, s.max)) * 100))}%`} backgroundColor={s.c} />)}
333 </Box>
334 )
335 if (s.fill) return <Box flexGrow={1} />
336 const text = <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b} wrap="truncate-end">{!s.w ? s.t : s.right ? s.t.trim() : s.t.trimEnd()}</Text>
337 return s.w ? <Box width={s.w} flexShrink={0} justifyContent={s.right ? 'flex-end' : 'flex-start'}>{text}</Box> : text
338 })
339 const line = (row: Row) => (row.length ? <Box alignItems="center">{items(row)}</Box> : <Box height={0.5} />)
340 const frame = (title: string, tone: string, rows: Row[], right = '') => {
341 if (!isGrid)
342 return (
343 <Box flexDirection="column" marginTop={2} borderStyle="round" borderColor={tone} borderDimColor>
344 <Box marginBottom={1}>
345 <Text color={tone} bold>{title}</Text>
346 <Box flexGrow={1} />
347 {right ? <Text dimColor>{right}</Text> : null}
348 </Box>
349 {rows.map(line)}
350 </Box>
351 )
352 const rule = Math.max(0, w - 6 - title.length - (right ? right.length + 2 : 0))
353 return (
354 <Box key={title} flexDirection="column" marginTop={1}>
355 <Text>
356 <Text color={tone} dimColor>╭─ </Text>
357 <Text color={tone} bold>{title}</Text>
358 <Text color={tone} dimColor> {'─'.repeat(rule)}</Text>
359 {right ? <Text dimColor> {right} </Text> : null}
360 <Text color={tone} dimColor>─╮</Text>
361 </Text>
362 {rows.map((row, i) => (
363 <Text key={String(i)}>
364 <Text color={tone} dimColor>│ </Text>
365 {draw(row, inner)}
366 <Text color={tone} dimColor> │</Text>
367 </Text>
368 ))}
369 <Text color={tone} dimColor>╰{'─'.repeat(w - 2)}╯</Text>
370 </Box>
371 )
372 }
373
374 // Context: one stacked bar of what fills it, the compaction mark, a legend, the size over turns, the heaviest items.
375 const used = b.categories.filter(c => c.kind === 'used' && c.tokens > 0)
376 const bar: Seg[] = used.map(c => ({ t: '█'.repeat(cells(c.tokens, b.window, inner)), c: c.color }))
377 const filled = width(bar)
378 const mark = b.threshold === undefined ? -1 : Math.min(inner - 1, cells(b.threshold, b.window, inner))
379 for (let i = filled; i < inner; i++) bar.push(i === mark ? { t: '┊', c: 'warning' } : { t: '·', d: true, c: mark >= 0 && i > mark ? 'error' : undefined })
380 // Real bars mark the compaction point by tinting the room past it.
381 const usedTokens = used.reduce((n, c) => n + c.tokens, 0)
382 const free = Math.max(0, (b.threshold ?? b.window) - usedTokens)
383 const parts = [...used.map(c => ({ n: c.tokens, c: c.color })), { n: free }, { n: Math.max(0, b.window - usedTokens - free), c: 'diffRemovedDimmed' }]
384 // The legend is a two-column grid, names left and counts right-aligned in each column.
385 const colW = Math.floor((inner - 3) / 2)
386 const keys = [
387 ...used.map(c => ({ mark: '■', c: c.color, name: c.name, n: tokens(c.tokens) })),
388 ...(b.threshold === undefined ? [] : [{ mark: isGrid ? '┊' : '■', c: isGrid ? 'warning' : 'diffRemovedDimmed', name: 'compacts at', n: tokens(b.threshold) }]),
389 ]
390 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)
391 const legend: Row[] = []
392 for (let i = 0; i < keys.length; i += 2) {
393 const [a, z] = [keys[i], keys[i + 1]]
394 if (a) legend.push([{ group: key(a) }, ...(z ? [{ t: ' ' }, { group: key(z) }] : [{ group: [] }])])
395 }
396 const last = turns.at(-1)
397 const prev = turns.at(-2)
398 const delta = last !== undefined && prev !== undefined ? last - prev : undefined
399 const chartCols = inner - 12
400 const chart = turns.length < 2 ? [] : braille(turns, b.window, chartCols, 2)
401 const change: Seg = { t: `${delta !== undefined && delta >= 0 ? '+' : '-'}${tokens(Math.abs(delta ?? 0))}`, b: true }
402 const contextRows: Row[] = [
403 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),
404 [],
405 [{ cells: bar, parts }],
406 [],
407 ...legend,
408 [],
409 ...(!chart.length
410 ? [[{ t: 'Size over turns shows after two turns.', d: true }]]
411 : isGrid
412 ? [
413 [{ t: chart[0] ?? '', c: 'suggestion' }, { t: ' ' }, change],
414 [{ t: chart[1] ?? '', c: 'suggestion' }, { t: ' last turn', d: true }],
415 ]
416 : [[{ spark: turns.slice(-chartCols * 2), max: b.window, c: 'suggestion' }, { t: ' ' }, change, { t: ' last turn', d: true }]]),
417 ...(b.heaviest.length ? [[] as Row, ...b.heaviest.map(item => spread([{ t: '▸ ', d: true }, { t: item.name }], [{ t: tokens(item.tokens), d: true }], inner))] : []),
418 ]
419
420 // Limits: each window's meter, its reset, and whether the current pace lasts until then.
421 const limitRows: Row[] = []
422 const tones: string[] = []
423 const glance: Row = [{ t: 'ctx ', d: true }, { t: `${Math.round(b.percent)}%`, c: zone(b.percent), b: true }]
424 for (const win of WINDOWS) {
425 const limit = r.limits.find(l => l.kind === win.kind)
426 if (!limit) continue
427 const p = pace(list, win.kind, now)
428 const tone = limit.percentUsed >= 80 || (p.kind === 'full' && p.inMs < 3_600_000) ? 'error' : p.kind === 'full' ? 'warning' : 'success'
429 tones.push(tone)
430 const pct = `${Math.round(limit.percentUsed)}%`.padStart(4)
431 const reset = `↻${duration(resetsIn(limit.resetsAt, now))}`.padEnd(7)
432 if (limitRows.length) limitRows.push([])
433 limitRows.push([
434 { t: `${win.label} `, b: true, w: 3 },
435 { cells: [{ t: '▕', d: true }, ...meter(limit.percentUsed, inner - 18), { t: '▏', d: true }], parts: zoneParts(limit.percentUsed) },
436 { t: ` ${pct} `, c: tone, b: true, w: 6, right: true },
437 { t: reset, d: true, w: 8, right: true },
438 ])
439 limitRows.push(
440 p.kind === 'measuring'
441 ? [{ t: ' measuring pace…', d: true }]
442 : p.kind === 'full'
443 ? [{ t: ` full in ~${duration(p.inMs)} at this pace`, c: tone }]
444 : [{ t: ' lasts until the reset at this pace', d: true }],
445 )
446 glance.push({ t: ` ${win.label} `, d: true }, { t: `${Math.round(limit.percentUsed)}%`, c: tone, b: true })
447 }
448 // toTimeString starts with the local HH:MM:SS.
449 const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
450 if (sent.length) limitRows.push([], ...sent.map(n => [{ t: `${hhmm(n.at)} `, d: true }, { t: `note sent: ${n.text}` }]))
451 const limitTone = tones.includes('error') ? 'error' : tones.includes('warning') ? 'warning' : 'success'
452
453 // Tools and Requests share the rows left below; each keeps at least three.
454 // The top padding and glance row, each frame's two borders and top margin, and the Requests column header.
455 const fixed = 2 + (contextRows.length + 3) + (limitRows.length ? limitRows.length + 3 : 0) + 3 + 4
456 const left = Math.max(6, e.props.scroll.bodyRows - fixed)
457 const toolCount = Math.max(3, Math.ceil(left / 2))
458 const askCount = Math.max(3, left - toolCount)
459
460 const shown = [...calls.filter(t => t.ok === undefined), ...calls.filter(t => t.ok !== undefined)].slice(0, toolCount)
461 const running = calls.filter(t => t.ok === undefined).length
462 const toolRows: Row[] = shown.length
463 ? shown.map(t => {
464 const icon = t.ok === undefined ? { t: '◌', c: 'warning' } : t.ok ? { t: '✓', c: 'success' } : { t: '✗', c: 'error' }
465 const time = t.ms === undefined ? '…' : elapsed(t.ms)
466 return spread([icon, { t: ` ${(t.sub ? `↳${t.name}` : t.name).padEnd(7)} `, b: !t.sub, d: t.sub, w: 11 }, { t: t.target.slice(0, inner - 16), d: true }], [{ t: time, d: true }], inner)
467 })
468 : [[{ t: 'No tool calls yet.', d: true }]]
469 const toolTone = shown.find(t => t.ok !== undefined)?.ok === false ? 'error' : 'success'
470
471 // A right-aligned column n wide.
472 const num = (t: string, n: number, s: Omit<Seg, 't'> = {}): Seg => ({ ...s, t: t.padStart(n), w: n, right: true })
473 const askRows: Row[] = asks.length
474 ? [
475 [{ t: 'loop'.padEnd(10), d: true, w: 10 }, num('in', 6, { d: true }), num('out', 6, { d: true }), num('cache', 7, { d: true }), num('time', 7, { d: true })],
476 ...asks.slice(0, askCount).map(q => {
477 const hit = q.input ? Math.round((q.cached / q.input) * 100) : 0
478 const cacheTone = hit < 20 ? 'error' : hit < 50 ? 'warning' : undefined
479 return [
480 { t: q.agent.padEnd(10).slice(0, 10), b: q.agent === 'main', d: q.agent !== 'main', w: 10 },
481 num(tokens(q.input), 6),
482 num(tokens(q.output), 6),
483 num(`${hit}%`, 7, { c: cacheTone, d: cacheTone === undefined }),
484 num(elapsed(q.ms), 7, { d: true }),
485 ]
486 }),
487 ]
488 : [[{ t: 'No model requests yet.', d: true }]]
489 const lastHit = asks[0] && asks[0].input ? asks[0].cached / asks[0].input : 1
490 const model = asks[0]?.model.replace(/^claude-/, '') ?? ''
491
492 return (
493 <Box flexDirection="column" paddingTop={1}>
494 {isGrid ? <Text>{draw(glance, w - 3)}</Text> : line(glance)}
495 {frame('Context', zone(b.percent), contextRows)}
496 {limitRows.length ? frame('Limits', limitTone, limitRows) : null}
497 {frame('Tools', toolTone, toolRows, running ? `${running} running` : '')}
498 {frame('Requests', lastHit < 0.5 ? 'warning' : 'success', askRows, model)}
499 </Box>
500 )
501 })
502}
503types/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