Session dashboard: context breakdown, prompt-cache status and breaks, expiry alerts, turn stats, cost, rate limits and git

A Claude Code mod that adds a session dashboard and a live status line: how full the context window is, whether the prompt cache is still warm, when and why it broke, how your turns and tools are doing, and what the session has cost.
At the prompt of a Claude Code terminal session:
/plugin install session-info --marketplace ruisaraiva19/claude-session-info
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install session-info --marketplace ruisaraiva19/claude-session-info
In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ruisaraiva19/claude-session-info, then Add plugin.
Update later with claude plugin update session-info.
Status line, always under the prompt, refreshed after every request and every 15 seconds:
◆ cache warm 42m · hit 96% · ctx 34% · $1.23
/session-info opens the dashboard; /session-info context, cache or turns opens a tab directly. Keys 1–4 switch tabs.
Alerts: a toast a few minutes before the prompt cache expires, one as context passes 70%, 85% and 95%, and one when a request breaks the cache for an unexpected reason (a changed system prompt or tool list, early eviction, an edited prefix).
Desktop and other graphical surfaces draw the charts as SVG; the terminal draws the same tabs with block characters.
Set in /config (or pluginConfigs in settings):
| Setting | Values | Default | What it does |
|---|---|---|---|
cacheTtl | 5m, 1h | 1h | How long a prompt-cache entry lives; Claude Code doesn't expose it, so set it to match your requests. |
alerts | toast, toast+native, off | toast | Cache-expiry, context-fill and cache-break alerts. |
claude plugin validate .
claude plugin test .
Run it from this folder without installing: claude --plugin-dir .
Commits follow Conventional Commits (feat:, fix:, docs:, ...); see CLAUDE.md.
hooks/register.tsx 243 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CacheBreak, CacheRequest, Tab, TurnStat } from '../types'
5import { hash, short } from './format'
6import {
7 EMPTY_SIGNALS,
8 EMPTY_TOTALS,
9 KEEP_BREAKS,
10 KEEP_REQUESTS,
11 KEEP_TURNS,
12 PANE,
13 checkAlerts,
14 refreshGit,
15 refreshStatus,
16 resetAll,
17 safe,
18} from './state'
19import type { Options } from './state'
20import { drawPane } from './view'
21
22const breaks = atom({ plugin: 'session-info', key: 'breaks' } as const, [])
23const requests = atom({ plugin: 'session-info', key: 'requests' } as const, [])
24const signals = atom({ plugin: 'session-info', key: 'signals' } as const, EMPTY_SIGNALS)
25const tab = atom({ plugin: 'session-info', key: 'tab' } as const, 'overview' as Tab)
26const tick = atom({ plugin: 'session-info', key: 'tick' } as const, 0)
27const tools = atom({ plugin: 'session-info', key: 'tools' } as const, [])
28const totals = atom({ plugin: 'session-info', key: 'totals' } as const, EMPTY_TOTALS)
29const turn = atom({ plugin: 'session-info', key: 'turn' } as const, null)
30const turns = atom({ plugin: 'session-info', key: 'turns' } as const, [])
31
32const TICK_MS = 15_000
33const GIT_EVERY = 4
34const TABS: readonly Tab[] = ['overview', 'context', 'cache', 'turns']
35
36/**
37 * Why a request read far less from the cache than the one before it had
38 * cached. A heuristic over what the mod saw between the two requests.
39 */
40function causeOf(prev: CacheRequest, cur: CacheRequest, startedAt: number, compactedAt: number, promptChangedAt: number, ttlMs: number) {
41 if (compactedAt > prev.at) return 'Compaction: the conversation was summarized'
42 if (cur.model !== prev.model) return `Model switch: ${prev.model} → ${cur.model}`
43 const idle = startedAt - prev.at
44 if (idle >= ttlMs) return `TTL expired after ${short(idle)} idle`
45 if (promptChangedAt > prev.at) return 'System prompt or tool list changed'
46 if (cur.cacheRead === 0) return 'Cache evicted early: nothing was read while the TTL was still warm'
47 return 'Prefix changed: earlier context was edited or reordered'
48}
49
50export const register: Register = (on, options) => {
51 const opts: Options = {
52 ttlMs: options.cacheTtl === '5m' ? 5 * 60_000 : 60 * 60_000,
53 ttlLabel: options.cacheTtl === '5m' ? '5m' : '1h',
54 alertMode: options.alerts === 'off' ? 'off' : options.alerts === 'toast+native' ? 'toast+native' : 'toast',
55 }
56
57 on('session.start', async ($, e, next) => {
58 await $.command.register({
59 name: 'session-info',
60 description: 'Session dashboard: context, prompt cache, turns, cost and limits',
61 argumentHint: '[overview|context|cache|turns]',
62 })
63 let ticks = 0
64 $.clock.every(TICK_MS, () => {
65 ticks += 1
66 void update($, tick, n => (n ?? 0) + 1)
67 void refreshStatus($, opts)
68 void checkAlerts($, opts)
69 if (ticks % GIT_EVERY === 0) void refreshGit($)
70 })
71 void refreshStatus($, opts)
72 void refreshGit($)
73
74 return next(e)
75 })
76
77 on('session.end', async ($, e, next) => {
78 if (e.reason === 'clear') {
79 await resetAll($)
80 $.ui.status(undefined)
81 }
82
83 return next(e)
84 })
85
86 on('command.run', { command: 'session-info' }, async ($, e) => {
87 const wanted = e.args.trim().toLowerCase()
88 if ((TABS as readonly string[]).includes(wanted)) await update($, tab, () => wanted as Tab)
89 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Session' })
90
91 return { text: isPlaced ? 'Session dashboard opened.' : 'The session dashboard needs a surface that shows panes.' }
92 })
93
94 // The prompt each request is built from: a new combination of system prompt
95 // and tools, after the first, may explain a cache break that follows.
96 on('prompt.compose', async ($, e, next) => {
97 const result = await next(e)
98 const key = hash(`${e.model}|${e.tools.join(',')}|${result.sections.map(s => s.text).join('\n')}`)
99 const was = await read($, signals)
100 const seen = was.promptKey.split(',').filter(k => k.length > 0)
101 if (!seen.includes(key)) {
102 const now = await $.clock.now()
103 await update($, signals, s => ({
104 ...s,
105 promptKey: [...seen, key].slice(-6).join(','),
106 promptChangedAt: seen.length > 0 ? now : s.promptChangedAt,
107 }))
108 }
109
110 return result
111 })
112
113 on('session.compact', async ($, e, next) => {
114 const result = await next(e)
115 if (e.agentId === undefined && result.skip === undefined) {
116 const now = await $.clock.now()
117 await update($, signals, s => ({ ...s, compactedAt: now }))
118 }
119
120 return result
121 }).catch(($, e, next) => next(e))
122
123 on('turn.start', async ($, e, next) => {
124 if (e.agentId === undefined) {
125 const [now, usage] = await Promise.all([$.clock.now(), safe(() => $.session.usage())])
126 await update($, turn, () => ({
127 turnId: e.turnId,
128 startedAt: now,
129 costAtStart: usage?.cost?.usd ?? 0,
130 requests: 0,
131 tools: 0,
132 errors: 0,
133 }))
134 }
135
136 return next(e)
137 })
138
139 on('tool.call', async ($, e, next) => {
140 if (e.agentId !== undefined) return next(e)
141 const startedAt = await $.clock.now()
142 const result = await next(e)
143 const ms = (await $.clock.now()) - startedAt
144 const isError = 'deny' in result ? result.deny !== undefined : result.isError === true
145 const name = String(e.tool)
146
147 await update($, tools, list => {
148 const all = list ?? []
149 const found = all.find(t => t.tool === name)
150 const row = found ?? { tool: name, calls: 0, errors: 0, totalMs: 0 }
151 const bumped = { ...row, calls: row.calls + 1, errors: row.errors + (isError ? 1 : 0), totalMs: row.totalMs + ms }
152 return found ? all.map(t => (t.tool === name ? bumped : t)) : [...all, bumped]
153 })
154 await update($, turn, t => (t ? { ...t, tools: t.tools + 1, errors: t.errors + (isError ? 1 : 0) } : t))
155
156 return result
157 }).catch(($, e, next) => next(e))
158
159 // Every API request: what the prompt cache served and wrote, and whether
160 // it had to re-cache a prefix the request before it had cached.
161 on('turn.step', async function* ($, e, next) {
162 const startedAt = await $.clock.now()
163 const result = yield* next(e)
164 const usage = result.usage
165 if (usage === null) return result
166
167 if (e.agentId !== undefined) {
168 await update($, totals, t => ({ ...(t ?? EMPTY_TOTALS), subagentRequests: (t ?? EMPTY_TOTALS).subagentRequests + 1 }))
169 return result
170 }
171
172 const [list, sig, now] = await Promise.all([read($, requests), read($, signals), $.clock.now()])
173 const request: CacheRequest = {
174 at: now,
175 model: usage.model,
176 input: usage.input_tokens,
177 cacheRead: usage.cache_read_input_tokens,
178 cacheWrite: usage.cache_creation_input_tokens,
179 output: usage.output_tokens,
180 }
181
182 const prev = list.at(-1)
183 if (prev !== undefined) {
184 const expected = prev.cacheRead + prev.cacheWrite
185 if (expected > 2048 && request.cacheRead < expected * 0.6 && request.cacheWrite > 2048) {
186 request.isBreak = true
187 const found: CacheBreak = {
188 at: now,
189 rewritten: request.cacheWrite,
190 read: request.cacheRead,
191 expected,
192 cause: causeOf(prev, request, startedAt, sig.compactedAt, sig.promptChangedAt, opts.ttlMs),
193 }
194 await update($, breaks, b => [...(b ?? []), found].slice(-KEEP_BREAKS))
195 if (opts.alertMode !== 'off' && !found.cause.startsWith('Compaction') && !found.cause.startsWith('TTL')) {
196 $.ui.toast(`Cache break: ${found.cause}`)
197 }
198 }
199 }
200
201 await update($, requests, l => [...(l ?? []), request].slice(-KEEP_REQUESTS))
202 await update($, totals, t => {
203 const was = t ?? EMPTY_TOTALS
204 return {
205 ...was,
206 requests: was.requests + 1,
207 input: was.input + request.input,
208 cacheRead: was.cacheRead + request.cacheRead,
209 cacheWrite: was.cacheWrite + request.cacheWrite,
210 output: was.output + request.output,
211 }
212 })
213 await update($, turn, t => (t ? { ...t, requests: t.requests + 1 } : t))
214 await refreshStatus($, opts)
215 await checkAlerts($, opts)
216
217 return result
218 })
219
220 on('turn.complete', async ($, e, next) => {
221 if (e.agentId === undefined) {
222 const [running, usage, now] = await Promise.all([read($, turn), safe(() => $.session.usage()), $.clock.now()])
223 const stat: TurnStat = {
224 at: now,
225 durationMs: e.durationMs,
226 requests: running?.requests ?? 0,
227 tools: running?.tools ?? 0,
228 errors: running?.errors ?? 0,
229 costUsd: Math.max(0, (usage?.cost?.usd ?? 0) - (running?.costAtStart ?? 0)),
230 outputTokens: e.usage?.output_tokens ?? 0,
231 reason: e.reason,
232 }
233 await update($, turns, list => [...(list ?? []), stat].slice(-KEEP_TURNS))
234 await update($, turn, () => null)
235 void refreshGit($)
236 }
237
238 return next(e)
239 })
240
241 on('ui.render', { component: 'Pane', requestId: 'session-info' }, ($, e) => drawPane($, e, opts))
242}
243hooks/format.ts 207 lines1import type { CacheRequest } from '../types'
2
3// Palette: the dataviz reference palette's dark-surface steps, which hold 3:1
4// on both light and dark surfaces, so one SVG reads in either theme. Labels
5// always sit beside colored marks, never inside them.
6
7export const SERIES = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']
8export const STATUS = { good: '#0ca30c', warning: '#fab219', serious: '#ec835a', critical: '#d03b3b' }
9export const ACCENT = '#3987e5'
10export const TRACK = 'rgba(128,128,128,0.22)'
11export const RULE = 'rgba(128,128,128,0.55)'
12
13// Numbers
14
15export const tokens = (n: number): string =>
16 n >= 1_000_000
17 ? `${(n / 1_000_000).toFixed(2)}M`
18 : n >= 10_000
19 ? `${Math.round(n / 1000)}k`
20 : n >= 1000
21 ? `${(n / 1000).toFixed(1)}k`
22 : String(Math.round(n))
23
24/** Context-style counts: `314`, `18.6k`, `33k`, `281.4k`, `1M`, `1.25M`. */
25export const tok1 = (n: number): string =>
26 n < 1000
27 ? String(Math.round(n))
28 : n < 1_000_000
29 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
30 : `${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`
31
32/** One decimal: `22.6%`, `0.0%`. */
33export const pct1 = (part: number, whole: number): string => `${whole > 0 ? ((part / whole) * 100).toFixed(1) : '0.0'}%`
34
35export const NEUTRAL = '#a3a29c'
36
37export const duration = (ms: number): string => {
38 const s = Math.max(0, Math.round(ms / 1000))
39 if (s < 60) return `${s}s`
40 const m = Math.floor(s / 60)
41 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
42 const h = Math.floor(m / 60)
43 if (h < 48) return `${h}h ${String(m % 60).padStart(2, '0')}m`
44 return `${Math.floor(h / 24)}d ${h % 24}h`
45}
46
47export const short = (ms: number): string => {
48 const s = Math.max(0, Math.round(ms / 1000))
49 if (s < 60) return `${s}s`
50 const m = Math.round(s / 60)
51 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 > 0 ? ` ${m % 60}m` : ''}`
52}
53
54export const clock = (ms: number): string => {
55 const d = new Date(ms)
56 const p = (n: number) => String(n).padStart(2, '0')
57 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
58}
59
60export const usd = (n: number): string => (n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`)
61
62export const percent = (part: number, whole: number): number =>
63 whole > 0 ? Math.round((part / whole) * 100) : 0
64
65export const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
66
67// The prompt a request was answered over: uncached, read and written together.
68export const promptOf = (r: { input: number; cacheRead: number; cacheWrite: number }) =>
69 r.input + r.cacheRead + r.cacheWrite
70
71export const hitOf = (r: CacheRequest) => percent(r.cacheRead, promptOf(r))
72
73export const limitName = (kind: string): string =>
74 kind === 'five_hour' ? '5-hour' : kind === 'seven_day' ? '7-day' : kind.replace(/_/g, ' ')
75
76/** Higher is worse: good under `warn`, warning under `bad`, critical above. */
77export const level = (pct: number, warn: number, bad: number) =>
78 pct >= bad ? STATUS.critical : pct >= warn ? STATUS.warning : STATUS.good
79
80export const hash = (text: string): string => {
81 let h = 0x811c9dc5
82 for (let i = 0; i < text.length; i++) {
83 h ^= text.charCodeAt(i)
84 h = Math.imul(h, 0x01000193)
85 }
86 return (h >>> 0).toString(36)
87}
88
89// Cache status
90
91export type CacheState =
92 | { kind: 'none' }
93 | { kind: 'warm'; remainingMs: number; last: CacheRequest }
94 | { kind: 'expired'; agoMs: number; last: CacheRequest }
95
96export const cacheState = (list: readonly CacheRequest[], now: number, ttlMs: number): CacheState => {
97 const last = list.at(-1)
98 if (last === undefined) return { kind: 'none' }
99 const age = now - last.at
100 return age < ttlMs ? { kind: 'warm', remainingMs: ttlMs - age, last } : { kind: 'expired', agoMs: age, last }
101}
102
103// Text charts, for surfaces without SVG
104
105const BLOCKS = '▁▂▃▄▅▆▇█'
106
107export const blockOf = (fraction: number): string =>
108 BLOCKS[clamp(Math.round(fraction * (BLOCKS.length - 1)), 0, BLOCKS.length - 1)] ?? '▁'
109
110export const textBar = (fraction: number, width: number): [string, string] => {
111 const filled = clamp(Math.round(fraction * width), 0, width)
112 return ['█'.repeat(filled), '░'.repeat(width - filled)]
113}
114
115// SVG charts. Marks only: every label is a Text beside the chart, so text
116// wears the surface's own ink in light and dark alike.
117
118const svg = (w: number, h: number, body: string) =>
119 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
120
121const esc = (s: string) => s.replace(/[<>&"]/g, c => `&#${c.charCodeAt(0)};`)
122
123/** A ring gauge: `fraction` of the circle in `color` over a neutral track. */
124export const ringSvg = (fraction: number, color: string, size = 64, stroke = 7): string => {
125 const r = (size - stroke) / 2
126 const c = 2 * Math.PI * r
127 const f = clamp(fraction, 0, 1)
128 const arc =
129 f > 0
130 ? `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${size / 2} ${size / 2})"/>`
131 : ''
132 return svg(
133 size,
134 size,
135 `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${TRACK}" stroke-width="${stroke}"/>${arc}`,
136 )
137}
138
139export type Segment = { fraction: number; color: string; label: string }
140
141/**
142 * One horizontal stacked bar: segments left to right with 2px surface gaps,
143 * the rest as track, rounded ends, and an optional marker (a threshold).
144 */
145export const stackedSvg = (segments: readonly Segment[], width = 640, height = 14, marker?: number): string => {
146 const pad = marker !== undefined ? 3 : 0
147 const gap = 2
148 let x = 0
149 const parts: string[] = [`<rect x="0" y="${pad}" width="${width}" height="${height}" rx="${height / 2}" fill="${TRACK}"/>`]
150 const shown = segments.filter(s => s.fraction * width >= 1)
151 shown.forEach((s, i) => {
152 const w = Math.max(1, s.fraction * width - (i < shown.length - 1 ? gap : 0))
153 parts.push(`<rect x="${x.toFixed(1)}" y="${pad}" width="${w.toFixed(1)}" height="${height}" fill="${s.color}"><title>${esc(s.label)}</title></rect>`)
154 x += s.fraction * width
155 })
156 // Round both ends of the whole bar by clipping to a pill.
157 const clip = `<clipPath id="pill"><rect x="0" y="${pad}" width="${width}" height="${height}" rx="${height / 2}"/></clipPath>`
158 const mark =
159 marker !== undefined
160 ? `<line x1="${(marker * width).toFixed(1)}" x2="${(marker * width).toFixed(1)}" y1="0" y2="${height + 2 * pad}" stroke="${RULE}" stroke-width="2" stroke-dasharray="3 2"/>`
161 : ''
162 return svg(width, height + 2 * pad, `<defs>${clip}</defs><g clip-path="url(#pill)">${parts.join('')}</g>${mark}`)
163}
164
165export type Column = { value: number; color: string; label: string }
166
167/**
168 * Vertical columns, one per datum, rounded at the data end and anchored to a
169 * baseline; `max` fixes the scale (100 for a percentage), else the data's own.
170 */
171export const columnsSvg = (cols: readonly Column[], width = 640, height = 64, max?: number, guide?: number): string => {
172 const n = Math.max(cols.length, 1)
173 const slot = width / Math.max(n, 24)
174 const bar = Math.max(2, slot - 2)
175 const top = max ?? Math.max(1, ...cols.map(c => c.value))
176 const body: string[] = [`<line x1="0" x2="${width}" y1="${height - 0.5}" y2="${height - 0.5}" stroke="${TRACK}" stroke-width="1"/>`]
177 if (guide !== undefined) {
178 const y = height - (guide / top) * (height - 2)
179 body.push(`<line x1="0" x2="${width}" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${RULE}" stroke-width="1" stroke-dasharray="4 3"/>`)
180 }
181 const offset = width - n * slot
182 cols.forEach((c, i) => {
183 const h = Math.max(2, (clamp(c.value, 0, top) / top) * (height - 2))
184 const x = offset + i * slot
185 const r = Math.min(2, bar / 2)
186 body.push(
187 `<path d="M${x.toFixed(1)},${height} v${-(h - r).toFixed(1)} q0,${-r} ${r},${-r} h${(bar - 2 * r).toFixed(1)} q${r},0 ${r},${r} v${(h - r).toFixed(1)} z" fill="${c.color}"><title>${esc(c.label)}</title></path>`,
188 )
189 })
190 return svg(width, height, body.join(''))
191}
192
193/** A thin single-value bar for table rows. */
194export const meterSvg = (fraction: number, color: string, width = 160, height = 8): string =>
195 svg(
196 width,
197 height,
198 `<rect x="0" y="0" width="${width}" height="${height}" rx="${height / 2}" fill="${TRACK}"/>` +
199 (fraction > 0
200 ? `<rect x="0" y="0" width="${Math.max(height, clamp(fraction, 0, 1) * width).toFixed(1)}" height="${height}" rx="${height / 2}" fill="${color}"/>`
201 : ''),
202 )
203
204/** A small rounded square for a legend row. */
205export const swatchSvg = (color: string, size = 10): string =>
206 svg(size, size, `<rect x="0" y="0" width="${size}" height="${size}" rx="${size / 4}" fill="${color}"/>`)
207hooks/state.ts 138 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Alerts, CacheTotals, GitInfo, Signals, Tab } from '../types'
5import { cacheState, hitOf, promptOf, short, tokens, usd } from './format'
6
7export const PLUGIN = 'session-info'
8export const PANE = 'session-info'
9export const KEEP_REQUESTS = 50
10export const KEEP_TURNS = 30
11export const KEEP_BREAKS = 20
12
13export const EMPTY_TOTALS: CacheTotals = {
14 requests: 0,
15 input: 0,
16 cacheRead: 0,
17 cacheWrite: 0,
18 output: 0,
19 subagentRequests: 0,
20}
21export const EMPTY_SIGNALS: Signals = { compactedAt: 0, promptKey: '', promptChangedAt: 0 }
22export const EMPTY_ALERTS: Alerts = { expiryFor: 0, contextLevel: 0 }
23
24export const requests = atom({ plugin: 'session-info', key: 'requests' } as const, [])
25export const totals = atom({ plugin: 'session-info', key: 'totals' } as const, EMPTY_TOTALS)
26export const tick = atom({ plugin: 'session-info', key: 'tick' } as const, 0)
27export const breaks = atom({ plugin: 'session-info', key: 'breaks' } as const, [])
28export const turns = atom({ plugin: 'session-info', key: 'turns' } as const, [])
29export const turn = atom({ plugin: 'session-info', key: 'turn' } as const, null)
30export const tools = atom({ plugin: 'session-info', key: 'tools' } as const, [])
31export const signals = atom({ plugin: 'session-info', key: 'signals' } as const, EMPTY_SIGNALS)
32export const alerts = atom({ plugin: 'session-info', key: 'alerts' } as const, EMPTY_ALERTS)
33export const git = atom({ plugin: 'session-info', key: 'git' } as const, null)
34export const tab = atom({ plugin: 'session-info', key: 'tab' } as const, 'overview' as Tab)
35
36export type Options = {
37 ttlMs: number
38 ttlLabel: string
39 alertMode: 'toast' | 'toast+native' | 'off'
40}
41
42/** Context levels that raise an alert as the window fills past each. */
43const CONTEXT_LEVELS = [70, 85, 95]
44
45/** A $ call that may be unavailable on a surface or in a test reads as undefined. */
46export async function safe<T>(call: () => Promise<T>): Promise<T | undefined> {
47 try {
48 return await call()
49 } catch {
50 return undefined
51 }
52}
53
54/** The pinned line under the prompt: cache warmth, hit rate, context, cost. */
55export async function refreshStatus($: EngineInterface, opts: Options) {
56 const [list, now, usage] = await Promise.all([read($, requests), $.clock.now(), safe(() => $.session.usage())])
57 const state = cacheState(list, now, opts.ttlMs)
58 const parts: string[] = []
59
60 if (state.kind === 'warm') {
61 parts.push(`◆ cache warm ${short(state.remainingMs)} · hit ${hitOf(state.last)}%`)
62 } else if (state.kind === 'expired') {
63 parts.push(`◇ cache cold · idle ${short(state.agoMs)}`)
64 }
65 if (usage?.context.percent !== undefined) parts.push(`ctx ${usage.context.percent}%`)
66 if (usage?.cost !== undefined) parts.push(usd(usage.cost.usd))
67
68 $.ui.status(parts.length > 0 ? parts.join(' · ') : undefined)
69}
70
71function alert($: EngineInterface, opts: Options, text: string) {
72 $.ui.toast(text)
73 if (opts.alertMode === 'toast+native') void safe(() => $.ui.notify(text, { title: 'Session info' }))
74}
75
76/** Warns once before the cache goes cold, and once per context level crossed. */
77export async function checkAlerts($: EngineInterface, opts: Options) {
78 if (opts.alertMode === 'off') return
79 const [list, now, fired, usage] = await Promise.all([
80 read($, requests),
81 $.clock.now(),
82 read($, alerts),
83 safe(() => $.session.usage()),
84 ])
85 const state = cacheState(list, now, opts.ttlMs)
86 const warnMs = opts.ttlMs <= 5 * 60_000 ? 60_000 : 5 * 60_000
87 let next = fired
88
89 if (state.kind === 'warm' && state.remainingMs <= warnMs && fired.expiryFor !== state.last.at) {
90 const resend = promptOf(state.last) + state.last.output
91 alert($, opts, `Prompt cache expires in ${short(state.remainingMs)}; the next request re-caches ~${tokens(resend)} tokens`)
92 next = { ...next, expiryFor: state.last.at }
93 }
94
95 const pct = usage?.context.percent
96 if (pct !== undefined) {
97 const reached = CONTEXT_LEVELS.filter(l => pct >= l).at(-1) ?? 0
98 if (reached > fired.contextLevel) {
99 alert($, opts, `Context window ${pct}% full${reached >= 85 ? ', consider /compact' : ''}`)
100 }
101 if (reached !== fired.contextLevel) next = { ...next, contextLevel: reached }
102 }
103
104 if (next !== fired) await update($, alerts, () => next)
105}
106
107/** Reads the branch, its distance from upstream and the count of changed files. */
108export async function refreshGit($: EngineInterface) {
109 const run = await safe(() => $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 }))
110 let info: GitInfo | null = null
111 if (run !== undefined && run.exitCode === 0) {
112 info = { branch: 'detached', dirty: 0, ahead: 0, behind: 0 }
113 for (const line of run.stdout.split('\n')) {
114 if (line.startsWith('# branch.head ')) info.branch = line.slice(14)
115 else if (line.startsWith('# branch.ab ')) {
116 const [a, b] = line.slice(12).split(' ')
117 info.ahead = Math.abs(Number(a) || 0)
118 info.behind = Math.abs(Number(b) || 0)
119 } else if (line.length > 0 && !line.startsWith('#')) info.dirty += 1
120 }
121 }
122 const was = await read($, git)
123 if (JSON.stringify(was) !== JSON.stringify(info)) await update($, git, () => info)
124}
125
126export async function resetAll($: EngineInterface) {
127 await Promise.all([
128 update($, requests, () => []),
129 update($, totals, () => EMPTY_TOTALS),
130 update($, breaks, () => []),
131 update($, turns, () => []),
132 update($, turn, () => null),
133 update($, tools, () => []),
134 update($, signals, () => EMPTY_SIGNALS),
135 update($, alerts, () => EMPTY_ALERTS),
136 ])
137}
138hooks/view.tsx 762 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Frozen, RenderElement, RenderInput, SessionContextBreakdown } from 'claude-code'
3
4import type { Tab } from '../types'
5import {
6 ACCENT,
7 NEUTRAL,
8 RULE,
9 SERIES,
10 TRACK,
11 STATUS,
12 blockOf,
13 cacheState,
14 clamp,
15 clock,
16 columnsSvg,
17 duration,
18 hitOf,
19 level,
20 limitName,
21 meterSvg,
22 pct1,
23 percent,
24 promptOf,
25 ringSvg,
26 short,
27 stackedSvg,
28 swatchSvg,
29 textBar,
30 tok1,
31 tokens,
32 usd,
33} from './format'
34import type { Column, Segment } from './format'
35import { EMPTY_TOTALS, safe } from './state'
36import type { Options } from './state'
37
38const breaks = atom({ plugin: 'session-info', key: 'breaks' } as const, [])
39const expanded = atom({ plugin: 'session-info', key: 'expanded' } as const, [])
40const git = atom({ plugin: 'session-info', key: 'git' } as const, null)
41const requests = atom({ plugin: 'session-info', key: 'requests' } as const, [])
42const tab = atom({ plugin: 'session-info', key: 'tab' } as const, 'overview' as Tab)
43const tick = atom({ plugin: 'session-info', key: 'tick' } as const, 0)
44const tools = atom({ plugin: 'session-info', key: 'tools' } as const, [])
45const totals = atom({ plugin: 'session-info', key: 'totals' } as const, EMPTY_TOTALS)
46const turn = atom({ plugin: 'session-info', key: 'turn' } as const, null)
47const turns = atom({ plugin: 'session-info', key: 'turns' } as const, [])
48
49const TAB_LABELS: readonly [Tab, string][] = [
50 ['overview', 'Overview'],
51 ['context', 'Context'],
52 ['cache', 'Cache'],
53 ['turns', 'Turns'],
54]
55
56/** `claude-opus-5-5` → `Opus 5.5`; anything else as given. */
57const modelName = (id: string): string => {
58 const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
59 if (m === null) return id
60 const [, family = '', major, minor] = m
61 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}.${minor}`
62}
63
64const tail = (path: string, parts = 2) => path.split('/').filter(Boolean).slice(-parts).join('/')
65
66export async function drawPane($: EngineInterface, e: Frozen<RenderInput<'Pane'>>, opts: Options): Promise<RenderElement> {
67 const els = $.ui.resolve(e)
68 const { Box, Text, Button } = els
69 const Svg = 'Svg' in els ? els.Svg : undefined
70 const rich = Svg !== undefined
71
72 await read($, tick)
73 const [current, open, list, sum, cut, done, running, toolStats, repo, now] = await Promise.all([
74 read($, tab),
75 read($, expanded),
76 read($, requests),
77 read($, totals),
78 read($, breaks),
79 read($, turns),
80 read($, turn),
81 read($, tools),
82 read($, git),
83 $.clock.now(),
84 ])
85 const needsBreakdown = current === 'overview' || current === 'context'
86 const [usage, model, id, prompts, version, cwd] = await Promise.all([
87 safe(() => $.session.usage(needsBreakdown ? { breakdown: 'summary', columns: e.props.bodyColumns } : undefined)),
88 safe(() => $.session.model()),
89 safe(() => $.session.id()),
90 safe(() => $.session.turns()),
91 safe(() => $.session.version()),
92 safe(() => $.session.cwd()),
93 ])
94
95 const cols = Math.max(30, e.props.bodyColumns)
96 const wide = cols >= 84
97 const barChars = clamp(cols - 8, 16, 60)
98 const cache = cacheState(list, now, opts.ttlMs)
99 const warnMs = opts.ttlMs <= 5 * 60_000 ? 60_000 : 5 * 60_000
100 const ctx = usage?.context
101 const ctxPct = ctx?.percent ?? 0
102 const breakdown = usage?.context.breakdown
103 const sessionHit = percent(sum.cacheRead, promptOf(sum))
104
105 // Typography: the desktop's type scale where it has one, weight and ink in a terminal.
106 const title = rich ? ({ size: 'title', weight: 'semibold', tabular: true } as const) : ({ bold: true } as const)
107 const heading = rich ? ({ size: 'heading', weight: 'semibold' } as const) : ({ bold: true } as const)
108 const caption = rich ? ({ size: 'caption', dimColor: true } as const) : ({ dimColor: true } as const)
109 const num = rich ? ({ tabular: true } as const) : ({} as const)
110
111 // Pieces
112
113 const label = (text: string) => <Text {...caption}>{rich ? text : text.toUpperCase()}</Text>
114
115 const swatch = (color: string, key?: string) => (
116 <Text key={key} color={color}>
117 ■
118 </Text>
119 )
120
121 const card = (key: string, name: string, children: RenderElement[], width?: string) => (
122 <Box
123 key={key}
124 flexDirection="column"
125 borderStyle="round"
126 borderColor="subtle"
127 paddingX={1}
128 flexGrow={1}
129 width={width ?? (wide ? '49%' : '100%')}
130 marginBottom={1}
131 >
132 {label(name)}
133 {children}
134 </Box>
135 )
136
137 const stat = (key: string, name: string, value: string, color?: string) => (
138 <Box key={key} flexDirection="column" marginRight={3}>
139 {label(name)}
140 <Text {...heading} {...num} color={color}>
141 {value}
142 </Text>
143 </Box>
144 )
145
146 const line = (key: string, name: string, value: string, color?: string) => (
147 <Box key={key} justifyContent="space-between">
148 <Text dimColor>{name}</Text>
149 <Text {...num} color={color} wrap="truncate-start">
150 {value}
151 </Text>
152 </Box>
153 )
154
155 /** A gauge: a ring on desktop, a bar of blocks in a terminal. */
156 const gauge = (key: string, fraction: number, color: string, alt: string) =>
157 Svg !== undefined ? (
158 <Svg key={key} source={ringSvg(fraction, color)} alt={alt} width={64} height={64} />
159 ) : (
160 <Text key={key}>
161 <Text color={color}>{textBar(fraction, 12)[0]}</Text>
162 <Text dimColor>{textBar(fraction, 12)[1]}</Text>
163 </Text>
164 )
165
166 /** Columns: an SVG chart on desktop, a sparkline of blocks in a terminal. */
167 const chart = (key: string, data: readonly Column[], alt: string, height: number, max?: number, guide?: number) =>
168 Svg !== undefined ? (
169 <Svg key={key} source={columnsSvg(data, 640, height, max, guide)} alt={alt} isInteractive />
170 ) : (
171 <Text key={key}>
172 {data.slice(-barChars).map((c, i) => (
173 <Text key={String(i)} color={c.color}>
174 {blockOf(c.value / (max ?? Math.max(1, ...data.map(d => d.value))))}
175 </Text>
176 ))}
177 </Text>
178 )
179
180 /** One stacked bar: an SVG with a dashed threshold on desktop, colored blocks in a terminal. */
181 const stacked = (key: string, segments: readonly Segment[], alt: string, marker?: number) => {
182 if (Svg !== undefined) {
183 return <Svg key={key} source={stackedSvg(segments, 640, 14, marker)} alt={alt} isInteractive />
184 }
185 let used = 0
186 const parts = segments.map((s, i) => {
187 const n = i === segments.length - 1 ? Math.max(0, barChars - used) : Math.round(s.fraction * barChars)
188 used += n
189 return s.color === 'transparent' ? (
190 <Text key={String(i)} dimColor>
191 {'░'.repeat(n)}
192 </Text>
193 ) : (
194 <Text key={String(i)} color={s.color}>
195 {'█'.repeat(n)}
196 </Text>
197 )
198 })
199 return (
200 <Box key={key} flexDirection="column">
201 <Text>{parts}</Text>
202 {marker !== undefined && <Text dimColor>{`${' '.repeat(Math.max(0, Math.round(marker * barChars) - 1))}▲ auto-compact`}</Text>}
203 </Box>
204 )
205 }
206
207 const legend = (key: string, items: readonly [string, string][]) => (
208 <Box key={key} flexWrap="wrap" columnGap={2}>
209 {items.map(([color, text]) => (
210 <Text key={text}>
211 <Text color={color}>■</Text> <Text dimColor>{text}</Text>
212 </Text>
213 ))}
214 </Box>
215 )
216
217 const hitColumns: Column[] = list.map(r => ({
218 value: hitOf(r),
219 color: r.isBreak === true ? STATUS.critical : ACCENT,
220 label: `${clock(r.at)} · ${hitOf(r)}% hit · read ${tokens(r.cacheRead)} · wrote ${tokens(r.cacheWrite)}${r.isBreak === true ? ' · cache break' : ''}`,
221 }))
222
223 // Header: who, where the cache stands, what it cost; then the tabs.
224
225 const pill =
226 cache.kind === 'warm'
227 ? { text: `● Cache warm · ${short(cache.remainingMs)}`, color: cache.remainingMs <= warnMs ? STATUS.warning : STATUS.good }
228 : cache.kind === 'expired'
229 ? { text: `○ Cache cold · idle ${short(cache.agoMs)}`, color: STATUS.serious }
230 : { text: '○ No requests yet', color: RULE }
231
232 const header = (
233 <Box key="header" flexDirection="column" marginBottom={1}>
234 <Box justifyContent="space-between" flexWrap="wrap">
235 <Text {...heading}>{modelName(model ?? 'Claude')}</Text>
236 <Box columnGap={2}>
237 <Text color={pill.color}>{pill.text}</Text>
238 {usage?.cost !== undefined && (
239 <Text {...num} bold>
240 {usd(usage.cost.usd)}
241 </Text>
242 )}
243 </Box>
244 </Box>
245 <Box columnGap={1} marginTop={1}>
246 {TAB_LABELS.map(([t, name], i) => (
247 <Button
248 key={`tab-${t}`}
249 label={name}
250 hotkey={String(i + 1)}
251 variant={t === current ? 'primary' : 'secondary'}
252 onPress={() => update($, tab, () => t)}
253 />
254 ))}
255 </Box>
256 </Box>
257 )
258
259 // Context
260
261 type Row = { name: string; tokens: number; color: string; dim?: boolean; empty?: boolean }
262
263 /** The breakdown as rows, largest first: four hues, then neutral; buffer and free after. */
264 const contextRows = (b: SessionContextBreakdown) => {
265 const used: Row[] = b.categories
266 .filter(c => c.kind === 'used' && c.tokens > 0)
267 .sort((x, y) => y.tokens - x.tokens)
268 .map((c, i) => ({ name: c.name, tokens: c.tokens, color: i < 4 ? (SERIES[i] ?? NEUTRAL) : NEUTRAL }))
269 const buffer = b.categories.find(c => c.kind === 'buffer' && c.tokens > 0)
270 const free = b.categories.find(c => c.kind === 'free')
271 const reserve: Row[] = [
272 ...(buffer !== undefined ? [{ name: buffer.name, tokens: buffer.tokens, color: RULE }] : []),
273 ...(free !== undefined ? [{ name: free.name, tokens: free.tokens, color: TRACK, empty: true }] : []),
274 ]
275 const deferred: Row[] = b.categories
276 .filter(c => c.kind === 'deferred' && c.tokens > 0)
277 .map(c => ({ name: c.name, tokens: c.tokens, color: RULE, dim: true }))
278 const max = Math.max(1, b.rawMaxTokens)
279 const segments: Segment[] = [
280 ...used.map(r => ({ fraction: r.tokens / max, color: r.color, label: `${r.name}: ${tok1(r.tokens)}` })),
281 ...(free !== undefined ? [{ fraction: free.tokens / max, color: 'transparent', label: `${free.name}: ${tok1(free.tokens)}` }] : []),
282 ...(buffer !== undefined ? [{ fraction: buffer.tokens / max, color: RULE, label: `${buffer.name}: ${tok1(buffer.tokens)}` }] : []),
283 ]
284 return { used, reserve, deferred, segments, max }
285 }
286
287 const dot = (color: string, empty?: boolean) =>
288 Svg !== undefined ? (
289 <Svg source={swatchSvg(color)} alt="" width={10} height={10} />
290 ) : empty === true ? (
291 <Text dimColor>□</Text>
292 ) : (
293 <Text color={color}>■</Text>
294 )
295
296 /** One line: swatch and name, then tokens and share in fixed right-aligned columns. */
297 const contextRow = (r: Row, max: number) => (
298 <Box key={r.name} columnGap={1} alignItems="center">
299 <Box columnGap={1} flexGrow={1} flexShrink={1} alignItems="center">
300 {dot(r.color, r.empty)}
301 <Text dimColor={r.dim} wrap="truncate-end">
302 {r.name}
303 </Text>
304 </Box>
305 <Box width={7} justifyContent="flex-end">
306 <Text {...num} dimColor>
307 {tok1(r.tokens)}
308 </Text>
309 </Box>
310 <Box width={6} justifyContent="flex-end">
311 <Text {...num} dimColor={r.dim}>
312 {r.dim === true ? '—' : pct1(r.tokens, max)}
313 </Text>
314 </Box>
315 </Box>
316 )
317
318 /** "28% used · 281.4k / 1M" over a thin bar. */
319 const contextHead = (b: SessionContextBreakdown, segments: readonly Segment[], marker?: number) => (
320 <Box key="head" flexDirection="column" marginBottom={1}>
321 <Box justifyContent="space-between">
322 <Text bold color={b.percentage >= 65 ? level(b.percentage, 65, 85) : undefined}>{`${b.percentage}% used`}</Text>
323 <Text {...num} dimColor>{`${tok1(b.totalTokens)} / ${tok1(b.rawMaxTokens)}`}</Text>
324 </Box>
325 {Svg !== undefined ? (
326 <Svg source={stackedSvg(segments, 640, 6, marker)} alt={`Context ${b.percentage}% used`} isInteractive />
327 ) : (
328 stacked('bar', segments, 'Context by category', marker)
329 )}
330 </Box>
331 )
332
333 const noBreakdown = (key: string) => (
334 <Text key={key} dimColor>
335 {ctx?.tokens !== undefined ? `${tokens(ctx.tokens)} of ${tokens(ctx.window)} · breakdown loading` : 'The breakdown appears after the first response.'}
336 </Text>
337 )
338
339 /** A collapsible list: a chevron row with its count, and its rows when open. */
340 const section = (key: string, name: string, count: string, rows: () => RenderElement[]) => {
341 const isOpen = open.includes(key)
342 return (
343 <Box key={key} flexDirection="column">
344 <Box justifyContent="space-between" alignItems="center">
345 <Button
346 key={`exp-${key}`}
347 plain
348 onPress={() => update($, expanded, l => ((l ?? []).includes(key) ? (l ?? []).filter(k => k !== key) : [...(l ?? []), key]))}
349 >
350 {isOpen ? '⌄' : '›'} {name}
351 </Button>
352 <Text {...num} dimColor>
353 {count}
354 </Text>
355 </Box>
356 {isOpen && (
357 <Box flexDirection="column" paddingLeft={2}>
358 {rows()}
359 </Box>
360 )}
361 </Box>
362 )
363 }
364
365 const detailRow = (key: string, name: string, value: string, note?: string) => (
366 <Box key={key} columnGap={1}>
367 <Box flexGrow={1} flexShrink={1}>
368 <Text wrap="truncate-end">
369 {name}
370 {note !== undefined && <Text dimColor>{` ${note}`}</Text>}
371 </Text>
372 </Box>
373 <Box width={7} justifyContent="flex-end">
374 <Text {...num} dimColor>
375 {value}
376 </Text>
377 </Box>
378 </Box>
379 )
380
381 const more = (key: string, n: number, what: string) =>
382 n > 0 ? [<Text key={key} dimColor>{`+${n} more ${what}`}</Text>] : []
383
384 const contextTab = (): RenderElement[] => {
385 if (breakdown === undefined) return [noBreakdown('none')]
386 const { used, reserve, deferred, segments, max } = contextRows(breakdown)
387 const threshold = breakdown.autoCompactThreshold
388 const marker = breakdown.isAutoCompactEnabled && threshold !== undefined ? threshold / max : undefined
389
390 const servers = new Map<string, { tokens: number; tools: number; deferred: number }>()
391 for (const t of breakdown.mcpTools) {
392 const s = servers.get(t.serverName) ?? { tokens: 0, tools: 0, deferred: 0 }
393 servers.set(t.serverName, { tokens: s.tokens + t.tokens, tools: s.tools + 1, deferred: s.deferred + (t.isLoaded ? 0 : 1) })
394 }
395 const byServer = [...servers.entries()].sort((a, b) => b[1].tokens - a[1].tokens)
396 const memory = [...breakdown.memoryFiles].sort((a, b) => b.tokens - a.tokens)
397 const skills = breakdown.skills
398 const skillRows = skills !== undefined ? [...skills.skillFrontmatter].sort((a, b) => b.tokens - a.tokens) : []
399 const agents = [...breakdown.agents].sort((a, b) => b.tokens - a.tokens)
400 const LIMIT = 12
401
402 return [
403 <Box key="stats" flexWrap="wrap" marginBottom={1}>
404 {stat('ac', 'Until auto-compact', breakdown.isAutoCompactEnabled && threshold !== undefined ? tok1(Math.max(0, threshold - breakdown.totalTokens)) : 'off')}
405 {stat('api', 'Last response', breakdown.apiUsage !== null ? tok1(promptOf({ input: breakdown.apiUsage.input_tokens, cacheRead: breakdown.apiUsage.cache_read_input_tokens, cacheWrite: breakdown.apiUsage.cache_creation_input_tokens })) : '—')}
406 {stat('model', 'Window of', modelName(breakdown.model))}
407 </Box>,
408 card(
409 'context',
410 'Context',
411 [
412 contextHead(breakdown, segments, marker),
413 ...used.map(r => contextRow(r, max)),
414 ...reserve.map(r => contextRow(r, max)),
415 ...(deferred.length > 0 ? [<Box key="gap-deferred" marginTop={1} />, ...deferred.map(r => contextRow(r, max))] : []),
416 <Box key="gap-sections" marginTop={1} />,
417 ...(byServer.length > 0
418 ? [
419 section('mcp', 'MCP tools', String(breakdown.mcpTools.length), () => [
420 ...byServer
421 .slice(0, LIMIT)
422 .map(([name, s]) => detailRow(name, name, tok1(s.tokens), `${s.tools} tools${s.deferred > 0 ? ` · ${s.deferred} deferred` : ''}`)),
423 ...more('more-mcp', byServer.length - LIMIT, 'servers'),
424 ]),
425 ]
426 : []),
427 ...(memory.length > 0
428 ? [
429 section('memory', 'Memory files', String(memory.length), () =>
430 memory.slice(0, LIMIT).map(m => detailRow(m.path, tail(m.path, 3), tok1(m.tokens), m.type)),
431 ),
432 ]
433 : []),
434 ...(skills !== undefined && skills.totalSkills > 0
435 ? [
436 section('skills', 'Skills', `${skills.includedSkills}/${skills.totalSkills}`, () => [
437 ...skillRows.slice(0, LIMIT).map(k => detailRow(k.name, k.name, tok1(k.tokens), k.pluginName)),
438 ...more('more-skills', skillRows.length - LIMIT, 'skills'),
439 ]),
440 ]
441 : []),
442 ...(agents.length > 0
443 ? [
444 section('agents', 'Agent types', String(agents.length), () =>
445 agents.slice(0, LIMIT).map(a => detailRow(a.agentType, a.agentType, tok1(a.tokens), a.source)),
446 ),
447 ]
448 : []),
449 ...(breakdown.slashCommands !== undefined
450 ? [detailRow('commands', 'Slash commands', tok1(breakdown.slashCommands.tokens), `${breakdown.slashCommands.includedCommands}/${breakdown.slashCommands.totalCommands}`)]
451 : []),
452 ],
453 '100%',
454 ),
455 ]
456 }
457
458 // Cache
459
460 const cacheHero = (key: string): RenderElement => {
461 const fraction = cache.kind === 'warm' ? cache.remainingMs / opts.ttlMs : 0
462 const color = cache.kind === 'warm' ? (cache.remainingMs <= warnMs ? STATUS.warning : STATUS.good) : STATUS.serious
463 const big = cache.kind === 'warm' ? short(cache.remainingMs) : cache.kind === 'expired' ? 'Cold' : '—'
464 const sub =
465 cache.kind === 'warm'
466 ? `left of ${opts.ttlLabel} · refreshed by each request`
467 : cache.kind === 'expired'
468 ? `idle ${short(cache.agoMs)} · next request re-caches ~${tokens(promptOf(cache.last) + cache.last.output)}`
469 : 'waiting for the first request'
470 return (
471 <Box key={key} columnGap={2} alignItems="center">
472 {gauge('ring', fraction, color, `Cache ${big}`)}
473 <Box flexDirection="column">
474 <Text {...title} color={color}>
475 {big}
476 </Text>
477 <Text dimColor>{sub}</Text>
478 </Box>
479 </Box>
480 )
481 }
482
483 const hitChart = (key: string, height: number) => (
484 <Box key={key} flexDirection="column" marginTop={1}>
485 {label(`Hit rate · last ${list.length} requests`)}
486 {list.length > 0 ? chart('chart', hitColumns, `Cache hit rate per request, ${sessionHit}% overall`, height, 100, 80) : <Text dimColor>No requests yet.</Text>}
487 {legend('legend', [
488 [ACCENT, 'hit %'],
489 [STATUS.critical, 'cache break'],
490 [RULE, '80% guide'],
491 ])}
492 </Box>
493 )
494
495 const cacheTab = (): RenderElement[] => {
496 const last = list.at(-1)
497 return [
498 cacheHero('hero'),
499 <Box key="stats" flexWrap="wrap" marginTop={1}>
500 {stat('session', 'Session hit', `${sessionHit}%`, level(100 - sessionHit, 20, 50))}
501 {last !== undefined && stat('last', 'Last hit', `${hitOf(last)}%`)}
502 {stat('read', 'Read', tokens(sum.cacheRead))}
503 {stat('wrote', 'Written', tokens(sum.cacheWrite))}
504 {stat('fresh', 'Uncached', tokens(sum.input))}
505 {stat('breaks', 'Breaks', String(cut.length), cut.length > 0 ? STATUS.serious : undefined)}
506 </Box>,
507 hitChart('hits', 72),
508 card(
509 'breaks',
510 'Cache breaks',
511 cut.length === 0
512 ? [<Text key="none" dimColor>None: every request reused the prefix before it.</Text>]
513 : cut
514 .slice(-6)
515 .reverse()
516 .map(b => (
517 <Box key={String(b.at)} flexDirection="column" marginBottom={1}>
518 <Text>
519 <Text color={STATUS.critical}>● </Text>
520 <Text {...num} dimColor>
521 {clock(b.at)}{' '}
522 </Text>
523 {b.cause}
524 </Text>
525 <Text dimColor>{` re-cached ${tokens(b.rewritten)} · read ${tokens(b.read)} of ${tokens(b.expected)} expected`}</Text>
526 </Box>
527 )),
528 '100%',
529 ),
530 card(
531 'recent',
532 'Recent requests',
533 [
534 <Text key="head" dimColor mono>
535 {`${'time'.padEnd(9)}${'hit'.padStart(5)}${'read'.padStart(8)}${'write'.padStart(8)}${'new'.padStart(7)}${'out'.padStart(7)}`}
536 </Text>,
537 ...list
538 .slice(-8)
539 .reverse()
540 .map(r => (
541 <Text key={String(r.at)} mono color={r.isBreak === true ? STATUS.critical : hitOf(r) < 80 ? STATUS.warning : undefined}>
542 {`${clock(r.at).padEnd(9)}${`${hitOf(r)}%`.padStart(5)}${tokens(r.cacheRead).padStart(8)}${tokens(r.cacheWrite).padStart(8)}${tokens(r.input).padStart(7)}${tokens(r.output).padStart(7)}`}
543 </Text>
544 )),
545 ],
546 '100%',
547 ),
548 ...(sum.subagentRequests > 0
549 ? [<Text key="sub" dimColor>{`Subagents made ${sum.subagentRequests} requests on caches of their own, not counted here.`}</Text>]
550 : []),
551 ]
552 }
553
554 // Turns
555
556 const turnsTab = (): RenderElement[] => {
557 const n = done.length
558 const avg = n > 0 ? done.reduce((s, t) => s + t.durationMs, 0) / n : 0
559 const toolsPer = n > 0 ? done.reduce((s, t) => s + t.tools, 0) / n : 0
560 const calls = toolStats.reduce((s, t) => s + t.calls, 0)
561 const errs = toolStats.reduce((s, t) => s + t.errors, 0)
562 const costPer = n > 0 ? done.reduce((s, t) => s + t.costUsd, 0) / n : 0
563 const top = [...toolStats].sort((a, b) => b.calls - a.calls).slice(0, 8)
564 const most = Math.max(1, ...top.map(t => t.calls))
565 const durations: Column[] = done.map(t => ({
566 value: t.durationMs / 1000,
567 color: t.reason === 'answer' ? ACCENT : t.reason === 'aborted' ? RULE : STATUS.serious,
568 label: `${clock(t.at)} · ${duration(t.durationMs)} · ${t.requests} requests · ${t.tools} tools · ${usd(t.costUsd)}`,
569 }))
570
571 return [
572 ...(running !== null
573 ? [
574 <Box key="live" columnGap={1} marginBottom={1}>
575 <Text color={STATUS.good}>● Running</Text>
576 <Text dimColor>{`${short(now - running.startedAt)} · ${running.requests} requests · ${running.tools} tools${running.errors > 0 ? ` · ${running.errors} failed` : ''}`}</Text>
577 </Box>,
578 ]
579 : []),
580 <Box key="stats" flexWrap="wrap">
581 {stat('turns', 'Turns', String(n))}
582 {stat('avg', 'Avg duration', n > 0 ? duration(avg) : '—')}
583 {stat('tools', 'Tools / turn', n > 0 ? toolsPer.toFixed(1) : '—')}
584 {stat('errors', 'Tool errors', calls > 0 ? `${percent(errs, calls)}%` : '—', errs > 0 ? STATUS.serious : undefined)}
585 {stat('cost', 'Cost / turn', n > 0 ? usd(costPer) : '—')}
586 </Box>,
587 <Box key="durations" flexDirection="column" marginTop={1}>
588 {label(`Duration · last ${n} turns`)}
589 {n > 0 ? chart('chart', durations, `Turn durations, average ${duration(avg)}`, 72, undefined, avg / 1000) : <Text dimColor>No finished turns yet.</Text>}
590 {legend('legend', [
591 [ACCENT, 'answered'],
592 [STATUS.serious, 'error or refusal'],
593 [RULE, 'interrupted · dashed: average'],
594 ])}
595 </Box>,
596 card(
597 'tools',
598 'Tools',
599 top.length === 0
600 ? [<Text key="none" dimColor>No tool calls yet.</Text>]
601 : top.map(t => (
602 <Box key={t.tool} columnGap={1} alignItems="center">
603 <Box width={18}>
604 <Text wrap="truncate-end">{t.tool}</Text>
605 </Box>
606 {Svg !== undefined ? (
607 <Svg source={meterSvg(t.calls / most, ACCENT)} alt={`${t.calls} calls`} width={140} height={8} />
608 ) : (
609 <Text color={ACCENT}>{textBar(t.calls / most, 12)[0].padEnd(12)}</Text>
610 )}
611 <Text {...num}>{String(t.calls).padStart(4)}</Text>
612 <Text {...num} dimColor>
613 {`${short(t.totalMs / t.calls)} avg`}
614 </Text>
615 {t.errors > 0 && <Text color={STATUS.critical}>{`${t.errors} failed`}</Text>}
616 </Box>
617 )),
618 '100%',
619 ),
620 ]
621 }
622
623 // Overview
624
625 const overviewTab = (): RenderElement[] => {
626 const costs: Column[] = done.map(t => ({ value: t.costUsd, color: ACCENT, label: `${clock(t.at)} · ${usd(t.costUsd)}` }))
627 const hasFill = ctx?.tokens !== undefined
628 const ctxColor = hasFill ? level(ctxPct, 65, 85) : RULE
629 const threshold = breakdown?.isAutoCompactEnabled === true ? breakdown.autoCompactThreshold : undefined
630 const contextCard = card('ctx', 'Context', [
631 <Box key="hero" columnGap={2} alignItems="center">
632 {gauge('ring', hasFill ? ctxPct / 100 : 0, ctxColor, `Context ${ctxPct}% used`)}
633 <Box flexDirection="column">
634 <Text {...title} color={hasFill ? ctxColor : undefined}>
635 {hasFill ? `${ctxPct}%` : '—'}
636 </Text>
637 <Text dimColor>{hasFill ? `${tok1(ctx.tokens ?? 0)} of ${tok1(ctx.window)} used` : 'waiting for the first response'}</Text>
638 </Box>
639 </Box>,
640 <Text key="line" dimColor>
641 {breakdown !== undefined
642 ? threshold !== undefined
643 ? `${tok1(Math.max(0, threshold - breakdown.totalTokens))} until auto-compact`
644 : 'Auto-compact off'
645 : ' '}
646 </Text>,
647 ])
648
649 const cacheCard = card('cache', 'Prompt cache', [
650 cacheHero('hero'),
651 <Text key="hit" dimColor>
652 {list.length > 0 ? `Hit ${hitOf(list.at(-1) ?? list[0]!)}% last · ${sessionHit}% session${cut.length > 0 ? ` · ${cut.length} breaks` : ''}` : ' '}
653 </Text>,
654 ])
655
656 const costCard = card('cost', 'Cost', [
657 <Text key="big" {...title}>
658 {usage?.cost !== undefined ? usd(usage.cost.usd) : '—'}
659 </Text>,
660 <Text key="sub" dimColor>
661 {done.length > 0 ? `${usd(done.reduce((s, t) => s + t.costUsd, 0) / done.length)} per turn · ${done.length} turns` : 'no finished turns yet'}
662 </Text>,
663 ...(done.length > 1 ? [chart('chart', costs, 'Cost per turn', 36)] : []),
664 ])
665
666 const limits = usage?.rateLimits ?? []
667 const limitsCard = card(
668 'limits',
669 'Rate limits',
670 limits.length === 0
671 ? [<Text key="none" dimColor>No reading yet.</Text>]
672 : limits.map(l => {
673 const color = level(l.percentUsed, 70, 90)
674 const resets = l.resetsAt !== undefined ? ` · resets ${short(Date.parse(l.resetsAt) - now)}` : ''
675 return (
676 <Box key={l.kind} flexDirection="column" marginBottom={1}>
677 <Box justifyContent="space-between">
678 <Text>{limitName(l.kind)}</Text>
679 <Text {...num} dimColor>{`${l.percentUsed}%${resets}`}</Text>
680 </Box>
681 {Svg !== undefined ? (
682 <Svg source={meterSvg(l.percentUsed / 100, color, 320, 8)} alt={`${limitName(l.kind)} ${l.percentUsed}%`} />
683 ) : (
684 <Text>
685 <Text color={color}>{textBar(l.percentUsed / 100, 24)[0]}</Text>
686 <Text dimColor>{textBar(l.percentUsed / 100, 24)[1]}</Text>
687 </Text>
688 )}
689 </Box>
690 )
691 }),
692 )
693
694 const branch =
695 repo !== null
696 ? `⎇ ${repo.branch}${repo.dirty > 0 ? ` · ${repo.dirty} changed` : ' · clean'}${repo.ahead > 0 ? ` · ↑${repo.ahead}` : ''}${repo.behind > 0 ? ` · ↓${repo.behind}` : ''}`
697 : undefined
698
699 const sessionCard = card(
700 'session',
701 'Session',
702 [
703 ...(branch !== undefined ? [line('git', 'Branch', branch, repo !== null && repo.dirty > 0 ? STATUS.warning : undefined)] : []),
704 ...(cwd !== undefined ? [line('cwd', 'Directory', tail(cwd, 3))] : []),
705 ...(usage !== undefined ? [line('up', 'Running', `${duration(now - usage.startedAt)} · since ${clock(usage.startedAt)}`)] : []),
706 ...(prompts !== undefined ? [line('prompts', 'Prompts', String(prompts))] : []),
707 ...(model !== undefined ? [line('model', 'Model', model)] : []),
708 ...(version !== undefined ? [line('version', 'Claude Code', version.version)] : []),
709 ...(id !== undefined ? [line('id', 'Session id', id)] : []),
710 <Box key="actions" columnGap={1} marginTop={1}>
711 {id !== undefined && (
712 <Button
713 key="copy"
714 label="Copy session id"
715 onPress={async () => {
716 const copied = await $.ui.copy({ text: id, surface: e.surface })
717 $.ui.toast(copied.isCopied ? 'Session id copied' : 'Could not reach the clipboard')
718 }}
719 />
720 )}
721 <Button
722 key="compact"
723 label="Compact now"
724 onPress={async () => {
725 try {
726 const done = await $.session.compact()
727 $.ui.toast(done.skip === undefined ? 'Conversation compacted' : `Compaction skipped: ${done.skip}`)
728 } catch {
729 $.ui.toast('Compaction runs between turns; try again when Claude is idle')
730 }
731 }}
732 />
733 </Box>,
734 ],
735 '100%',
736 )
737
738 return [
739 <Box key="cards" flexWrap="wrap" columnGap={1}>
740 {contextCard}
741 {cacheCard}
742 {costCard}
743 {limitsCard}
744 </Box>,
745 hitChart('hits', 48),
746 <Box key="gap" marginTop={1} />,
747 sessionCard,
748 ]
749 }
750
751 const body =
752 current === 'context' ? contextTab() : current === 'cache' ? cacheTab() : current === 'turns' ? turnsTab() : overviewTab()
753
754 return (
755 <Box flexDirection="column">
756 {header}
757 {body}
758 </Box>
759 )
760}
761
762types/index.d.ts 87 lines1/** One main-loop API request, as its usage reported it. */
2export type CacheRequest = {
3 at: number
4 model: string
5 input: number
6 cacheRead: number
7 cacheWrite: number
8 output: number
9 /** Set when this request re-cached a prefix the previous one had cached. */
10 isBreak?: boolean
11}
12
13/** Token counts summed over every main-loop request this session. */
14export type CacheTotals = {
15 requests: number
16 input: number
17 cacheRead: number
18 cacheWrite: number
19 output: number
20 subagentRequests: number
21}
22
23/** A request whose cache read fell well short of the prefix cached before it. */
24export type CacheBreak = {
25 at: number
26 /** Tokens written to the cache by the breaking request. */
27 rewritten: number
28 /** Tokens the cache served, against what the previous request had cached. */
29 read: number
30 expected: number
31 cause: string
32}
33
34/** One finished main-loop turn. */
35export type TurnStat = {
36 at: number
37 durationMs: number
38 requests: number
39 tools: number
40 errors: number
41 costUsd: number
42 outputTokens: number
43 reason: string
44}
45
46/** The turn running now, filled as its requests and tool calls land. */
47export type TurnInFlight = {
48 turnId: string
49 startedAt: number
50 costAtStart: number
51 requests: number
52 tools: number
53 errors: number
54}
55
56export type ToolStat = { tool: string; calls: number; errors: number; totalMs: number }
57
58/** What happened since the last main-loop request, read to explain a break. */
59export type Signals = { compactedAt: number; promptKey: string; promptChangedAt: number }
60
61/** Which alerts have fired, so each fires once. */
62export type Alerts = { expiryFor: number; contextLevel: number }
63
64export type GitInfo = { branch: string; dirty: number; ahead: number; behind: number }
65
66export type Tab = 'overview' | 'context' | 'cache' | 'turns'
67
68declare module 'claude-code' {
69 interface PluginState {
70 'session-info': {
71 requests: CacheRequest[]
72 totals: CacheTotals
73 tick: number
74 breaks: CacheBreak[]
75 turns: TurnStat[]
76 turn: TurnInFlight | null
77 tools: ToolStat[]
78 signals: Signals
79 alerts: Alerts
80 git: GitInfo | null
81 tab: Tab
82 /** Which collapsible sections of the context card are open. */
83 expanded: string[]
84 }
85 }
86}
87