SLOPSHOPPER

usage-meter

A live Session pane and a control bar for Claude Code: memory and limits used, model and effort pickers, agent progress, plugins and connectors.

newpanebandguardcommandtoast
v1.0.0MITupdated 2026-10-12Rezwan3App/claude-usage-meter-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Usage ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ◑ 49% memory used ⏺ Update(src/auth.ts) │ ┃ When it runs out, Claude compresses olde ⎿ Added 2 lines, removed 1 line │ ┃ messages and carries on. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 5-hour limit ██████────────────── 31% us │ ┃ 7-day limit ──────────────────── — ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 98.9k tokens this session, 98.9k all time ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /usage-meter │ ┃ ⎿ usage-meter: Session pane opened. │ ┃ │ ┃ Agents │ ┃ │ ┃ Finished 1 │ ┃ ▐▛█▜▌ Claude │ ┃ ▝▜█▛▘ main session Opus 5.5 │ ┃ ▸ 9 tool calls │ ┃ █████████████████████████████████████ │ ┃ │ ┃ │ ┃ Model:: Opus 5.5 ▾ Effort:: default ▾ Agents:: just Claude ▾ 📁 /work/app Done ████████████████████████ 100% 1/1 agents ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Model:: Opus 5.5 ▾ Effort:: default ▾ Agents:: just Claude ▾ 📁 /work/app Done ████████████████████████ 100% 1/1 agents
Pane · Session
Usage ◑ 49% memory used When it runs out, Claude compresses older messages and carries on. 5-hour limit ██████────────────── 31% used resets in N 7-day limit ──────────────────── — 98.9k tokens this session, 98.9k all time Agents Finished 1 ▐▛█▜▌ Claude ▝▜█▛▘ main session Opus 5.5 ▸ 9 tool calls ███████████████████████████████████████████████ Setup ▸ Plugins (0) ▸ Connectors (0) ▐▛███▜▌ ▝▜█████▛▘ ▘▘ ▝▝ Nice! Done in 42s
README

<h1 align="center">usage-meter for Claude Code</h1>

See what your Claude Code session is doing at a glance: how full the conversation is, how much of your limits you've used, which agents are working, and which model you're on. Switch model, effort and agent count from a bar right above the chat box.

🖼️ Demo

Control bar, right above the chat box

Control bar above the chat box: Model, Effort and Agents dropdowns, the working folder, and a progress bar at 80% with 4 of 5 agents done

Session pane, docked on the right

Session pane: a memory ring at 11% used, 5-hour and 7-day limit bars, token totals, running and finished agents, and a Setup section with installed plugins

✨ What you get

🎛️ Control bar (above the chat box)

PartWhat it does
ModelDropdown to switch between Opus, Sonnet, Haiku and Fable without typing /model
EffortDropdown for low, medium, high, xhigh or max
AgentsAsk Claude to split each task across 2 to 6 parallel agents, or leave it on "just Claude"
📁 FolderThe folder this session works in
ProgressWhile Claude works: a bar from 0 to 100% with the current step ("Reading and understanding", "Making changes", "Running and checking"). With agents running, it counts finished agents instead

🧭 Session pane (docked on the right)

SectionWhat it shows
UsageA ring for how much conversation memory is used. You get a heads-up popup at 85%, before Claude starts compressing older messages. Bars for your 5-hour and 7-day limits with reset times, and tokens used this session and all time
AgentsEvery agent Claude starts, live: its task, type, model, run time, tokens and tool calls. Click "tool calls" to see what each one ran
SetupFold-out lists of your installed plugins and connected claude.ai connectors. Click any name to read what it does
MascotA small Claude that hops when a turn goes well and looks sad when one fails

🚀 Install

You need Claude Code 2.1.275 or later (terminal or the desktop app's Code tab).

In any Claude Code session, type:

/plugin install usage-meter --marketplace Rezwan3App/claude-usage-meter-mod
  1. Answer y when it asks to add the marketplace.
  2. Press Enter to install for your user (all your projects).
  3. That's it. The bar shows up above the chat box, and the Session pane opens on its own.

💡 If the window is narrow when a session starts, the pane opens with your first message instead. Type /usage-meter to open it any time.

Install from a copy on your computer

Clone the repo, then point Claude Code at the folder in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-usage-meter"
  }
}

On Windows use C:\\Users\\you\\claude-usage-meter. Separate several folders with ; on Windows or : on macOS and Linux. Restart Claude Code after saving.

⚙️ Settings

Open /config and find usage-meter.

SettingDefaultWhat it does
Block em dashesOffWhen on, Claude can't save an em dash into a .md, .txt, .html or .csv file (or any file name). It's told to rewrite with a comma, period, colon or parentheses instead. Handy if you keep a no-em-dash writing style

🔒 Privacy

Everything stays on your computer. The mod reads:

  • your session's own usage numbers from Claude Code
  • ~/.claude/plugins/installed_plugins.json and each plugin's manifest, for the plugin list
  • ~/.claude.json, only for the names of claude.ai connectors you've connected

Token totals are saved in Claude Code's plugin storage so the "all time" number survives restarts. Nothing is sent anywhere.

🧩 Good to know

  • Agents dropdown: picking 2 or more adds one line to each message asking Claude to split the work across that many agents. It's a request, not a hard limit; small tasks may still use fewer.
  • Progress percent: the steps are based on what kind of tool Claude is using, not true progress. The bar only moves forward and ends at 100% when the turn finishes.
  • Connectors list: it shows connectors your claude.ai account has connected. One you removed recently may still be listed.
  • Folder-search agents: an Explore agent started without a model runs on Haiku to save your limits.

