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
★ 5v0.3.1MITupdated 2026-10-07arasovic/claude-code-mods/session-meter
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 503 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. 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}
503
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