SLOPSHOPPER

usage-band

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

newpanebandguardcommandstatus
v0.2.0Apache-2.0updated 2026-10-06ludodelot/claude_code_bi_dev_panel/mod/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
│ ┃ Claude Code usage ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Claude Code · session ⏱ 30m ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ████░░░░░░░░ ⏺ Update(src/auth.ts) │ ┃ 31% ⎿ Added 2 lines, removed 1 line │ ┃ 5h ⏺ Bash(bun test) │ ┃ resets in NaNm ⎿ 3 pass, 1 fail │ ┃ │ ┃ Tokens ↑ 6k sent · ↓ 1k generated · 91k ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ cached │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Power BI projects & GitHub repos │ ┃ › /usage-band │ ┃ ╭────────────────────────────────────────╮ ⎿ usage-band: ⏱ 30m | tok ↑6k ↓1k cache 91k | ctx 49% | 5h 3 │ ┃ │ 📁 acme/app@feat/auth-refresh (2 │ ⎿ usage-band: 📁 acme/app@feat/auth-refresh (2 changed) · /work/app │ ┃ │ changed) │ │ ┃ │ /work/app │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ │ ┃ │ ┃ [ Show / hide band ] │ ┃ │ ⏱ 30m | tok ↑6k ↓1k cache 91k | ctx 49% | 5h 31% ↻NaNm 📁 acme/app@feat/auth-refresh (2 changed) · /work/app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-band: ⏱ 30m | tok ↑6k ↓1k cache 91k | ctx 49% | 5h 31% ↻NaNm

Draws

Band
⏱ 30m | tok ↑6k ↓1k cache 91k | ctx 49% | 5h 31% ↻NaNm 📁 acme/app@feat/auth-refresh (2 changed) · /work/app
Pane · Claude Code usage
Claude Code · session ⏱ 30m ████░░░░░░░░ 31% 5h resets in NaNm Tokens ↑ 6k sent · ↓ 1k generated · 91k cached Power BI projects & GitHub repos ╭────────────────────────────────────────────────────────╮ │ 📁 acme/app@feat/auth-refresh (2 changed) │ │ /work/app │ ╰────────────────────────────────────────────────────────╯ [ Show / hide band ]
README

Claude Code · Business Intelligence Developer

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.

Preview

Install in one minute

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>.

Always on the latest version

PieceHow it stays current
VS Code panelChecks 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 pluginsMarketplaces 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.

Report a problem or an idea

Three ways, all ending as a public GitHub issue that you review before it is sent:

  1. Ask Claude. Say "report this issue" or "I have an idea for the panel". Claude checks for duplicates, collects only versions and the shape of the data (never prompts, code, paths or names), shows you the draft and files it after your yes. It will also mention this option once if it notices the panel misbehaving.
  2. From VS Code. Use the Report an issue or idea link at the bottom of the panel, or the command Claude Code: Report an issue or idea about this panel. It opens a pre-filled draft on GitHub; nothing is sent until you press Submit.
  3. On GitHub. New issue: Something is wrong or Idea or area of opportunity.

Issues are public. Reports redact your home folder, account name, e-mail addresses and tokens, but please also keep client and company names out.

What you get

SectionWhat it shows
Status bar5h 68% · 7d 31% · ctx 47% plus $(organization) N while subagents are running. Turns amber/red near the limits.
Forecast & next stepsWhen 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 nowThe 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 tilesAPI-equivalent value and $/hour, tokens processed (stacked bar), cache hit rate, session time.
Agents & tasksSubagents running right now (live timer, current tool call), finished and idle ones, plus Top token burners ranked across agents and prompts.
Plan limits5-hour, 7-day and context gauges with status badges.
Burn rateUsage over each window vs. an even pace, a projection, and "hits 100% in ~X" warnings. Hover for exact values.
Token analyticsDonut breakdown (new input, cache write, cache read, generated) and automatic insights.
Session trendsContext and cost over the current session.
Projects & changesRepos, 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.

How it works and privacy

  1. mod/usage-band: a Claude Code mod that records limits, context, tokens, cost, repos and edits into ~/.claude/usage-band/state.json.
  2. 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.

Settings and commands

SettingDefaultWhat it does
claudeUsageBar.userNameglobal git user.nameName shown in the dashboard.
claudeUsageBar.pollIntervalMs2000Refresh interval (min 1000).
claudeUsageBar.notificationstrueWarn when a plan limit crosses a threshold.
claudeUsageBar.notifyAt[80, 95]Usage percentages that trigger a warning.
claudeUsageBar.checkForUpdatestrueDaily 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.

Develop and release

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

Notes and limits

  • Token "burned" = new input + cache writes + generated tokens. Cache reads are reported separately because they would dwarf everything else.
  • Cost is the API-equivalent value of your usage, not what a subscription bills you.
  • Subagent status is inferred from transcripts: running means activity in the last 90 s.
  • Power BI detection expects PBIP projects (*.pbip, *.SemanticModel, *.Report).

En español

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.

License

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.

Source 2 files
hooks/register.tsx 506 lines
1import { 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}
506
types/index.d.ts 45 lines
1export 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