Band above the prompt and data feed for the Claude Code panel in VS Code: session time, tokens, context %, 5h/7d plan limits.

by DELOT (v)
A live dashboard for Claude Code inside VS Code. It tells you when your plan limits will run out, what Claude is doing and what it costs, and what to do next (compact, switch model, start fresh), with the Git repo and Power BI project each session is working on.

In Claude Code, type these three lines:
/plugin marketplace add ludodelot/claude_code_bi_dev_panel
/plugin install usage-panel-tools@claude-usage-panel
/plugin install usage-band@claude-usage-panel
Then tell Claude: "install the usage panel". It downloads the latest release, checks its checksum and installs the VS Code extension. Reload VS Code and click the Claude item at the right of the status bar.
usage-panel-tools works on any Claude Code version with plugins. It teaches Claude to install/update the panel and to report problems (see below).usage-band feeds the panel with your limits and usage. It needs a Claude Code version that supports mods (if /plugin install succeeds but the band never appears, update Claude Code).Prefer to do it by hand? Download claude-usage-bar-<version>.vsix from Releases and run code --install-extension <file>.
| Piece | How it stays current |
|---|---|
| VS Code panel | Checks GitHub once a day. When a new release exists it offers Update now (checksum verified) or Release notes. Run Claude Code: Check for panel updates any time. The installed version is shown next to the title and in the footer. |
| Claude Code plugins | Marketplaces added from GitHub do not auto-update by default. Turn it on in /plugin → Marketplaces → claude-usage-panel, or run /plugin marketplace update claude-usage-panel and then /reload-plugins. Or just ask Claude to "update the usage panel". |
Turn the daily check off with claudeUsageBar.checkForUpdates.
Three ways, all ending as a public GitHub issue that you review before it is sent:
Issues are public. Reports redact your home folder, account name, e-mail addresses and tokens, but please also keep client and company names out.
| Section | What it shows |
|---|---|
| Status bar | 5h 68% · 7d 31% · ctx 47% plus $(organization) N while subagents are running. Turns amber/red near the limits. |
| Forecast & next steps | When each limit runs out at your pace, how long before the reset, and suggested actions (/compact, /model, /clear…) with a copy button. The 5-hour limit follows your recent pace; the 7-day limit follows your average per day, so nights and idle days do not distort it. |
| In use now | The Git repo, branch, GitHub remote and Power BI project (PBIP, models, reports) each recent Claude Code session is working on, detected from its transcript. |
| KPI tiles | API-equivalent value and $/hour, tokens processed (stacked bar), cache hit rate, session time. |
| Agents & tasks | Subagents running right now (live timer, current tool call), finished and idle ones, plus Top token burners ranked across agents and prompts. |
| Plan limits | 5-hour, 7-day and context gauges with status badges. |
| Burn rate | Usage over each window vs. an even pace, a projection, and "hits 100% in ~X" warnings. Hover for exact values. |
| Token analytics | Donut breakdown (new input, cache write, cache read, generated) and automatic insights. |
| Session trends | Context and cost over the current session. |
| Projects & changes | Repos, branches, uncommitted/ahead/behind counts, Power BI models/reports and a feed of edits. |
A sticky section menu highlights where you are. Light, dark and high-contrast themes follow VS Code, and status colors come from your theme. Animations respect prefers-reduced-motion.
mod/usage-band: a Claude Code mod that records limits, context, tokens, cost, repos and edits into ~/.claude/usage-band/state.json.extension/: the VS Code extension. It reads that file, scans your local session transcripts under ~/.claude/projects/ for subagents and per-task token usage, and renders the panel.Your usage data stays on your machine. The panel runs under a strict Content Security Policy with no external requests from the page. The only network request the extension makes is the daily check of this project's public release list on GitHub (and the download of a release when you choose Update now, verified against SHA256SUMS). No usage data is sent. Disable it with claudeUsageBar.checkForUpdates.
| Setting | Default | What it does |
|---|---|---|
claudeUsageBar.userName | global git user.name | Name shown in the dashboard. |
claudeUsageBar.pollIntervalMs | 2000 | Refresh interval (min 1000). |
claudeUsageBar.notifications | true | Warn when a plan limit crosses a threshold. |
claudeUsageBar.notifyAt | [80, 95] | Usage percentages that trigger a warning. |
claudeUsageBar.checkForUpdates | true | Daily check for a newer panel release. |
Commands: Open usage dashboard, Export usage history (CSV), Check for panel updates, Report an issue or idea about this panel, Open the panel project on GitHub.
npm test # unit tests + coverage (Node 20+, no dependencies)
node tools/preview.js dark # writes tools/preview-dark.html with mock data
Open the generated file in a browser to iterate on the UI without VS Code.
Release: bump version in extension/package.json (and mod/usage-band/.claude-plugin/plugin.json if the mod changed), update CHANGELOG.md, then push a tag vX.Y.Z. The Release workflow runs the tests, builds the .vsix, writes SHA256SUMS and publishes the release that installed panels look for.
extension/
extension.js status bar item, polling, notifications, commands
updater.js daily update check and verified install
update-check.js version compare, release parsing, checksum (pure, tested)
report.js "report an issue" command
diagnostics.js redaction and pre-filled issue links (pure, tested)
logger.js recent log lines for reports
meta.js where the project lives
format.js formatters shared by the host and the webview
history-csv.js usage history to CSV
advisor.js forecast and suggestion rules (pure, tested)
forecast-view.js forecast cards and suggestion list
activity.js reads session + subagent transcripts (incremental, cached)
workspace-detect.js repo and Power BI project detection
panel.js HTML skeleton
analytics.js pace, projections, insights
charts.js SVG charts with hover tooltips, donut
agents-view.js subagents and token-burner ranking
webview.js rendering and live updates
mod/usage-band/ the Claude Code mod (data feed)
plugins/usage-panel-tools/ skills: report-issue, install-panel
.claude-plugin/marketplace.json makes this repository a Claude Code marketplace
test/ node:test suites
*.pbip, *.SemanticModel, *.Report).Panel en vivo para Claude Code dentro de VS Code: avisa cuándo se acaban tus límites (5 h y 7 d, este último según tu promedio por día), muestra qué hace Claude y cuánto cuesta, y sugiere acciones (/compact, /model, /clear). Instalación en Claude Code:
/plugin marketplace add ludodelot/claude_code_bi_dev_panel
/plugin install usage-panel-tools@claude-usage-panel
/plugin install usage-band@claude-usage-panel
y dile a Claude: «instala el panel». Se actualiza solo (revisa GitHub una vez al día y te pregunta). Para reportar un problema o una idea, dile a Claude «reporta este problema», o usa el enlace al pie del panel: siempre ves el borrador antes de enviarlo y no se incluyen prompts, código ni rutas.
Apache-2.0 © DELOT (v). See LICENSE and NOTICE.
You can use, modify and redistribute this freely, including commercially. Keep the license and the NOTICE file with your copies.
hooks/register.tsx 506 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { ChangeEntry, ModelStats, Snapshot, Totals, Workspace } from '../types'
5
6const EMPTY_TOTALS: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
7const EMPTY_SNAPSHOT: Snapshot = { contextPercent: null, limits: [], usd: null }
8const MAX_WORKSPACES = 3
9const MAX_WALK_UP = 8
10const GIT_TIMEOUT_MS = 5_000
11const MAX_TMDL_FILES = 300
12const REFRESH_MS = 60_000
13const MAX_CHANGES = 25
14const DESKTOP_CACHE_MS = 10_000
15const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
16const FILE_PATH_TOOLS = ['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']
17const PANE = 'usage-panel'
18
19const COLOR_OK = '#3fb950'
20const COLOR_WARN = '#d29922'
21const COLOR_HOT = '#f85149'
22const COLOR_TRACK = 'rgba(128,128,128,0.28)'
23const WARN_AT = 60
24const HOT_AT = 85
25const RING_SIZE = 88
26const RING_RADIUS = 34
27const RING_STROKE = 9
28const TERMINAL_BAR_CELLS = 12
29
30const totals = atom({ plugin: 'usage-band', key: 'totals' } as const, EMPTY_TOTALS)
31const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
32const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
33const changes = atom({ plugin: 'usage-band', key: 'changes' } as const, [] as ChangeEntry[])
34const workspaces = atom({ plugin: 'usage-band', key: 'projects' } as const, [] as Workspace[])
35
36const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
37
38const formatTokens = (n: number): string =>
39 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 1_000)}k` : `${n}`
40
41const formatSpan = (ms: number): string => {
42 const minutes = Math.max(0, Math.round(ms / 60_000))
43 const hours = Math.floor(minutes / 60)
44 if (hours >= 48) return `${Math.floor(hours / 24)}d${hours % 24}h`
45 return hours >= 1 ? `${hours}h${String(minutes % 60).padStart(2, '0')}` : `${minutes}m`
46}
47
48const formatReset = (iso: string | undefined, now: number): string =>
49 iso ? ` ↻${formatSpan(Date.parse(iso) - now)}` : ''
50
51const colorFor = (percent: number): string =>
52 percent >= HOT_AT ? COLOR_HOT : percent >= WARN_AT ? COLOR_WARN : COLOR_OK
53
54const lastSeparator = (p: string): number => Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
55const dirOf = (p: string): string => (lastSeparator(p) <= 0 ? p : p.slice(0, lastSeparator(p)))
56const baseName = (p: string): string => p.slice(lastSeparator(p) + 1)
57const normalize = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
58const stripModelSuffix = (name: string): string => name.replace(/\.SemanticModel$/i, '')
59const stripReportSuffix = (name: string): string => name.replace(/\.Report$/i, '')
60const isIgnoredDir = (dir: string): boolean =>
61 /(^|\/)\.claude(\/|$)/.test(normalize(dir)) || /\/appdata\/local\/temp(\/|$)/.test(normalize(dir))
62
63const git = async ($: EngineInterface, dir: string, args: string[]): Promise<string | null> => {
64 const run = await $.process.run(['git', ...args], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)
65 return run && run.exitCode === 0 && run.stdout.trim() ? run.stdout.trim() : null
66}
67
68type PbiFound = { dir: string; pbip: string[]; models: string[]; reports: string[] }
69
70const findPbi = async ($: EngineInterface, start: string, stopAt: string | null): Promise<PbiFound | null> => {
71 let dir = start
72 for (let i = 0; i < MAX_WALK_UP; i++) {
73 const entries = await $.fs.list(dir).catch(() => [])
74 const named = (kind: 'file' | 'dir', suffix: string) =>
75 entries.filter(x => x.kind === kind && x.name.toLowerCase().endsWith(suffix)).map(x => x.name)
76 const found = {
77 dir,
78 pbip: named('file', '.pbip'),
79 models: named('dir', '.semanticmodel'),
80 reports: named('dir', '.report'),
81 }
82 if (found.pbip.length || found.models.length || found.reports.length) return found
83 const parent = dirOf(dir)
84 if (parent === dir || (stopAt !== null && normalize(dir) === normalize(stopAt))) return null
85 dir = parent
86 }
87 return null
88}
89
90const linkedModel = async ($: EngineInterface, dir: string, report: string): Promise<string | null> => {
91 try {
92 const pbir = JSON.parse(await $.fs.read(`${dir}/${report}/definition.pbir`))
93 const path: unknown = pbir?.datasetReference?.byPath?.path
94 return typeof path === 'string' ? baseName(path) : null
95 } catch {
96 return null
97 }
98}
99
100const countModel = async ($: EngineInterface, dir: string, model: string): Promise<ModelStats> => {
101 const tablesDir = `${dir}/${model}/definition/tables`
102 const entries = await $.fs.list(tablesDir).catch(() => [])
103 const files = entries.filter(f => f.kind === 'file' && f.name.endsWith('.tmdl')).slice(0, MAX_TMDL_FILES)
104 const perFile = await Promise.all(
105 files.map(async f => {
106 const text = await $.fs.read(`${tablesDir}/${f.name}`).catch(() => '')
107 return (text.match(/^\s*measure\s/gm) ?? []).length
108 }),
109 )
110 const relationshipsText = await $.fs.read(`${dir}/${model}/definition/relationships.tmdl`).catch(() => '')
111 const relationships = (relationshipsText.match(/^relationship\s/gm) ?? []).length
112 return { name: model, tables: files.length, measures: perFile.reduce((sum, n) => sum + n, 0), relationships }
113}
114
115const countPages = async ($: EngineInterface, dir: string, report: string): Promise<number> => {
116 const entries = await $.fs.list(`${dir}/${report}/definition/pages`).catch(() => [])
117 return entries.filter(e => e.kind === 'dir').length
118}
119
120type GitInfo = {
121 repoRoot: string | null
122 branch: string | null
123 githubSlug: string | null
124 dirty: number | null
125 lastCommit: string | null
126 ahead: number | null
127 behind: number | null
128}
129
130const NO_GIT: GitInfo = { repoRoot: null, branch: null, githubSlug: null, dirty: null, lastCommit: null, ahead: null, behind: null }
131
132const readGit = async ($: EngineInterface, dir: string): Promise<GitInfo> => {
133 const repoRoot = await git($, dir, ['rev-parse', '--show-toplevel'])
134 if (!repoRoot) return NO_GIT
135 const [branch, remote, status, lastCommit, counts] = await Promise.all([
136 git($, dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
137 git($, dir, ['remote', 'get-url', 'origin']),
138 git($, dir, ['status', '--porcelain']),
139 git($, dir, ['log', '-1', '--pretty=%s']),
140 git($, dir, ['rev-list', '--left-right', '--count', '@{u}...HEAD']),
141 ])
142 const [behind, ahead] = counts ? counts.split(/\s+/).map(Number) : [null, null]
143 const slug = remote ? /github\.com[:/]+([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1] : undefined
144 const dirty = status ? status.split('\n').length : 0
145 return { repoRoot, branch, githubSlug: slug ?? null, dirty, lastCommit, ahead: ahead ?? null, behind: behind ?? null }
146}
147
148const describeDir = async ($: EngineInterface, dir: string): Promise<Workspace> => {
149 const info = await readGit($, dir)
150 const pbi = await findPbi($, dir, info.repoRoot)
151 const reports = pbi
152 ? await Promise.all(
153 pbi.reports.map(async name => ({
154 name,
155 model: await linkedModel($, pbi.dir, name),
156 pages: await countPages($, pbi.dir, name),
157 })),
158 )
159 : []
160 const modelStats = pbi ? await Promise.all(pbi.models.map(name => countModel($, pbi.dir, name))) : []
161 return {
162 key: info.repoRoot ?? dir,
163 dir,
164 ...info,
165 pbipDir: pbi?.dir ?? null,
166 pbip: pbi?.pbip ?? [],
167 models: pbi?.models ?? [],
168 modelStats,
169 reports,
170 checkedAt: await $.clock.now(),
171 }
172}
173
174const contains = (root: string, dir: string): boolean =>
175 normalize(dir) === normalize(root) || normalize(dir).startsWith(`${normalize(root)}/`)
176
177const trackDir = async ($: EngineInterface, dir: string): Promise<void> => {
178 if (isIgnoredDir(dir)) return
179 const known = await read($, workspaces)
180 const match = known.find(w => contains(w.pbipDir ?? w.repoRoot ?? w.dir, dir))
181 if (match && (await $.clock.now()) - match.checkedAt < REFRESH_MS) {
182 await update($, workspaces, list => [match, ...list.filter(w => w.key !== match.key)])
183 return
184 }
185 const described = await describeDir($, match ? (match.pbipDir ?? match.dir) : dir)
186 await update($, workspaces, list =>
187 [described, ...list.filter(w => w.key !== described.key)].slice(0, MAX_WORKSPACES),
188 )
189}
190
191const repoLabel = (w: Workspace): string => {
192 if (!w.repoRoot) return 'no repo'
193 const name = w.githubSlug ?? baseName(w.repoRoot)
194 return `${name}${w.branch ? `@${w.branch}` : ''}${w.dirty ? ` (${w.dirty} changed)` : ''}`
195}
196
197const modelPart = (m: ModelStats): string =>
198 `model ${stripModelSuffix(m.name)}${m.tables ? ` (${m.tables} tables, ${m.measures} measures, ${m.relationships} relationships)` : ''}`
199
200const pbiParts = (w: Workspace): string[] =>
201 [
202 w.pbip.length ? `pbip ${w.pbip.join(', ')}` : null,
203 ...w.modelStats.map(modelPart),
204 ...w.reports.map(
205 r =>
206 `report ${stripReportSuffix(r.name)}${r.pages ? ` (${r.pages} pages)` : ''}${r.model ? ` → ${stripModelSuffix(r.model)}` : ''}`,
207 ),
208 ].filter((p): p is string => p !== null)
209
210const workspaceLine = (w: Workspace): string => {
211 const parts = pbiParts(w)
212 return `📁 ${repoLabel(w)} · ${w.pbipDir ?? w.dir}${parts.length ? ` · ${parts.join(' · ')}` : ''}`
213}
214
215type Gauge = { label: string; percent: number | null; note: string }
216
217const readGauges = async ($: EngineInterface): Promise<Gauge[]> => {
218 const [s, now] = await Promise.all([read($, snapshot), $.clock.now()])
219 const limit = (kind: string): Gauge => {
220 const found = s.limits.find(l => l.kind === kind)
221 return {
222 label: LIMIT_LABELS[kind] ?? kind,
223 percent: found ? Math.round(found.percentUsed) : null,
224 note: found?.resetsAt ? `resets in ${formatSpan(Date.parse(found.resetsAt) - now)}` : 'no reading yet',
225 }
226 }
227 return [
228 limit('five_hour'),
229 limit('seven_day'),
230 { label: 'context', percent: s.contextPercent, note: 'model window' },
231 ]
232}
233
234const buildSummary = async ($: EngineInterface): Promise<string> => {
235 const [t, s, usage, now] = await Promise.all([
236 read($, totals),
237 read($, snapshot),
238 $.session.usage(),
239 $.clock.now(),
240 ])
241 const limits = s.limits
242 .map(l => `${LIMIT_LABELS[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}%${formatReset(l.resetsAt, now)}`)
243 .join(' · ')
244 const parts = [
245 `⏱ ${formatSpan(now - usage.startedAt)}`,
246 `tok ↑${formatTokens(t.input + t.cacheWrite)} ↓${formatTokens(t.output)} cache ${formatTokens(t.cacheRead)}`,
247 s.contextPercent === null ? null : `ctx ${s.contextPercent}%`,
248 limits || null,
249 ].filter((p): p is string => p !== null)
250 return parts.join(' | ')
251}
252
253// The VS Code extension draws nothing from mods, so the figures go to a file
254// that the companion extension (claude-usage-bar) watches and displays.
255const writeState = async ($: EngineInterface): Promise<void> => {
256 try {
257 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
258 if (!home) return
259 const [t, s, usage, list, cwd, recent] = await Promise.all([
260 read($, totals),
261 read($, snapshot),
262 $.session.usage(),
263 read($, workspaces),
264 $.session.cwd(),
265 read($, changes),
266 ])
267 const state = { updatedAt: await $.clock.now(), startedAt: usage.startedAt, cwd, totals: t, ...s, workspaces: list, changes: recent, desktopOpen }
268 await $.fs.write(`${home}/.claude/usage-band/state.json`, JSON.stringify(state))
269 } catch {
270 // The file is a convenience for the editor; a failed write must not break the session.
271 }
272}
273
274const ringSvg =(percent: number, color: string): string => {
275 const circumference = 2 * Math.PI * RING_RADIUS
276 const filled = (Math.min(100, Math.max(0, percent)) / 100) * circumference
277 const mid = RING_SIZE / 2
278 const circle = (stroke: string, extra: string) =>
279 `<circle cx="${mid}" cy="${mid}" r="${RING_RADIUS}" fill="none" stroke="${stroke}" stroke-width="${RING_STROKE}"${extra}/>`
280 const arc = ` stroke-linecap="round" stroke-dasharray="${filled.toFixed(1)} ${circumference.toFixed(1)}" transform="rotate(-90 ${mid} ${mid})"`
281 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${RING_SIZE} ${RING_SIZE}" width="${RING_SIZE}" height="${RING_SIZE}">${circle(COLOR_TRACK, '')}${circle(color, arc)}</svg>`
282}
283
284const textBar = (percent: number): string => {
285 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * TERMINAL_BAR_CELLS)
286 return '█'.repeat(filled) + '░'.repeat(TERMINAL_BAR_CELLS - filled)
287}
288
289const drawGauge = (ui: ElementTable, g: Gauge) => {
290 const { Box, Text } = ui
291 const percent = g.percent ?? 0
292 const color = g.percent === null ? COLOR_TRACK : colorFor(percent)
293 const dial =
294 'Svg' in ui ? (
295 <ui.Svg source={ringSvg(percent, color)} alt={`${g.label} ${g.percent ?? 'sin dato'}%`} width={RING_SIZE} height={RING_SIZE} />
296 ) : (
297 <Text color={color}>{textBar(percent)}</Text>
298 )
299 return (
300 <Box key={g.label} flexDirection="column" alignItems="center" paddingX={2}>
301 {dial}
302 <Text bold color={g.percent === null ? undefined : color}>
303 {g.percent === null ? '—' : `${g.percent}%`}
304 </Text>
305 <Text bold>{g.label}</Text>
306 <Text dimColor>{g.note}</Text>
307 </Box>
308 )
309}
310
311const drawWorkspace = (ui: ElementTable, w: Workspace) => {
312 const { Box, Text } = ui
313 const parts = pbiParts(w)
314 return (
315 <Box key={w.key} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginBottom={1}>
316 <Text bold>📁 {repoLabel(w)}</Text>
317 <Text dimColor>{w.pbipDir ?? w.dir}</Text>
318 {parts.map(part => (
319 <Text> {part}</Text>
320 ))}
321 </Box>
322 )
323}
324
325const drawPane = async ($: EngineInterface, ui: ElementTable) => {
326 const { Box, Text, Button } = ui
327 const [gauges, t, usage, now, list] = await Promise.all([
328 readGauges($),
329 read($, totals),
330 $.session.usage(),
331 $.clock.now(),
332 read($, workspaces),
333 ])
334 return (
335 <Box flexDirection="column" padding={1} gap={1}>
336 <Text bold>Claude Code · session ⏱ {formatSpan(now - usage.startedAt)}</Text>
337 <Box flexDirection="row" flexWrap="wrap">
338 {gauges.map(g => drawGauge(ui, g))}
339 </Box>
340 <Text>
341 Tokens ↑ {formatTokens(t.input + t.cacheWrite)} sent · ↓ {formatTokens(t.output)} generated · {formatTokens(t.cacheRead)} cached
342 </Text>
343 <Text bold>Power BI projects & GitHub repos</Text>
344 {list.length === 0 && <Text dimColor>No files touched yet.</Text>}
345 {list.map(w => drawWorkspace(ui, w))}
346 <Button key="toggle" label="Show / hide band" onPress={() => update($, isHidden, hidden => !hidden)} />
347 </Box>
348 )
349}
350
351type EditInput = {
352 tool?: string
353 old_string?: string
354 new_string?: string
355 content?: string
356 edits?: { old_string?: string; new_string?: string }[]
357}
358
359const countMeasures = (text: string | undefined): number => (text?.match(/^\s*measure\s/gm) ?? []).length
360
361const classifyPath = (file: string): { kind: ChangeEntry['kind']; area: string } => {
362 const p = file.replace(/\\/g, '/')
363 const model = /\/([^/]+)\.SemanticModel\/(.+)$/i.exec(p)
364 if (model) {
365 const rest = model[2] ?? ''
366 const table = /^definition\/tables\/(.+)\.tmdl$/i.exec(rest)
367 return { kind: 'model', area: `${model[1]} › ${table ? `table ${table[1]}` : rest}` }
368 }
369 const report = /\/([^/]+)\.Report\/(.+)$/i.exec(p)
370 if (report) {
371 const rest = report[2] ?? ''
372 const page = /pages\/([^/]+)\//i.exec(rest)
373 return { kind: 'report', area: `${report[1]} › ${page ? `page ${page[1]}` : rest}` }
374 }
375 return { kind: 'other', area: baseName(p) }
376}
377
378const measureImpact = (input: EditInput): { added: number; edited: number } => {
379 if (input.tool === 'Write') return { added: countMeasures(input.content), edited: 0 }
380 const edits = input.tool === 'MultiEdit' ? (input.edits ?? []) : [input]
381 return edits.reduce(
382 (sum, edit) => {
383 const before = countMeasures(edit.old_string)
384 const after = countMeasures(edit.new_string)
385 return before === 0
386 ? { added: sum.added + after, edited: sum.edited }
387 : { added: sum.added + Math.max(0, after - before), edited: sum.edited + before }
388 },
389 { added: 0, edited: 0 },
390 )
391}
392
393let desktopOpen = false
394let desktopCheckedAt = 0
395
396const isDesktopOpen = async ($: EngineInterface): Promise<boolean> => {
397 const now = await $.clock.now()
398 if (now - desktopCheckedAt < DESKTOP_CACHE_MS) return desktopOpen
399 const run = await $.process
400 .run(['tasklist', '/FI', 'IMAGENAME eq PBIDesktop.exe', '/NH'], { timeoutMs: GIT_TIMEOUT_MS })
401 .catch(() => undefined)
402 desktopOpen = !!run && /PBIDesktop\.exe/i.test(run.stdout)
403 desktopCheckedAt = now
404 return desktopOpen
405}
406
407const recordChange = async ($: EngineInterface, input: unknown, file: string): Promise<void> => {
408 const edit = input as EditInput
409 if (!edit.tool || !EDIT_TOOLS.includes(edit.tool) || isIgnoredDir(dirOf(file))) return
410 const where = classifyPath(file)
411 const entry: ChangeEntry = {
412 t: await $.clock.now(),
413 file,
414 tool: edit.tool,
415 ...where,
416 ...measureImpact(edit),
417 desktopOpen: where.kind === 'other' ? false : await isDesktopOpen($),
418 }
419 await update($, changes, list => [entry, ...list].slice(0, MAX_CHANGES))
420}
421
422const filePathOf = (input: unknown): string | null => {
423 const e = input as { tool?: string; file_path?: unknown; notebook_path?: unknown }
424 if (!e.tool || !FILE_PATH_TOOLS.includes(e.tool)) return null
425 const path = e.file_path ?? e.notebook_path
426 return typeof path === 'string' && path ? path : null
427}
428
429export const register: Register = on => {
430 on('session.start', async ($, e, next) => {
431 await $.command.register({
432 name: 'usage-band',
433 description: 'Open the usage panel (tokens, plan limits, GitHub repos and Power BI projects); `hide`/`show` toggles the band',
434 })
435 await trackDir($, await $.session.cwd()).catch(() => undefined)
436 await writeState($)
437 return next(e)
438 })
439
440 on('command.run', { command: 'usage-band' }, async ($, e) => {
441 const arg = e.args.trim()
442 if (arg === 'hide' || arg === 'show') {
443 await update($, isHidden, () => arg === 'hide')
444 return { text: arg === 'hide' ? 'Usage band hidden.' : 'Usage band shown.' }
445 }
446 const opened = await $.ui.open({ id: PANE, title: 'Claude Code usage' })
447 const lines = (await read($, workspaces)).map(workspaceLine)
448 const note = opened.isPlaced ? [] : [`(panel unavailable: ${opened.reason})`]
449 return { text: [await buildSummary($), ...lines, ...note].join('\n') }
450 })
451
452 on('session.measure', async ($, e, next) => {
453 const latest: Snapshot = {
454 contextPercent: e.context.percent ?? null,
455 limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
456 usd: e.cost?.usd ?? null,
457 }
458 await update($, snapshot, () => latest)
459 $.ui.status(await buildSummary($))
460 await writeState($)
461 return next(e)
462 })
463
464 on('tool.call', async ($, e, next) => {
465 const path = filePathOf(e)
466 if (path) {
467 await trackDir($, dirOf(path)).catch(() => undefined)
468 await recordChange($, e, path).catch(() => undefined)
469 }
470 await writeState($)
471 return next(e)
472 })
473
474 on('turn.complete', async ($, e, next) => {
475 const u = e.usage
476 if (u) {
477 await update($, totals, t => ({
478 input: t.input + u.input_tokens,
479 output: t.output + u.output_tokens,
480 cacheRead: t.cacheRead + u.cache_read_input_tokens,
481 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
482 }))
483 }
484 $.ui.status(await buildSummary($))
485 await writeState($)
486 return next(e)
487 })
488
489 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, $.ui.resolve(e)))
490
491 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
492 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
493
494 const { Box, Text } = $.ui.resolve(e)
495 const lines = [await buildSummary($), ...(await read($, workspaces)).map(workspaceLine)]
496
497 return (
498 <Box flexDirection="column">
499 {lines.map(line => (
500 <Text dimColor>{line}</Text>
501 ))}
502 </Box>
503 )
504 })
505}
506types/index.d.ts 45 lines1export type Totals = { input: number; output: number; cacheRead: number; cacheWrite: number }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Snapshot = { contextPercent: number | null; limits: Limit[]; usd: number | null }
4export type ModelStats = { name: string; tables: number; measures: number; relationships: number }
5export type ReportInfo = { name: string; model: string | null; pages: number }
6export type Workspace = {
7 key: string
8 dir: string
9 repoRoot: string | null
10 branch: string | null
11 githubSlug: string | null
12 dirty: number | null
13 lastCommit: string | null
14 ahead: number | null
15 behind: number | null
16 pbipDir: string | null
17 pbip: string[]
18 models: string[]
19 modelStats: ModelStats[]
20 reports: ReportInfo[]
21 checkedAt: number
22}
23export type ChangeEntry = {
24 t: number
25 file: string
26 tool: string
27 kind: 'model' | 'report' | 'other'
28 area: string
29 added: number
30 edited: number
31 desktopOpen: boolean
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'usage-band': {
37 totals: Totals
38 snapshot: Snapshot
39 isHidden: boolean
40 projects: Workspace[]
41 changes: ChangeEntry[]
42 }
43 }
44}
45