SLOPSHOPPER

session-info

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

paneguardcommandtoaststatusprompt
v0.2.0no licenseupdated 2026-10-17ruisaraiva19/claude-session-info
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-info
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ Opus 5.5 ○ No requests yet $0.420 │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Overview ] [ Context ] [ Cache ] [ Turns ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ CONTEXT │ ⏺ Bash(bun test) │ ┃ │ ██████░░░░░░ 49% │ ⎿ 3 pass, 1 fail │ ┃ │ 97.4k of 200k used │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ HIT RATE · LAST 0 REQUESTS › /session-info │ ┃ No requests yet. ⎿ session-info: Session dashboard opened. │ ┃ ■ hit % ■ cache break ■ 80% guide │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ SESSION │ │ ┃ │ Branch ⎇ detached · 2 changed │ │ ┃ │ Directory work/app │ │ ┃ │ Running 30m 00s · since 08:23:20 │ │ ┃ │ Prompts 1 │ │ ┃ │ Model claude-opus-5-5 │ │ ┃ │ Claude Code 2.1.289 │ │ ┃ │ Session id preview-session │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-info: ctx 49% · $0.420

Draws

Pane · Session
Opus 5.5 ○ No requests yet $0.420 [ Overview ] [ Context ] [ Cache ] [ Turns ] ╭──────────────────────────────────────────────────────────╮ │ CONTEXT │ │ ██████░░░░░░ 49% │ │ 97.4k of 200k used │ │ │ ╰──────────────────────────────────────────────────────────╯ HIT RATE · LAST 0 REQUESTS No requests yet. ■ hit % ■ cache break ■ 80% guide ╭──────────────────────────────────────────────────────────╮ │ SESSION │ │ Branch ⎇ detached · 2 changed │ │ Directory work/app │ │ Running 30m 00s · since 08:23:20 │ │ Prompts 1 │ │ Model claude-opus-5-5 │ │ Claude Code 2.1.289 │ │ Session id preview-session │ │ │ │ [ Copy session id ] [ Compact now ] │ ╰──────────────────────────────────────────────────────────╯
README

session-info

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.

Install

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.

What you get

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.

  • Overview: context and prompt-cache gauges, cost per turn, rate-limit windows, a hit-rate chart of the last 50 requests, and session details: git branch and changed files, directory, uptime, version and session id, with Copy session id and Compact now buttons.
  • Context: the window by category on a bar with the auto-compact threshold marked, deferred tool schemas, and expandable MCP servers, memory files, skills and agent types.
  • Cache: time left before the cache goes cold, hit rates, tokens read and written, every cache break with its likely cause, and the recent requests.
  • Turns: the running turn, average duration, tools per turn, tool error rate, cost per turn, a duration chart and the tools you use most.

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.

Settings

Set in /config (or pluginConfigs in settings):

SettingValuesDefaultWhat it does
cacheTtl5m, 1h1hHow long a prompt-cache entry lives; Claude Code doesn't expose it, so set it to match your requests.
alertstoast, toast+native, offtoastCache-expiry, context-fill and cache-break alerts.

How the numbers are read

  • Cache figures come from the token usage the API reports for each main-conversation request. Subagents keep caches of their own and are counted apart.
  • A cache break is a request that read less than 60% of the prefix the request before it had cached. Its cause is a best guess from what happened in between: compaction, a model switch, the cache timing out, a changed system prompt or tool list, or early eviction.
  • Where an organization manages Claude Code, the compaction and prompt-composition events only reach plugins it lists, so break causes there are less specific.

Development

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.

Source 5 files
hooks/register.tsx 243 lines
1import { 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}
243
hooks/format.ts 207 lines
1import 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}"/>`)
207
hooks/state.ts 138 lines
1import { 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}
138
hooks/view.tsx 762 lines
1import { 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
762
types/index.d.ts 87 lines
1/** 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