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

<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.
Control bar, right above the chat box

Session pane, docked on the right

| Part | What it does |
|---|---|
| Model | Dropdown to switch between Opus, Sonnet, Haiku and Fable without typing /model |
| Effort | Dropdown for low, medium, high, xhigh or max |
| Agents | Ask Claude to split each task across 2 to 6 parallel agents, or leave it on "just Claude" |
| 📁 Folder | The folder this session works in |
| Progress | While 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 |
| Section | What it shows |
|---|---|
| Usage | A 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 |
| Agents | Every agent Claude starts, live: its task, type, model, run time, tokens and tool calls. Click "tool calls" to see what each one ran |
| Setup | Fold-out lists of your installed plugins and connected claude.ai connectors. Click any name to read what it does |
| Mascot | A small Claude that hops when a turn goes well and looks sad when one fails |
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
y when it asks to add the marketplace.💡 If the window is narrow when a session starts, the pane opens with your first message instead. Type
/usage-meterto open it any time.
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.
Open /config and find usage-meter.
| Setting | Default | What it does |
|---|---|---|
| Block em dashes | Off | When 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 |
Everything stays on your computer. The mod reads:
~/.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 connectedToken totals are saved in Claude Code's plugin storage so the "all time" number survives restarts. Nothing is sent anywhere.
Explore agent started without a model runs on Haiku to save your limits.| Problem | Fix |
|---|---|
| Nothing shows up | Run claude --version and update if it's older than 2.1.275. Then start a new session |
| Bar shows but no pane | The 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-meter | That's the error. Open an issue and paste the line |
/plugin uninstall usage-meter
If you installed from a folder, remove its path from CLAUDE_CODE_PLUGIN_DIRS.
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.
MIT. Made by Rezwan Islam.
hooks/register.tsx 630 lines1import { 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}
630types/index.d.ts 43 lines1export 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