🛠️ Troubleshooting

ProblemFix
Nothing shows upRun claude --version and update if it's older than 2.1.275. Then start a new session
Bar shows but no paneThe window was too narrow at start. Send a message or type /usage-meter
Limits show "no reading"Your Claude Code build isn't passing limit data to mods yet. The rest of the pane still works
A dim line in the chat names usage-meterThat's the error. Open an issue and paste the line

🗑️ Uninstall

/plugin uninstall usage-meter

If you installed from a folder, remove its path from CLAUDE_CODE_PLUGIN_DIRS.

👩‍💻 Develop

claude plugin validate .
claude plugin test .

hooks/register.tsx holds the whole mod. Tests live next to it as *.test.ts. Loading the folder through CLAUDE_CODE_PLUGIN_DIRS with CLAUDE_CODE_PLUGIN_DIR_WATCH=1 reloads it each time you save.

📄 License

MIT. Made by Rezwan Islam.

Source 2 files
hooks/register.tsx 630 lines
1import { update } from 'claude-code'
2import type { AgentInfo, Register } from 'claude-code'
3
4import type { Agent, Call, Mood, PluginRow, Reading, Rec, Totals } from '../types'
5
6const PANE = 'usage-meter'
7const ZERO: Rec = { tokIn: 0, tokOut: 0 }
8const TOTALS = { plugin: 'usage-meter', key: 'totals' } as const
9const READING = { plugin: 'usage-meter', key: 'reading' } as const
10const MOOD = { plugin: 'usage-meter', key: 'mood' } as const
11const AGENTS = { plugin: 'usage-meter', key: 'agents' } as const
12const NOW = { plugin: 'usage-meter', key: 'now' } as const
13const PCT = { plugin: 'usage-meter', key: 'pct' } as const
14const STEP = { plugin: 'usage-meter', key: 'step' } as const
15const EFFORT = { plugin: 'usage-meter', key: 'effort' } as const
16const PLUGINS = { plugin: 'usage-meter', key: 'plugins' } as const
17const CONNECTORS = { plugin: 'usage-meter', key: 'connectors' } as const
18// One-liners for claude.ai connectors; anything not listed shows a generic line.
19const CONNECTOR_NOTES: Record<string, string> = {
20  'Gmail': 'Read, search and draft email.',
21  'Google Calendar': 'See and schedule events.',
22  'Google Drive': 'Find and read files in Drive.',
23  'Google Docs': 'Read and edit Google Docs.',
24  'Notion': 'Search and edit Notion pages.',
25  'Slack': 'Read and post in Slack.',
26  'Canva': 'Create and edit Canva designs.',
27  'Airtable': 'Read and update Airtable bases.',
28  'Fireflies': 'Meeting transcripts and notes.',
29  'Miro': 'Read and edit Miro boards.',
30  'Supabase': 'Manage Supabase projects and databases.',
31  'Microsoft 365': 'Outlook, OneDrive and Office files.',
32  'ViewMax': 'Generate images and short videos.',
33  'Claude Docs': 'Docs you create and edit with Claude.',
34  'ZipRecruiter': 'Search job listings.',
35  'Unsplash': 'Find stock photos.',
36  'AccuWeather®': 'Weather forecasts.',
37  'Mobbin': 'Real app screens for design reference.',
38}
39const MODELS = ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1']
40const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
41// ponytail: stage-based percent (by tool kind), not true progress; the turn's real length is unknowable
42const STAGES: Record<string, [number, string]> = {
43  Read: [25, 'Reading and understanding'], Grep: [25, 'Searching the code'], Glob: [25, 'Searching the code'],
44  WebSearch: [25, 'Researching'], WebFetch: [25, 'Researching'],
45  Edit: [60, 'Making changes'], Write: [60, 'Making changes'],
46  Bash: [80, 'Running and checking'], PowerShell: [80, 'Running and checking'],
47}
48const CONTEXT = { plugin: 'usage-meter', key: 'context' } as const
49const HELPERS = { plugin: 'usage-meter', key: 'helpers' } as const
50const OPEN = { plugin: 'usage-meter', key: 'open' } as const
51// Tint behind each pane zone, tuned for the dark theme (mods can't read the app theme).
52const PANEL_BG = '#262626'
53const MAX_CALLS = 40
54// Row id for Claude's own work in the main session.
55const MAIN = 'main'
56
57// The one argument that says what a call did: a file name, a command, a pattern, a URL.
58export const callDetail = (e: Record<string, unknown>): string | undefined => {
59  for (const k of ['file_path', 'notebook_path', 'command', 'pattern', 'url', 'query', 'description', 'path', 'prompt']) {
60    const v = e[k]
61    if (typeof v !== 'string' || !v.trim()) continue
62    const s = k.endsWith('_path') ? (v.split(/[\\/]/).pop() ?? v) : v.replace(/\s+/g, ' ').trim()
63    return s.length > 60 ? `${s.slice(0, 59)}…` : s
64  }
65  return undefined
66}
67// Each agent gets its own color, cycling.
68const HUES = ['claude', 'suggestion', 'success', 'warning', 'merged', 'planMode'] as const
69
70export const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`)
71// A bar as two parts, so the empty track can be drawn quieter than the fill.
72export const barParts = (frac: number, width: number) => {
73  const n = Math.round(Math.max(0, Math.min(1, frac)) * width)
74  return { fill: '█'.repeat(n), track: '─'.repeat(width - n) }
75}
76// A short block sweeping across the track, for work with no known end.
77export const sweep = (tick: number, width: number) => {
78  const run = Math.max(2, Math.floor(width / 5))
79  const at = tick % (width + run) - run
80  return Array.from({ length: width }, (_, i) => (i >= at && i < at + run ? '█' : '░')).join('')
81}
82export const until = (iso: string | undefined, now: number) => {
83  if (!iso) return undefined
84  const mins = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
85  const d = Math.floor(mins / 1440), h = Math.floor((mins % 1440) / 60), m = mins % 60
86  return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`
87}
88// "1:01", "12:05", "1:02:03"
89export const clock = (ms: number) => {
90  const s = Math.max(0, Math.floor(ms / 1000))
91  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = String(s % 60).padStart(2, '0')
92  return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
93}
94const secs = (ms: number) => (ms >= 60000 ? `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s` : `${Math.max(1, Math.round(ms / 1000))}s`)
95export const moodFor = (reason: string, durationMs: number): Mood =>
96  reason === 'answer'
97    ? { face: 'happy', up: false, note: `Nice! Done in ${secs(durationMs)}` }
98    : { face: 'sad', up: false, note: reason === 'aborted' ? 'Interrupted' : reason === 'refusal' ? 'That one got refused' : 'Ran into an error' }
99
100// "claude-opus-5-5[1m]" -> "Opus 5.5 · 1M context"; a name already readable is kept.
101export const modelName = (id: string) => {
102  const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(id)
103  const big = id.includes('[1m]') ? ' · 1M context' : ''
104  if (!m) return id.replace('[1m]', '') + big
105  const [, fam = '', major, minor] = m
106  return `${fam.slice(0, 1).toUpperCase()}${fam.slice(1)} ${major}${minor && minor.length <= 2 ? `.${minor}` : ''}${big}`
107}
108// Last folder in bold, the rest dim, home shown as ~.
109export const splitPath = (cwd: string, home?: string) => {
110  const p = home && cwd.toLowerCase().startsWith(home.toLowerCase()) ? '~' + cwd.slice(home.length) : cwd
111  const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
112  return i < 0 ? { parent: '', name: p } : { parent: p.slice(0, i + 1), name: p.slice(i + 1) || p }
113}
114
115const ART = {
116  happy: [' ▐▛███▜▌ ', '▝▜█████▛▘', '  ▘▘ ▝▝  '],
117  sad: [' ▐▙███▟▌ ', '▗▐█████▌▖', '  ▘▘ ▝▝  '],
118}
119// The small one each agent row carries.
120const MINI = ['▐▛█▜▌', '▝▜█▛▘']
121// SVG twins of HUES, since SVG can't read theme keys.
122const HUE_HEX: Record<(typeof HUES)[number], string> = {
123  claude: '#D97757', suggestion: '#5b8def', success: '#3fb950', warning: '#e5a00d', merged: '#a371f7', planMode: '#3fb8af',
124}
125
126// Pixel crab, after savvy-progress (johnnyvizz/claude-kit, MIT), itself from DockCrab.
127// Desktop draws it as SVG: block glyphs never line up in a proportional font.
128// Kept to the same plain markup as ringSvg (bare <rect>s, 0-origin viewBox, no
129// <g>/class/<style>): the desktop drew nothing for a richer version.
130export const crabSvg = (color: string, o: { sad?: boolean; dim?: boolean; width?: number } = {}) => {
131  const w = o.width ?? 26
132  const op = o.dim ? ' fill-opacity="0.45"' : ''
133  const r = (x: number, y: number, rw: number, rh: number, c = color) => `<rect x="${x}" y="${y}" width="${rw}" height="${rh}" fill="${c}"${op}/>`
134  const eye = o.sad ? 5 : 4
135  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${Math.round((w * 19) / 26)}" viewBox="0 0 26 19">`
136    + r(5, 2, 16, 12) + r(1, 6, 4, 4) + r(21, 6, 4, 4) + r(7, eye, 2, 2, '#1F1E1D') + r(17, eye, 2, 2, '#1F1E1D')
137    + r(5, 14, 2, 4) + r(9, 14, 2, 4) + r(15, 14, 2, 4) + r(19, 14, 2, 4)
138    + '</svg>'
139}
140
141// A progress ring as SVG: dim track, cool-blue arc from 12 o'clock. Hex colors, since SVG can't read theme keys.
142export const ringSvg = (pct: number, size = 36, color?: string) => {
143  const r = size / 2 - 3, c = 2 * Math.PI * r, f = Math.max(0, Math.min(100, pct)) / 100
144  const arc = color ?? (pct >= 90 ? '#e5484d' : pct >= 70 ? '#e5a00d' : '#5b8def')
145  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">`
146    + `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="#8a8f98" stroke-opacity="0.25" stroke-width="4"/>`
147    + `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${arc}" stroke-width="4" stroke-linecap="round"`
148    + ` stroke-dasharray="${(c * f).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${size / 2} ${size / 2})"/></svg>`
149}
150// Where there's no SVG (the terminal): a quarter-step glyph.
151export const ringGlyph = (pct: number) => (pct >= 88 ? '●' : pct >= 63 ? '◕' : pct >= 38 ? '◑' : pct >= 13 ? '◔' : '○')
152
153// Two hops: up, down, up, down.
154function hop($: any, mood: Mood) {
155  void $.state.set(MOOD, { ...mood, up: true })
156  $.clock.after(180, () => void $.state.set(MOOD, { ...mood, up: false }))
157  $.clock.after(360, () => void $.state.set(MOOD, { ...mood, up: true }))
158  $.clock.after(540, () => void $.state.set(MOOD, { ...mood, up: false }))
159}
160
161// update() writes only if nothing changed since the read, else re-reads and
162// retries, so agents started or finishing at the same moment all land.
163async function editAgents($: Parameters<typeof update>[0], fn: (list: Agent[]) => Agent[]) {
164  await update($, AGENTS, list => fn((list ?? []) as Agent[]))
165}
166
167// Backstop for missed events (a hot reload, a cleared list): add any agent the
168// engine knows about but the pane doesn't, and mark ones the engine says ended.
169export async function syncAgents($: any) {
170  const live = (await $.agent.list()) as AgentInfo[]
171  const ended = (s: AgentInfo['status']): Agent['state'] | undefined => (s === 'completed' ? 'done' : s === 'failed' || s === 'killed' ? 'failed' : undefined)
172  const { value: have = [] as Agent[] } = await $.state.get(AGENTS)
173  const stale = live.some(l => {
174    const a = have.find((x: Agent) => x.id === l.id)
175    return !a || (a.state === 'running' && ended(l.status))
176  })
177  if (!stale) return
178  const now = await $.clock.now()
179  await editAgents($, list => {
180    const next: Agent[] = list.map(a => {
181      const l = live.find(x => x.id === a.id)
182      const s = l && a.state === 'running' ? ended(l.status) : undefined
183      return s ? { ...a, state: s, ms: now - a.startedAt } : a
184    })
185    for (const l of live) {
186      if (next.some(a => a.id === l.id)) continue
187      next.push({ id: l.id, title: l.description, type: l.type, model: '', tools: 0, tokens: 0, startedAt: now, ms: 0, state: ended(l.status) ?? 'running' })
188    }
189    return next
190  })
191}
192
193// One store key per session id ("s:<id>"), so a resumed session updates its own row.
194async function sum($: any): Promise<Totals> {
195  const key = `s:${await $.session.id()}`
196  const all = { ...ZERO }
197  let cur = { ...ZERO }
198  let sessions = 0
199  for (const k of await $.store.keys()) {
200    if (!k.startsWith('s:')) continue
201    const r = { ...ZERO, ...((await $.store.get(k)) as Partial<Rec>) }
202    if (k === key) cur = r
203    all.tokIn += r.tokIn; all.tokOut += r.tokOut
204    sessions += 1
205  }
206  return { cur, all, sessions }
207}
208
209async function addTokens($: any, input: number, output: number) {
210  const key = `s:${await $.session.id()}`
211  const rec = { ...ZERO, ...((await $.store.get(key)) as Partial<Rec> | undefined) }
212  await $.store.set(key, { tokIn: rec.tokIn + input, tokOut: rec.tokOut + output })
213}
214
215async function saveLimits($: any, raw: { kind: string; percentUsed: number; resetsAt?: string }[]) {
216  if (raw.length === 0) return
217  const now = await $.clock.now()
218  const r: Reading = {
219    asOf: now,
220    limits: raw
221      .map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsIn: until(l.resetsAt, now) }))
222      .sort((a, b) => (a.kind === 'seven_day' ? -1 : b.kind === 'seven_day' ? 1 : 0)),
223  }
224  await $.state.set(READING, r)
225  await $.store.set('limits', r)
226}
227
228const home = async ($: any) => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
229
230// One toast the first time context crosses 85%, so compaction never surprises.
231async function saveContext($: any, percent: number | undefined) {
232  if (percent === undefined) return
233  const { value: before = 0 } = await $.state.get(CONTEXT)
234  await $.state.set(CONTEXT, percent)
235  if (before < 85 && percent >= 85) $.ui.toast(`Context is ${percent}% full. Claude will compress older messages soon to keep chatting.`)
236}
237
238// Connectors the claude.ai account has connected, as ~/.claude.json records them.
239async function loadConnectors($: any) {
240  let names: string[] = []
241  try { names = JSON.parse(await $.fs.read(`${await home($)}/.claude.json`)).claudeAiMcpEverConnected ?? [] } catch {}
242  const rows = names.map(n => n.replace(/^claude\.ai /, '')).sort((a, b) => a.localeCompare(b))
243    .map(name => ({ name, description: CONNECTOR_NOTES[name] ?? 'Connected through claude.ai.', enabled: true }))
244  await $.state.set(CONNECTORS, rows)
245}
246
247// Prose files only: code can legitimately hold an em dash.
248const PROSE = /\.(md|markdown|txt|html?|csv)$/i
249export const emDashIn = (args: Record<string, unknown>) => {
250  const path = String(args.file_path ?? args.notebook_path ?? '')
251  if (path.includes('—')) return 'the file name'
252  if (!PROSE.test(path)) return undefined
253  const edits = Array.isArray(args.edits) ? (args.edits as Record<string, unknown>[]) : [args]
254  return edits.some(x => String(x.content ?? x.new_string ?? x.new_source ?? '').includes('—')) ? 'the text' : undefined
255}
256
257// Installed plugins from ~/.claude/plugins, each with its manifest's one-liner.
258async function loadPlugins($: any) {
259  const dir = `${await home($)}/.claude`
260  const json = async (path: string) => { try { return JSON.parse(await $.fs.read(path)) } catch { return undefined } }
261  const installed = await json(`${dir}/plugins/installed_plugins.json`)
262  const enabled = (await json(`${dir}/settings.json`))?.enabledPlugins ?? {}
263  const list: PluginRow[] = []
264  for (const [id, copies] of Object.entries(installed?.plugins ?? {}) as [string, { installPath: string }[]][]) {
265    const manifest = await json(`${copies[0]?.installPath}/.claude-plugin/plugin.json`)
266    list.push({ name: id.split('@')[0] ?? id, description: oneLine(manifest?.description ?? ''), enabled: enabled[id] === true })
267  }
268  await $.state.set(PLUGINS, list.sort((a, b) => a.name.localeCompare(b.name)))
269}
270
271export const oneLine = (text: string) => {
272  const first = text.split(/(?<=\.)\s|\n/)[0]?.trim() ?? ''
273  return first.length > 80 ? `${first.slice(0, 79)}…` : first
274}
275
276export const register: Register = (on, options) => {
277  let placed = false
278
279  on('session.start', async ($, e, next) => {
280    await $.command.register({ name: 'usage-meter', description: 'Show the session pane (limits, tokens, agents)' })
281    await $.state.set(TOTALS, await sum($))
282    const saved = (await $.store.get('limits')) as Reading | undefined
283    if (saved) await $.state.set(READING, saved)
284    const usage = await $.session.usage()
285    await saveContext($, usage.context.percent)
286    await saveLimits($, usage.rateLimits)
287    await $.state.set(MOOD, { face: 'idle', up: false, note: 'Ready when you are' })
288    await loadPlugins($)
289    await loadConnectors($)
290    // One tick a second redraws the band and the running agents' sweep.
291    $.clock.every(1000, () => void $.clock.now().then(t => $.state.set(NOW, t)))
292    $.clock.every(3000, () => void syncAgents($).catch(() => {}))
293    void syncAgents($).catch(() => {})
294    // An open the plugin makes on its own is drawn only on a wide enough
295    // window; if it waits, the first prompt below opens it as asked.
296    void $.ui.open({ id: PANE, title: 'Session' }).then(r => { placed = r.isPlaced })
297    return next(e)
298  })
299
300  // An open made while handling a prompt the person entered counts as asked,
301  // so it's drawn at any width. Once placed it's never reopened, so closing
302  // the pane by hand keeps it closed for the session.
303  on('prompt.submit', async ($, e, next) => {
304    await $.state.set(PCT, 5)
305    await $.state.set(STEP, 'Reading your request')
306    if (!e.text.startsWith('/')) {
307      // Claude itself is a row too, kept across requests so its earlier work stays visible.
308      const startedAt = await $.clock.now()
309      const model = modelName(await $.session.model())
310      await editAgents($, list => list.some(a => a.id === MAIN)
311        ? list.map(a => (a.id === MAIN ? { ...a, model, startedAt, state: 'running' as const } : a))
312        : [{ id: MAIN, title: 'Claude', type: 'main session', model, tools: 0, tokens: 0, startedAt, ms: 0, state: 'running' as const }, ...list])
313    }
314    if (!placed) placed = (await $.ui.open({ id: PANE, title: 'Session' })).isPlaced
315    const { value: helpers = 1 } = await $.state.get(HELPERS)
316    if (helpers > 1 && !e.text.startsWith('/')) {
317      return next({ ...e, text: `${e.text}
318
319(Split this task across ${helpers} parallel subagents with the Agent tool, one independent piece each, then combine their results.)` })
320    }
321    return next(e)
322  })
323
324  on('command.run', { command: 'usage-meter' }, async $ => {
325    await $.ui.open({ id: PANE, title: 'Session', focus: true })
326    placed = true
327    return { text: 'Session pane opened.' }
328  })
329
330  on('agent.spawn', async ($, e, next) => {
331    // Backstop for ~/.claude/CLAUDE.md model routing: search-only agents with no model named run on Haiku.
332    const r = await next(e.subagentType === 'Explore' && !e.model && !e.isFork ? { ...e, model: 'haiku' } : e)
333    const id = r.agentId
334    if (id) {
335      const startedAt = await $.clock.now()
336      await editAgents($, list => [
337        ...list,
338        { id, title: e.description, type: e.subagentType, model: modelName(r.model), tools: 0, tokens: 0, startedAt, ms: 0, state: 'running' },
339      ])
340    }
341    return r
342  })
343
344  // Opt-in (userConfig blockEmDashes): refuse em dashes in prose files and have Claude rewrite.
345  if (options.blockEmDashes === true) {
346    on('tool.call', { tool: ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
347      const where = emDashIn(e as unknown as Record<string, unknown>)
348      if (where) return { deny: `Em dash found in ${where}. This project doesn't use em dashes. Rewrite with a comma, period, colon or parentheses and try again.` }
349      return next(e)
350    })
351  }
352
353  on('tool.call', async ($, e, next) => {
354    const id = e.agentId
355    const stage = STAGES[e.tool]
356    if (!id && stage) {
357      const { value: p = 0 } = await $.state.get(PCT)
358      await $.state.set(PCT, Math.max(p, stage[0]))
359      await $.state.set(STEP, stage[1])
360    }
361    {
362      const rowId = id ?? MAIN
363      const call: Call = { tool: e.tool, detail: callDetail(e as unknown as Record<string, unknown>) }
364      await editAgents($, list =>
365        list.map(a => (a.id === rowId ? { ...a, tools: a.tools + 1, calls: [...(a.calls ?? []), call].slice(-MAX_CALLS) } : a)),
366      )
367    }
368    return next(e)
369  })
370
371  on('turn.complete', async ($, e, next) => {
372    const r = await next(e)
373    if (!e.agentId) {
374      await $.state.set(PCT, 100)
375      await $.state.set(STEP, 'Done')
376      const end = await $.clock.now()
377      await editAgents($, list => list.map(a => (a.id === MAIN && a.state === 'running' ? { ...a, state: 'done' as const, ms: end - a.startedAt } : a)))
378    }
379    const u = e.usage
380    if (u) {
381      await addTokens($, u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, u.output_tokens)
382      await $.state.set(TOTALS, await sum($))
383    }
384    const usage = await $.session.usage()
385    await saveLimits($, usage.rateLimits)
386    await saveContext($, usage.context.percent)
387    const id = e.agentId
388    if (id) {
389      const used = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
390      const state = e.reason === 'answer' ? 'done' : 'failed'
391      await editAgents($, list => list.map(a => (a.id === id ? { ...a, tokens: a.tokens + used, ms: e.durationMs, state } : a)))
392    } else {
393      // Only the main loop moves the big mascot.
394      const mood = moodFor(e.reason, e.durationMs)
395      if (mood.face === 'happy') hop($, mood)
396      else await $.state.set(MOOD, mood)
397    }
398    return r
399  })
400
401  on('session.measure', async ($, e, next) => {
402    await saveLimits($, e.rateLimits)
403    await saveContext($, e.context.percent)
404    return next(e)
405  })
406
407  on('turn.step', async function* ($, e, next) {
408    if (!e.agentId) await $.state.set(EFFORT, e.effort === undefined ? '' : String(e.effort))
409    return yield* next(e)
410  })
411
412  // The band above the prompt: model, effort, folder; plus progress while working.
413  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
414    if (e.props.hasSurvey) return next(e)
415    const { Box, Text, Select } = $.ui.resolve(e)
416    const { value: agents = [] } = await $.state.get(AGENTS)
417    await $.state.get(NOW) // ticks every second, so the band redraws
418    const { value: effort = '' } = await $.state.get(EFFORT)
419    const model = modelName(await $.session.model())
420    const where = splitPath(await $.session.cwd(), await home($))
421    const done = agents.filter(a => a.state !== 'running').length
422    const busy = e.props.isWorking || done < agents.length
423    const frac = agents.length ? done / agents.length : 0
424    const width = Math.max(8, Math.min(24, e.props.bodyColumns - 40))
425    const { value: pct = 0 } = await $.state.get(PCT)
426    const { value: step = '' } = await $.state.get(STEP)
427    const choose = (command: string) => async (args: string) => {
428      if (command === 'effort') await $.state.set(EFFORT, args)
429      try {
430        const r = await $.command.run({ command, args })
431        $.ui.toast(r.text || `/${command} ${args}`)
432      } catch (err) {
433        $.ui.toast(`Couldn't run /${command} ${args}: ${String(err)}`)
434      }
435    }
436    const modelId = await $.session.model()
437    const models = MODELS.includes(modelId) ? MODELS : [modelId, ...MODELS]
438    const steady = !busy && agents.length === 0
439    const { value: helpers = 1 } = await $.state.get(HELPERS)
440
441    return (
442      <Box flexDirection="column">
443        <Box gap={2}>
444          <Select key="model" label="Model:" value={modelId} options={models.map(id => ({ value: id, label: modelName(id) }))} onSelect={choose('model')} />
445          <Select key="effort" label="Effort:" value={effort || 'default'} options={[...(effort ? [] : [{ value: 'default', label: 'default' }]), ...EFFORTS.map(x => ({ value: x }))]} onSelect={choose('effort')} />
446          <Select key="helpers" label="Agents:" value={String(helpers)} options={[{ value: '1', label: 'just Claude' }, ...['2', '3', '4', '5', '6'].map(value => ({ value }))]} onSelect={v => void $.state.set(HELPERS, Number(v))} />
447          <Text color="suggestion" bold wrap="truncate-start">📁 {where.parent}{where.name}</Text>
448        </Box>
449        {steady ? (
450          <Text dimColor>Idle</Text>
451        ) : (
452          <Box gap={2}>
453            <Text bold color={busy ? 'text' : 'success'}>{busy ? 'Working' : 'Done'}</Text>
454            {(() => { const p = barParts(busy && agents.length === 0 ? pct / 100 : frac, width); return <Text><Text color="success">{p.fill}</Text><Text dimColor>{p.track}</Text></Text> })()}
455            <Text bold>{agents.length > 0 ? `${Math.round(frac * 100)}%` : `${pct}%`}</Text>
456            <Text>{agents.length > 0 ? `${done}/${agents.length} agents` : step}</Text>
457          </Box>
458        )}
459      </Box>
460    )
461  })
462
463  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
464    const els = $.ui.resolve(e)
465    const { Box, Text, Button } = els
466    const Svg = 'Svg' in els ? els.Svg : undefined
467    const { value: open = [] } = await $.state.get(OPEN)
468    const { value: ctx } = await $.state.get(CONTEXT)
469    const { value: mood } = await $.state.get(MOOD)
470    const { value: r } = await $.state.get(READING)
471    const { value: t } = await $.state.get(TOTALS)
472    const { value: agents = [] } = await $.state.get(AGENTS)
473    const { value: now = 0 } = await $.state.get(NOW)
474    const m = mood ?? { face: 'idle', up: false, note: '' }
475    const cols = e.props.bodyColumns
476    const art = ART[m.face === 'sad' ? 'sad' : 'happy']
477    const weekly = r?.limits.find(l => l.kind === 'seven_day')
478    const fiveHour = r?.limits.find(l => l.kind === 'five_hour')
479
480    const flip = (id: string) => () =>
481      void update($, OPEN, ids => (ids ?? []).includes(id) ? (ids ?? []).filter(x => x !== id) : [...(ids ?? []), id])
482    const row = (a: Agent, i: number) => {
483      const hue = HUES[i % HUES.length] ?? 'claude'
484      const running = a.state === 'running'
485      const ms = running ? now - a.startedAt : a.ms
486      const width = Math.max(8, cols - 9)
487      const isOpen = open.includes(a.id)
488      const calls = a.calls ?? []
489      return (
490        <Box key={a.id} gap={1} paddingBottom={1}>
491          {/* Never squeezed: a narrowed column wraps the art's lines and garbles it. */}
492          <Box flexDirection="column" flexShrink={0}>
493            {Svg
494              ? <Svg source={crabSvg(HUE_HEX[hue], { dim: a.state === 'failed' })} alt={`${a.title}, ${a.state}`} width={26} height={19} />
495              : MINI.map(line => <Text color={hue} dimColor={a.state === 'failed'}>{line}</Text>)}
496          </Box>
497          <Box flexDirection="column" flexGrow={1}>
498            <Box justifyContent="space-between">
499              <Text bold wrap="truncate-end">{a.title}</Text>
500              <Text color={a.state === 'done' ? 'success' : a.state === 'failed' ? 'error' : 'warning'}>
501                {a.state === 'done' ? '✓' : a.state === 'failed' ? '✗' : '…'}
502              </Text>
503            </Box>
504            <Text wrap="truncate-end"><Text color={hue}>{a.type}</Text><Text dimColor> {a.model}</Text></Text>
505            <Box justifyContent="space-between">
506              <Button key={`calls-${a.id}`} plain dimColor onPress={flip(a.id)}>
507                {`${isOpen ? '▾' : '▸'} ${a.tools} tool ${a.tools === 1 ? 'call' : 'calls'}`}
508              </Button>
509              <Text dimColor>{a.tokens ? `${fmtTok(a.tokens)} tokens, ` : ''}{clock(ms)}</Text>
510            </Box>
511            {isOpen && (
512              <Box flexDirection="column" paddingLeft={2}>
513                {calls.length === 0
514                  ? <Text dimColor>Not recorded for this agent.</Text>
515                  : calls.map(c => (
516                      <Text wrap="truncate-end"><Text color={hue}>{c.tool}</Text><Text dimColor>{c.detail ? ` ${c.detail}` : ''}</Text></Text>
517                    ))}
518                {calls.length > 0 && a.tools > calls.length && <Text dimColor>Last {calls.length} of {a.tools}</Text>}
519              </Box>
520            )}
521            {/* Desktop draws block glyphs wider than a column, so cut at the edge rather than wrap. */}
522            <Text color={hue} wrap="truncate-end">{running ? sweep(Math.floor(now / 1000), width) : '█'.repeat(width)}</Text>
523          </Box>
524        </Box>
525      )
526    }
527    const { value: plugins = [] } = await $.state.get(PLUGINS)
528    const { value: connectors = [] } = await $.state.get(CONNECTORS)
529    const fill = ctx ?? 0
530    // One scale for every meter, always read as "used": calm teal under 70, amber 70 to 84, red 85 and up.
531    const tone = (pct: number) => (pct >= 85 ? { key: 'error', hex: '#e5484d' } : pct >= 70 ? { key: 'warning', hex: '#e5a00d' } : { key: 'planMode', hex: '#3fb8af' })
532    const ctxTone = tone(fill)
533    const barWidth = Math.max(8, Math.min(20, cols - 30))
534    const limitRow = (key: string, label: string, l: { percentUsed: number; resetsIn?: string } | undefined) => {
535      const p = barParts((l?.percentUsed ?? 0) / 100, barWidth)
536      return (
537      <Box key={key} gap={2}>
538        <Box width={13} flexShrink={0}><Text>{label}</Text></Box>
539        <Text><Text color={l ? tone(l.percentUsed).key : undefined}>{p.fill}</Text><Text dimColor>{p.track}</Text></Text>
540        <Box width={9} flexShrink={0}>{l ? <Text><Text bold>{Math.round(l.percentUsed)}%</Text><Text dimColor> used</Text></Text> : <Text dimColor>—</Text>}</Box>
541        {l?.resetsIn && <Text dimColor wrap="truncate-end">resets in {l.resetsIn}</Text>}
542      </Box>
543      )
544    }
545    const enabled = plugins.filter(pl => pl.enabled)
546    const off = plugins.filter(pl => !pl.enabled)
547    const itemRow = (kind: string) => (pl: PluginRow) => {
548      const id = `${kind}-${pl.name}`
549      const isOpen = open.includes(id)
550      return (
551        <Box key={id} flexDirection="column" paddingLeft={2}>
552          <Button key={id} plain onPress={flip(id)}>{`${isOpen ? '▾' : '▸'} ${pl.name}`}</Button>
553          {isOpen && <Box paddingLeft={2}><Text dimColor wrap="wrap">{pl.description || 'No description given.'}</Text></Box>}
554        </Box>
555      )
556    }
557    const section = (kind: string, title: string, rows: PluginRow[], empty: string, extra?: unknown) => {
558      const id = `sec-${kind}`
559      const isOpen = open.includes(id)
560      return (
561        <Box flexDirection="column">
562          <Button key={id} plain onPress={flip(id)}>{`${isOpen ? '▾' : '▸'} ${title} (${rows.length})`}</Button>
563          {isOpen && rows.length === 0 && <Box paddingLeft={2}><Text dimColor>{empty}</Text></Box>}
564          {isOpen && rows.map(itemRow(kind))}
565          {isOpen && extra}
566        </Box>
567      )
568    }
569
570    const running = agents.filter(a => a.state === 'running')
571    const finished = agents.filter(a => a.state !== 'running')
572    // Each zone sits on its own tinted block, so resources, agents and setup read as separate panels.
573    const zone = { flexDirection: 'column', gap: 1, backgroundColor: PANEL_BG, paddingX: 1, paddingY: 1 } as const
574
575    return (
576      <Box flexDirection="column" gap={2}>
577        <Box {...zone}>
578          <Text bold>Usage</Text>
579        <Box gap={2} alignItems="center">
580          {Svg
581            ? <Svg source={ringSvg(fill, 64, ctxTone.hex)} alt={`${fill}% of conversation memory used`} width={64} height={64} />
582            : <Text color={ctxTone.key}>{ringGlyph(fill)}</Text>}
583          <Box flexDirection="column" flexGrow={1}>
584            <Text bold color={ctxTone.key}>{ctx === undefined ? 'Memory not measured yet' : `${fill}% memory used`}</Text>
585            <Text dimColor wrap="wrap">
586              {fill >= 85
587                ? 'Almost full. Claude will soon compress older messages into a summary so you can keep chatting.'
588                : fill >= 70
589                  ? 'Filling up. When it runs out, Claude compresses older messages and carries on.'
590                  : 'When it runs out, Claude compresses older messages and carries on.'}
591            </Text>
592          </Box>
593        </Box>
594        <Box flexDirection="column">
595          {limitRow('5h', '5-hour limit', fiveHour)}
596          {limitRow('7d', '7-day limit', weekly)}
597        </Box>
598        <Text>
599          <Text bold>{t ? fmtTok(t.cur.tokIn + t.cur.tokOut) : '0'}</Text>
600          <Text dimColor> tokens this session, {t ? fmtTok(t.all.tokIn + t.all.tokOut) : '0'} all time</Text>
601        </Text>
602        </Box>
603        <Box {...zone}>
604          <Text bold>Agents</Text>
605          <Box flexDirection="column">
606          {agents.length === 0 && <Text dimColor>Agents appear here while they work on a task.</Text>}
607          {running.length > 0 && <Text color="warning">Running {running.length}</Text>}
608          {running.map(a => row(a, agents.indexOf(a)))}
609          {finished.length > 0 && <Text dimColor>Finished {finished.length}</Text>}
610          {finished.map(a => row(a, agents.indexOf(a)))}
611          </Box>
612        </Box>
613        <Box {...zone}>
614          <Text bold>Setup</Text>
615        {section('plugin', 'Plugins', enabled, 'No plugins turned on.', off.length > 0 && <Box paddingLeft={2}><Text dimColor wrap="wrap">Installed but off: {off.map(pl => pl.name).join(', ')}</Text></Box>)}
616        {section('connector', 'Connectors', connectors, 'No connectors found on your claude.ai account.')}
617        </Box>
618        <Box flexDirection="column" alignItems="center">
619          {!m.up && <Text> </Text>}
620          {Svg
621            ? <Svg source={crabSvg(HUE_HEX.claude, { sad: m.face === 'sad', dim: m.face === 'sad', width: 48 })} alt={m.face === 'sad' ? 'Claude, sad' : 'Claude'} width={48} height={35} />
622            : art.map(line => <Text color="claude" dimColor={m.face === 'sad'}>{line}</Text>)}
623          {m.up && <Text> </Text>}
624          <Text color={m.face === 'sad' ? 'error' : undefined} dimColor>{m.note}</Text>
625        </Box>
626      </Box>
627    )
628  })
629}
630
types/index.d.ts 43 lines
1export type Rec = { tokIn: number; tokOut: number }
2export type Totals = { cur: Rec; all: Rec; sessions: number }
3export type Limit = { kind: string; percentUsed: number; resetsIn?: string }
4export type Reading = { limits: Limit[]; asOf: number }
5export type Mood = { face: 'idle' | 'happy' | 'sad'; up: boolean; note: string }
6// One tool call an agent made: the tool and its main argument, shortened.
7export type Call = { tool: string; detail?: string }
8export type PluginRow = { name: string; description: string; enabled: boolean }
9export type Agent = {
10  id: string
11  title: string
12  type: string
13  model: string
14  tools: number
15  calls?: Call[]
16  tokens: number
17  startedAt: number
18  ms: number
19  state: 'running' | 'done' | 'failed'
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'usage-meter': {
25      totals: Totals | null
26      reading: Reading | null
27      mood: Mood | null
28      agents: Agent[]
29      now: number
30      pct: number
31      step: string
32      effort: string
33      plugins: PluginRow[]
34      connectors: PluginRow[]
35      // How many parallel agents to ask for on each prompt; 1 means none.
36      helpers: number
37      context: number
38      // Agent ids whose tool-call list is expanded.
39      open: string[]
40    }
41  }
42}
43