From 6258d01b2fed0e1b3b132d5150b62d92744e653b Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 10:18:14 -0700
Subject: [PATCH 01/13] chore: release 0.6.0 -- Copilot provider, All Time,
avg/s, Top Sessions
Release notes cover the two merged PRs:
- #44: GitHub Copilot provider parsing ~/.copilot/session-state/ with
model tracking via session.model_change events. Adds fallback pricing
for six gpt/o3/o4 models. Copilot logs only output tokens, so cost
rows sit below actual API cost; documented in README and CHANGELOG.
- #51: All Time period (key 5, -p all), avg/s column in By Project,
and a new Top Sessions panel showing the five most expensive sessions
across all projects.
README: provider list updated (Copilot added, output-tokens-only caveat
alongside Cursor's Auto-mode estimation note), usage examples include
`-p all` and key `5`, provider filter list includes `copilot`.
CHANGELOG: 0.6.0 entry lists both features with contributor credits,
plus two fixes (longest-key-first model display sort and empty
firstTimestamp placeholder).
114 tests pass. Build succeeds at 132KB.
---
CHANGELOG.md | 25 +++++++++++++++++++++++++
README.md | 25 +++++++++++++++----------
package-lock.json | 4 ++--
package.json | 2 +-
4 files changed, 43 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3024b5d9..34e4b466 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,30 @@
# Changelog
+## 0.6.0 - 2026-04-16
+
+### Added
+- **GitHub Copilot provider.** Parses `~/.copilot/session-state/*/events.jsonl`
+ and tracks model changes via `session.model_change` events. Picks up six new
+ model prices (`gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-5-mini`, `o3`,
+ `o4-mini`). Contributed by @theodorosD. Note: Copilot logs only output
+ tokens, so cost rows will sit below actual API cost.
+- **All Time period (key `5`).** Shows every recorded session since CodeBurn
+ started tracking. Daily Activity expands to every available day instead of
+ the fixed 14- or 31-day window. `codeburn report -p all` also works from
+ the CLI. Contributed by @lfl1337.
+- **avg/s column in By Project.** Average cost per session next to the
+ existing total cost and session count. Surfaces projects where individual
+ sessions are expensive even if the total is modest. Contributed by @lfl1337.
+- **Top Sessions panel.** Highlights the five most expensive sessions across
+ all projects with date, project, cost, and API call count. Helps spot
+ outliers that drag weekly or monthly totals. Contributed by @lfl1337.
+
+### Fixed
+- `modelDisplayName` now matches longest key first so `gpt-4.1-mini` resolves
+ to `GPT-4.1 Mini` instead of `GPT-4.1`.
+- `TopSessions` handles missing `firstTimestamp` gracefully with a
+ `----------` placeholder instead of rendering a stray whitespace row.
+
## 0.5.0 - 2026-04-15
### Added
diff --git a/README.md b/README.md
index 147e36ac..c3c26c89 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, and **Pi** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
+By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
@@ -38,7 +38,7 @@ npx codeburn
### Requirements
- Node.js 20+
-- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, and/or Pi (`~/.pi/agent/sessions/`)
+- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, Pi (`~/.pi/agent/sessions/`), and/or GitHub Copilot (`~/.copilot/session-state/`)
- For Cursor/OpenCode support: `better-sqlite3` is installed automatically as an optional dependency
## Usage
@@ -48,6 +48,7 @@ codeburn # interactive dashboard (default: 7 days)
codeburn today # today's usage
codeburn month # this month's usage
codeburn report -p 30days # rolling 30-day window
+codeburn report -p all # every recorded session
codeburn report --refresh 60 # auto-refresh every 60 seconds
codeburn status # compact one-liner (today + month)
codeburn status --format json
@@ -55,21 +56,22 @@ codeburn export # CSV with today, 7 days, 30 days
codeburn export -f json # JSON export
```
-Arrow keys switch between Today / 7 Days / 30 Days / Month. Press `q` to quit, `1` `2` `3` `4` as shortcuts.
+Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
## Providers
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
```bash
-codeburn report # all providers combined (default)
-codeburn report --provider claude # Claude Code only
+codeburn report # all providers combined (default)
+codeburn report --provider claude # Claude Code only
codeburn report --provider codex # Codex only
-codeburn report --provider cursor # Cursor only
-codeburn report --provider opencode # OpenCode only
-codeburn report --provider pi # Pi only
-codeburn today --provider codex # Codex today
-codeburn export --provider claude # export Claude data only
+codeburn report --provider cursor # Cursor only
+codeburn report --provider opencode # OpenCode only
+codeburn report --provider pi # Pi only
+codeburn report --provider copilot # GitHub Copilot only
+codeburn today --provider codex # Codex today
+codeburn export --provider claude # export Claude data only
```
The `--provider` flag works on all commands: `report`, `today`, `month`, `status`, `export`.
@@ -84,12 +86,15 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported |
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
| Pi | `~/.pi/agent/sessions/` | Supported |
+| GitHub Copilot | `~/.copilot/session-state/` | Supported (output tokens only) |
| Amp | -- | Planned (provider plugin system) |
Codex tool names are normalized to match Claude's conventions (`exec_command` shows as `Bash`, `read_file` as `Read`, etc.) so the activity classifier and tool breakdown work across providers.
Cursor reads token usage from its local SQLite database. Since Cursor's "Auto" mode hides the actual model used, costs are estimated using Sonnet pricing (labeled "Auto (Sonnet est.)" in the dashboard). The Cursor view shows a **Languages** panel (extracted from code blocks) instead of Core Tools/Shell/MCP panels, since Cursor does not log individual tool calls. First run on a large Cursor database may take up to a minute; results are cached and subsequent runs are instant.
+GitHub Copilot only logs output tokens in its session state, so Copilot cost rows sit below actual API cost. The model is tracked via `session.model_change` events; messages before the first model change are skipped to avoid silent misattribution.
+
### Adding a provider
The provider plugin system makes adding a new provider a single file. Each provider implements session discovery, JSONL parsing, tool normalization, and model display names. See `src/providers/codex.ts` for an example.
diff --git a/package-lock.json b/package-lock.json
index 5c9fe7db..282a31db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codeburn",
- "version": "0.5.7",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
- "version": "0.5.7",
+ "version": "0.6.0",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
diff --git a/package.json b/package.json
index 74156f46..2f91219f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codeburn",
- "version": "0.5.7",
+ "version": "0.6.0",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",
From 09139b1d920ba258fc336a6d1588196483e7e7c1 Mon Sep 17 00:00:00 2001
From: Travis Haley
Date: Thu, 16 Apr 2026 09:57:06 -0600
Subject: [PATCH 02/13] feat: add --format json to report, today, and month
commands
Outputs full dashboard data as structured JSON to stdout, including:
overview, daily breakdown, projects, models with token counts,
activities with one-shot rates, core tools, MCP servers, and
shell commands.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
README.md | 39 ++++++++++----
src/cli.ts | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 184 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index c3c26c89..2a4f6e90 100644
--- a/README.md
+++ b/README.md
@@ -44,20 +44,41 @@ npx codeburn
## Usage
```bash
-codeburn # interactive dashboard (default: 7 days)
-codeburn today # today's usage
-codeburn month # this month's usage
-codeburn report -p 30days # rolling 30-day window
-codeburn report -p all # every recorded session
-codeburn report --refresh 60 # auto-refresh every 60 seconds
-codeburn status # compact one-liner (today + month)
+codeburn # interactive dashboard (default: 7 days)
+codeburn today # today's usage
+codeburn month # this month's usage
+codeburn report -p 30days # rolling 30-day window
+codeburn report -p all # every recorded session
+codeburn report --format json # full dashboard data as JSON
+codeburn report --refresh 60 # auto-refresh every 60 seconds
+codeburn status # compact one-liner (today + month)
codeburn status --format json
-codeburn export # CSV with today, 7 days, 30 days
-codeburn export -f json # JSON export
+codeburn export # CSV with today, 7 days, 30 days
+codeburn export -f json # JSON export
```
Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
+### JSON output
+
+`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
+
+```bash
+codeburn report --format json # 7-day JSON report
+codeburn today --format json # today's data as JSON
+codeburn month --format json # this month as JSON
+codeburn report -p 30days --format json # 30-day window
+```
+
+The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects, models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering:
+
+```bash
+codeburn report --format json | jq '.projects'
+codeburn today --format json | jq '.overview.cost'
+```
+
+For the lighter `status --format json` (today + month totals only) or file-based exports (`export -f json`), see above.
+
## Providers
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
diff --git a/src/cli.ts b/src/cli.ts
index 2c5fcd1b..8f7269e0 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,6 +2,7 @@ import { Command } from 'commander'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing } from './models.js'
import { parseAllSessions } from './parser.js'
+import { getCostColumnHeader, convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
@@ -67,14 +68,150 @@ program.hook('preAction', async () => {
await loadCurrency()
})
+function buildJsonReport(projects: ProjectSummary[], period: string) {
+ const sessions = projects.flatMap(p => p.sessions)
+ const { code } = getCurrency()
+
+ const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
+ const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
+ const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
+ const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
+ const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
+ const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
+ const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
+ const allInput = totalInput + totalCacheRead + totalCacheWrite
+ const cacheHitPercent = allInput > 0 ? Math.round((totalCacheRead / allInput) * 1000) / 10 : 0
+
+ // daily
+ const dailyMap: Record = {}
+ for (const sess of sessions) {
+ for (const turn of sess.turns) {
+ if (!turn.timestamp) { continue }
+ const day = turn.timestamp.slice(0, 10)
+ if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
+ for (const call of turn.assistantCalls) {
+ dailyMap[day].cost += call.costUSD
+ dailyMap[day].calls += 1
+ }
+ }
+ }
+ const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
+ date,
+ cost: convertCost(d.cost),
+ calls: d.calls,
+ }))
+
+ // projects
+ const projectList = projects.map(p => ({
+ name: p.project,
+ path: p.projectPath,
+ cost: convertCost(p.totalCostUSD),
+ calls: p.totalApiCalls,
+ sessions: p.sessions.length,
+ }))
+
+ // models
+ const modelMap: Record = {}
+ for (const sess of sessions) {
+ for (const [model, d] of Object.entries(sess.modelBreakdown)) {
+ if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
+ modelMap[model].calls += d.calls
+ modelMap[model].cost += d.costUSD
+ modelMap[model].inputTokens += d.tokens.inputTokens
+ modelMap[model].outputTokens += d.tokens.outputTokens
+ modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
+ modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
+ }
+ }
+ const models = Object.entries(modelMap)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([name, d]) => ({ name, cost: convertCost(d.cost), ...d, cost_usd: undefined }))
+ .map(({ cost_usd: _, ...rest }) => rest)
+
+ // activities
+ const catMap: Record = {}
+ for (const sess of sessions) {
+ for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
+ if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
+ catMap[cat].turns += d.turns
+ catMap[cat].cost += d.costUSD
+ catMap[cat].editTurns += d.editTurns
+ catMap[cat].oneShotTurns += d.oneShotTurns
+ }
+ }
+ const activities = Object.entries(catMap)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([cat, d]) => ({
+ category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
+ cost: convertCost(d.cost),
+ turns: d.turns,
+ editTurns: d.editTurns,
+ oneShotTurns: d.oneShotTurns,
+ oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
+ }))
+
+ // tools
+ const toolMap: Record = {}
+ const mcpMap: Record = {}
+ const bashMap: Record = {}
+ for (const sess of sessions) {
+ for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
+ toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
+ }
+ for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
+ mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
+ }
+ for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
+ bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
+ }
+ }
+
+ const sortedMap = (m: Record) =>
+ Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
+
+ return {
+ generated: new Date().toISOString(),
+ currency: code,
+ period,
+ overview: {
+ cost: convertCost(totalCostUSD),
+ calls: totalCalls,
+ sessions: totalSessions,
+ cacheHitPercent,
+ tokens: {
+ input: totalInput,
+ output: totalOutput,
+ cacheRead: totalCacheRead,
+ cacheWrite: totalCacheWrite,
+ },
+ },
+ daily,
+ projects: projectList,
+ models,
+ activities,
+ tools: sortedMap(toolMap),
+ mcpServers: sortedMap(mcpMap),
+ shellCommands: sortedMap(bashMap),
+ }
+}
+
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
- await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
+ const period = toPeriod(opts.period)
+ if (opts.format === 'json') {
+ await loadPricing()
+ const { range, label } = getDateRange(period)
+ const projects = await parseAllSessions(range, opts.provider)
+ console.log(JSON.stringify(buildJsonReport(projects, label), null, 2))
+ return
+ }
+ await renderDashboard(period, opts.provider, opts.refresh)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@@ -160,8 +297,16 @@ program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
+ if (opts.format === 'json') {
+ await loadPricing()
+ const { range, label } = getDateRange('today')
+ const projects = await parseAllSessions(range, opts.provider)
+ console.log(JSON.stringify(buildJsonReport(projects, label), null, 2))
+ return
+ }
await renderDashboard('today', opts.provider, opts.refresh)
})
@@ -169,8 +314,16 @@ program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
+ if (opts.format === 'json') {
+ await loadPricing()
+ const { range, label } = getDateRange('month')
+ const projects = await parseAllSessions(range, opts.provider)
+ console.log(JSON.stringify(buildJsonReport(projects, label), null, 2))
+ return
+ }
await renderDashboard('month', opts.provider, opts.refresh)
})
From fad21f3097208229b511b7ab13ca6335fe8e2b79 Mon Sep 17 00:00:00 2001
From: Travis Haley
Date: Thu, 16 Apr 2026 13:08:46 -0600
Subject: [PATCH 03/13] fix: destructure cost before spread so convertCost
isn't overwritten in models JSON output
---
src/cli.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/cli.ts b/src/cli.ts
index 8f7269e0..c53a315e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -125,8 +125,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
}
const models = Object.entries(modelMap)
.sort(([, a], [, b]) => b.cost - a.cost)
- .map(([name, d]) => ({ name, cost: convertCost(d.cost), ...d, cost_usd: undefined }))
- .map(({ cost_usd: _, ...rest }) => rest)
+ .map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
// activities
const catMap: Record = {}
From 60712785d75a0f76ec971b80fd94d45a0717077d Mon Sep 17 00:00:00 2001
From: Travis Haley
Date: Thu, 16 Apr 2026 13:10:05 -0600
Subject: [PATCH 04/13] style: remove section-label comments per repo
convention
---
src/cli.ts | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/cli.ts b/src/cli.ts
index c53a315e..77a9a91b 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -82,7 +82,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
const allInput = totalInput + totalCacheRead + totalCacheWrite
const cacheHitPercent = allInput > 0 ? Math.round((totalCacheRead / allInput) * 1000) / 10 : 0
- // daily
const dailyMap: Record = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
@@ -101,7 +100,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
calls: d.calls,
}))
- // projects
const projectList = projects.map(p => ({
name: p.project,
path: p.projectPath,
@@ -110,7 +108,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
sessions: p.sessions.length,
}))
- // models
const modelMap: Record = {}
for (const sess of sessions) {
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
@@ -127,7 +124,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
- // activities
const catMap: Record = {}
for (const sess of sessions) {
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
@@ -149,7 +145,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
}))
- // tools
const toolMap: Record = {}
const mcpMap: Record = {}
const bashMap: Record = {}
From 7a061ea679faeabd696b3555a22bfaeeea3c062d Mon Sep 17 00:00:00 2001
From: Travis Haley
Date: Thu, 16 Apr 2026 13:13:09 -0600
Subject: [PATCH 05/13] feat: add periodKey and topSessions to JSON output
---
src/cli.ts | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/cli.ts b/src/cli.ts
index 77a9a91b..3a65da90 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -68,7 +68,7 @@ program.hook('preAction', async () => {
await loadCurrency()
})
-function buildJsonReport(projects: ProjectSummary[], period: string) {
+function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
@@ -163,10 +163,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
const sortedMap = (m: Record) =>
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
+ const topSessions = projects
+ .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp?.slice(0, 10) ?? null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
+ .sort((a, b) => b.cost - a.cost)
+ .slice(0, 5)
+
return {
generated: new Date().toISOString(),
currency: code,
period,
+ periodKey,
overview: {
cost: convertCost(totalCostUSD),
calls: totalCalls,
@@ -186,6 +192,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string) {
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
+ topSessions,
}
}
@@ -202,7 +209,7 @@ program
await loadPricing()
const { range, label } = getDateRange(period)
const projects = await parseAllSessions(range, opts.provider)
- console.log(JSON.stringify(buildJsonReport(projects, label), null, 2))
+ console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
return
}
await renderDashboard(period, opts.provider, opts.refresh)
@@ -298,7 +305,7 @@ program
await loadPricing()
const { range, label } = getDateRange('today')
const projects = await parseAllSessions(range, opts.provider)
- console.log(JSON.stringify(buildJsonReport(projects, label), null, 2))
+ console.log(JSON.stringify(buildJsonReport(projects, label, 'today'), null, 2))
return
}
await renderDashboard('today', opts.provider, opts.refresh)
@@ -315,7 +322,7 @@ program
await loadPricing()
const { range, label } = getDateRange('month')
const projects = await parseAllSessions(range, opts.provider)
- console.log(JSON.stringify(buildJsonReport(projects, label), null, 2))
+ console.log(JSON.stringify(buildJsonReport(projects, label, 'month'), null, 2))
return
}
await renderDashboard('month', opts.provider, opts.refresh)
From 9b6a9e8fc3b922147c4187c231ab3ef0827471ea Mon Sep 17 00:00:00 2001
From: akki
Date: Thu, 16 Apr 2026 22:33:53 +0300
Subject: [PATCH 06/13] fix: respect SwiftBar settings when installing the menu
bar
---
src/menubar.ts | 62 ++++++++++++++++++++++++++++++++++---------
tests/menubar.test.ts | 57 +++++++++++++++++++++++++++++++++++++++
2 files changed, 106 insertions(+), 13 deletions(-)
create mode 100644 tests/menubar.test.ts
diff --git a/src/menubar.ts b/src/menubar.ts
index 63860003..a5abb5d5 100644
--- a/src/menubar.ts
+++ b/src/menubar.ts
@@ -1,4 +1,4 @@
-import { execSync } from 'child_process'
+import { execFileSync, execSync } from 'child_process'
import { existsSync } from 'fs'
import { chmod, mkdir, unlink, writeFile } from 'fs/promises'
import { homedir, platform } from 'os'
@@ -7,6 +7,8 @@ import { formatCost, formatTokens } from './format.js'
import { getCurrency } from './currency.js'
const PLUGIN_REFRESH = '5m'
+const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
+const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
function getSwiftBarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
@@ -16,6 +18,49 @@ function getXbarPluginDir(): string {
return join(homedir(), 'Library', 'Application Support', 'xbar', 'plugins')
}
+export function parsePluginDirectoryPreference(value: string): string | undefined {
+ const pluginDir = value.trim()
+ if (!pluginDir) return undefined
+ if (pluginDir === '~') return homedir()
+ if (pluginDir.startsWith('~/')) return join(homedir(), pluginDir.slice(2))
+ return pluginDir
+}
+
+function getConfiguredSwiftBarPluginDir(): string | undefined {
+ if (platform() !== 'darwin') return undefined
+
+ try {
+ return parsePluginDirectoryPreference(execFileSync('defaults', [
+ 'read',
+ SWIFTBAR_PREFERENCES_DOMAIN,
+ SWIFTBAR_PLUGIN_DIRECTORY_KEY,
+ ], { encoding: 'utf-8' }))
+ } catch {
+ return undefined
+ }
+}
+
+function getSwiftBarPluginDirs(): string[] {
+ const dirs = [getConfiguredSwiftBarPluginDir(), getSwiftBarPluginDir()]
+ return dirs.filter((dir, index): dir is string => dir !== undefined && dirs.indexOf(dir) === index)
+}
+
+export function chooseMenubarPluginDir(
+ swiftBarPluginDirs: string[],
+ xbarPluginDir: string,
+ pathExists: (path: string) => boolean,
+): { pluginDir: string; appName: string } {
+ const preferredSwiftBarDir = swiftBarPluginDirs[0] ?? getSwiftBarPluginDir()
+
+ for (const pluginDir of swiftBarPluginDirs) {
+ if (pathExists(pluginDir)) return { pluginDir, appName: 'SwiftBar' }
+ }
+
+ if (pathExists(xbarPluginDir)) return { pluginDir: xbarPluginDir, appName: 'xbar' }
+
+ return { pluginDir: preferredSwiftBarDir, appName: 'SwiftBar' }
+}
+
function getCodeburnBin(): string {
try {
return execSync('which codeburn', { encoding: 'utf-8' }).trim()
@@ -225,18 +270,9 @@ export async function installMenubar(): Promise {
const bin = getCodeburnBin()
const pluginContent = generatePlugin(bin)
- let pluginDir: string
- let appName: string
+ const { pluginDir, appName } = chooseMenubarPluginDir(getSwiftBarPluginDirs(), getXbarPluginDir(), existsSync)
- if (existsSync(getSwiftBarPluginDir())) {
- pluginDir = getSwiftBarPluginDir()
- appName = 'SwiftBar'
- } else if (existsSync(getXbarPluginDir())) {
- pluginDir = getXbarPluginDir()
- appName = 'xbar'
- } else {
- pluginDir = getSwiftBarPluginDir()
- appName = 'SwiftBar'
+ if (!existsSync(pluginDir)) {
await mkdir(pluginDir, { recursive: true })
}
@@ -264,7 +300,7 @@ export async function installMenubar(): Promise {
export async function uninstallMenubar(): Promise {
const paths = [
- join(getSwiftBarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
+ ...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
]
diff --git a/tests/menubar.test.ts b/tests/menubar.test.ts
new file mode 100644
index 00000000..93936b84
--- /dev/null
+++ b/tests/menubar.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { join } from 'path'
+import { homedir } from 'os'
+
+import { chooseMenubarPluginDir, parsePluginDirectoryPreference } from '../src/menubar.js'
+
+describe('parsePluginDirectoryPreference', () => {
+ it('trims defaults output and preserves spaces in paths', () => {
+ expect(parsePluginDirectoryPreference('/Users/test/Documents/Tech stuff/swiftbar_plugins\n')).toBe('/Users/test/Documents/Tech stuff/swiftbar_plugins')
+ })
+
+ it('expands tilde paths', () => {
+ expect(parsePluginDirectoryPreference('~/swiftbar_plugins')).toBe(join(homedir(), 'swiftbar_plugins'))
+ })
+
+ it('ignores blank preference values', () => {
+ expect(parsePluginDirectoryPreference(' \n')).toBeUndefined()
+ })
+})
+
+describe('chooseMenubarPluginDir', () => {
+ const configuredSwiftBarDir = '/Users/test/Documents/Tech stuff/swiftbar_plugins'
+ const defaultSwiftBarDir = '/Users/test/Library/Application Support/SwiftBar/plugins'
+ const xbarDir = '/Users/test/Library/Application Support/xbar/plugins'
+
+ it('uses SwiftBar configured plugin directory before the default directory', () => {
+ const existing = new Set([configuredSwiftBarDir, defaultSwiftBarDir])
+ const result = chooseMenubarPluginDir(
+ [configuredSwiftBarDir, defaultSwiftBarDir],
+ xbarDir,
+ path => existing.has(path),
+ )
+
+ expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
+ })
+
+ it('falls back to xbar when no SwiftBar plugin directory exists', () => {
+ const existing = new Set([xbarDir])
+ const result = chooseMenubarPluginDir(
+ [defaultSwiftBarDir],
+ xbarDir,
+ path => existing.has(path),
+ )
+
+ expect(result).toEqual({ pluginDir: xbarDir, appName: 'xbar' })
+ })
+
+ it('creates the preferred SwiftBar directory when no plugin directory exists', () => {
+ const result = chooseMenubarPluginDir(
+ [configuredSwiftBarDir, defaultSwiftBarDir],
+ xbarDir,
+ () => false,
+ )
+
+ expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
+ })
+})
From 634a8f11ab165621fb086f3964893037c88ad6ae Mon Sep 17 00:00:00 2001
From: Travis Haley
Date: Thu, 16 Apr 2026 13:48:58 -0600
Subject: [PATCH 07/13] fix: add claude-opus-4-7 model mapping and pricing
---
src/models.ts | 2 ++
src/providers/claude.ts | 1 +
2 files changed, 3 insertions(+)
diff --git a/src/models.ts b/src/models.ts
index f995912a..1eaaf2ce 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -24,6 +24,7 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const WEB_SEARCH_COST = 0.01
const FALLBACK_PRICING: Record = {
+ 'claude-opus-4-7': { inputCostPerToken: 5e-6, outputCostPerToken: 25e-6, cacheWriteCostPerToken: 6.25e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 6 },
'claude-opus-4-6': { inputCostPerToken: 5e-6, outputCostPerToken: 25e-6, cacheWriteCostPerToken: 6.25e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 6 },
'claude-opus-4-5': { inputCostPerToken: 5e-6, outputCostPerToken: 25e-6, cacheWriteCostPerToken: 6.25e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
'claude-opus-4-1': { inputCostPerToken: 15e-6, outputCostPerToken: 75e-6, cacheWriteCostPerToken: 18.75e-6, cacheReadCostPerToken: 1.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
@@ -175,6 +176,7 @@ export function calculateCost(
export function getShortModelName(model: string): string {
const canonical = getCanonicalName(model)
const shortNames: Record = {
+ 'claude-opus-4-7': 'Opus 4.7',
'claude-opus-4-6': 'Opus 4.6',
'claude-opus-4-5': 'Opus 4.5',
'claude-opus-4-1': 'Opus 4.1',
diff --git a/src/providers/claude.ts b/src/providers/claude.ts
index 79fec19e..cb8caef8 100644
--- a/src/providers/claude.ts
+++ b/src/providers/claude.ts
@@ -5,6 +5,7 @@ import { homedir } from 'os'
import type { Provider, SessionSource, SessionParser } from './types.js'
const shortNames: Record = {
+ 'claude-opus-4-7': 'Opus 4.7',
'claude-opus-4-6': 'Opus 4.6',
'claude-opus-4-5': 'Opus 4.5',
'claude-opus-4-1': 'Opus 4.1',
From 4154413e59fa5c3c05c79a85dce255a1b3a01ab5 Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 14:43:03 -0700
Subject: [PATCH 08/13] chore: DRY json report branches and drop unused import
- extract Period type alias
- hoist repeated json action body into runJsonReport helper
- remove unused getCostColumnHeader import
---
src/cli.ts | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/src/cli.ts b/src/cli.ts
index 3a65da90..b47229f4 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,7 +2,7 @@ import { Command } from 'commander'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing } from './models.js'
import { parseAllSessions } from './parser.js'
-import { getCostColumnHeader, convertCost } from './currency.js'
+import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
@@ -51,7 +51,9 @@ function getDateRange(period: string): { range: DateRange; label: string } {
}
}
-function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
+type Period = 'today' | 'week' | '30days' | 'month' | 'all'
+
+function toPeriod(s: string): Period {
if (s === 'today') return 'today'
if (s === 'month') return 'month'
if (s === '30days') return '30days'
@@ -59,6 +61,13 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
return 'week'
}
+async function runJsonReport(period: Period, provider: string): Promise {
+ await loadPricing()
+ const { range, label } = getDateRange(period)
+ const projects = await parseAllSessions(range, provider)
+ console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
+}
+
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
@@ -206,10 +215,7 @@ program
.action(async (opts) => {
const period = toPeriod(opts.period)
if (opts.format === 'json') {
- await loadPricing()
- const { range, label } = getDateRange(period)
- const projects = await parseAllSessions(range, opts.provider)
- console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
+ await runJsonReport(period, opts.provider)
return
}
await renderDashboard(period, opts.provider, opts.refresh)
@@ -302,10 +308,7 @@ program
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
if (opts.format === 'json') {
- await loadPricing()
- const { range, label } = getDateRange('today')
- const projects = await parseAllSessions(range, opts.provider)
- console.log(JSON.stringify(buildJsonReport(projects, label, 'today'), null, 2))
+ await runJsonReport('today', opts.provider)
return
}
await renderDashboard('today', opts.provider, opts.refresh)
@@ -319,10 +322,7 @@ program
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
if (opts.format === 'json') {
- await loadPricing()
- const { range, label } = getDateRange('month')
- const projects = await parseAllSessions(range, opts.provider)
- console.log(JSON.stringify(buildJsonReport(projects, label, 'month'), null, 2))
+ await runJsonReport('month', opts.provider)
return
}
await renderDashboard('month', opts.provider, opts.refresh)
From 67c504a60ab53dcd436a82c96a9e1d7425468f45 Mon Sep 17 00:00:00 2001
From: Travis Haley
Date: Thu, 16 Apr 2026 09:39:58 -0600
Subject: [PATCH 09/13] feat: add --project and --exclude filters for
project-level filtering
Adds two new repeatable flags to all commands (report, today, month, status, export):
- --project : include only projects matching name (substring, case-insensitive)
- --exclude : exclude projects matching name (substring, case-insensitive)
Both flags can be specified multiple times to match multiple projects.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
README.md | 14 ++++++++++++
src/cli.ts | 55 +++++++++++++++++++++++++++++++----------------
src/dashboard.tsx | 16 ++++++++------
src/parser.ts | 25 +++++++++++++++++++++
4 files changed, 84 insertions(+), 26 deletions(-)
diff --git a/README.md b/README.md
index 2a4f6e90..246aeec7 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,20 @@ codeburn export --provider claude # export Claude data only
The `--provider` flag works on all commands: `report`, `today`, `month`, `status`, `export`.
+### Project filtering
+
+Filter results by project name (case-insensitive substring match). Both flags are repeatable:
+
+```bash
+codeburn report --project myapp # show only projects matching "myapp"
+codeburn report --exclude myapp # show everything except "myapp"
+codeburn report --exclude myapp --exclude tests # exclude multiple projects
+codeburn month --project api --project web # include multiple projects
+codeburn export --project inventory # export only "inventory" project data
+```
+
+The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`.
+
### Supported providers
| Provider | Data location | Status |
diff --git a/src/cli.ts b/src/cli.ts
index b47229f4..79844f50 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,7 +1,7 @@
import { Command } from 'commander'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing } from './models.js'
-import { parseAllSessions } from './parser.js'
+import { parseAllSessions, filterProjectsByName } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
@@ -61,10 +61,15 @@ function toPeriod(s: string): Period {
return 'week'
}
-async function runJsonReport(period: Period, provider: string): Promise {
+function collect(val: string, acc: string[]): string[] {
+ acc.push(val)
+ return acc
+}
+
+async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise {
await loadPricing()
const { range, label } = getDateRange(period)
- const projects = await parseAllSessions(range, provider)
+ const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
}
@@ -211,14 +216,16 @@ program
.option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
const period = toPeriod(opts.period)
if (opts.format === 'json') {
- await runJsonReport(period, opts.provider)
+ await runJsonReport(period, opts.provider, opts.project, opts.exclude)
return
}
- await renderDashboard(period, opts.provider, opts.refresh)
+ await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@@ -265,15 +272,18 @@ program
.description('Compact status output (today + week + month)')
.option('--format ', 'Output format: terminal, menubar, json', 'terminal')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
+ const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar') {
const todayRange = getDateRange('today').range
- const todayData = buildPeriodData('Today', await parseAllSessions(todayRange, pf))
- const weekData = buildPeriodData('7 Days', await parseAllSessions(getDateRange('week').range, pf))
- const thirtyDayData = buildPeriodData('30 Days', await parseAllSessions(getDateRange('30days').range, pf))
- const monthData = buildPeriodData('Month', await parseAllSessions(getDateRange('month').range, pf))
+ const todayData = buildPeriodData('Today', fp(await parseAllSessions(todayRange, pf)))
+ const weekData = buildPeriodData('7 Days', fp(await parseAllSessions(getDateRange('week').range, pf)))
+ const thirtyDayData = buildPeriodData('30 Days', fp(await parseAllSessions(getDateRange('30days').range, pf)))
+ const monthData = buildPeriodData('Month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const todayProviders: ProviderCost[] = []
for (const p of await getAllProviders()) {
const data = await parseAllSessions(todayRange, p.name)
@@ -285,8 +295,8 @@ program
}
if (opts.format === 'json') {
- const todayData = buildPeriodData('today', await parseAllSessions(getDateRange('today').range, pf))
- const monthData = buildPeriodData('month', await parseAllSessions(getDateRange('month').range, pf))
+ const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
+ const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const { code, rate } = getCurrency()
console.log(JSON.stringify({
currency: code,
@@ -296,7 +306,7 @@ program
return
}
- const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
+ const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
console.log(renderStatusBar(monthProjects))
})
@@ -305,13 +315,15 @@ program
.description('Today\'s usage dashboard')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
if (opts.format === 'json') {
- await runJsonReport('today', opts.provider)
+ await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
- await renderDashboard('today', opts.provider, opts.refresh)
+ await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
@@ -319,13 +331,15 @@ program
.description('This month\'s usage dashboard')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
if (opts.format === 'json') {
- await runJsonReport('month', opts.provider)
+ await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
- await renderDashboard('month', opts.provider, opts.refresh)
+ await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
@@ -334,13 +348,16 @@ program
.option('-f, --format ', 'Export format: csv, json', 'csv')
.option('-o, --output ', 'Output file path')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
+ const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
const periods: PeriodExport[] = [
- { label: 'Today', projects: await parseAllSessions(getDateRange('today').range, pf) },
- { label: '7 Days', projects: await parseAllSessions(getDateRange('week').range, pf) },
- { label: '30 Days', projects: await parseAllSessions(getDateRange('30days').range, pf) },
+ { label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
+ { label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
+ { label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]
if (periods.every(p => p.projects.length === 0)) {
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 55c055b3..6af5d3a7 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -4,7 +4,7 @@ import React, { useState, useCallback, useEffect } from 'react'
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens } from './format.js'
-import { parseAllSessions } from './parser.js'
+import { parseAllSessions, filterProjectsByName } from './parser.js'
import { loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
@@ -593,11 +593,13 @@ function DashboardContent({ projects, period, columns, activeProvider }: { proje
)
}
-function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds }: {
+function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds, projectFilter, excludeFilter }: {
initialProjects: ProjectSummary[]
initialPeriod: Period
initialProvider: string
refreshSeconds?: number
+ projectFilter?: string[]
+ excludeFilter?: string[]
}) {
const { exit } = useApp()
const [period, setPeriod] = useState(initialPeriod)
@@ -629,10 +631,10 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const reloadData = useCallback(async (p: Period, prov: string) => {
setLoading(true)
const range = getDateRange(p)
- const data = await parseAllSessions(range, prov)
+ const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
setProjects(data)
setLoading(false)
- }, [])
+ }, [projectFilter, excludeFilter])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
@@ -718,16 +720,16 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
)
}
-export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number): Promise {
+export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[]): Promise {
await loadPricing()
const range = getDateRange(period)
- const projects = await parseAllSessions(range, provider)
+ const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const isTTY = process.stdin.isTTY && process.stdout.isTTY
if (isTTY) {
const { waitUntilExit } = render(
-
+
)
await waitUntilExit()
} else {
diff --git a/src/parser.ts b/src/parser.ts
index 4055feab..429cb45c 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -462,6 +462,31 @@ function cachePut(key: string, data: ProjectSummary[]) {
sessionCache.set(key, { data, ts: now })
}
+export function filterProjectsByName(
+ projects: ProjectSummary[],
+ include?: string[],
+ exclude?: string[],
+): ProjectSummary[] {
+ let result = projects
+ if (include && include.length > 0) {
+ const patterns = include.map(s => s.toLowerCase())
+ result = result.filter(p => {
+ const name = p.project.toLowerCase()
+ const path = p.projectPath.toLowerCase()
+ return patterns.some(pat => name.includes(pat) || path.includes(pat))
+ })
+ }
+ if (exclude && exclude.length > 0) {
+ const patterns = exclude.map(s => s.toLowerCase())
+ result = result.filter(p => {
+ const name = p.project.toLowerCase()
+ const path = p.projectPath.toLowerCase()
+ return !patterns.some(pat => name.includes(pat) || path.includes(pat))
+ })
+ }
+ return result
+}
+
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise {
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)
From d90042ec3cdbfc82c05d9201b0b57f45ee85f4de Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 15:44:39 -0700
Subject: [PATCH 10/13] fix: stop Top Sessions panel from truncating the calls
column
The TopSessions row layout summed to the full panel width without
leaving room for the Box border (round) + paddingX, so Ink truncated
the last 4 characters -- landing exactly on the calls column and
producing rows like "$182.58 ..." with no calls value.
Introduce PANEL_CHROME = 4 and subtract it from the name column so the
row fits inside the panel's inner area.
---
src/dashboard.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 55c055b3..8b413997 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -132,6 +132,8 @@ function HBar({ value, max, width }: { value: number; max: number; width: number
)
}
+const PANEL_CHROME = 4
+
function Panel({ title, color, children, width }: { title: string; color: string; children: React.ReactNode; width: number }) {
return (
@@ -390,7 +392,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
}
const maxCost = top[0].totalCostUSD
- const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1)
+ const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
return (
From 98c1e266d78bfc82deed9d6a4ff1c334a81f0611 Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 15:34:33 -0700
Subject: [PATCH 11/13] test: cover filterProjectsByName include/exclude
semantics
Adds unit tests for the project-filter helper: include OR semantics,
exclude AND-negation, case-insensitive matching against both project name
and projectPath, ordering (exclude applied after include), empty-string
edge case, and input immutability.
---
tests/parser-filter.test.ts | 82 +++++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 tests/parser-filter.test.ts
diff --git a/tests/parser-filter.test.ts b/tests/parser-filter.test.ts
new file mode 100644
index 00000000..4c347220
--- /dev/null
+++ b/tests/parser-filter.test.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect } from 'vitest'
+
+import { filterProjectsByName } from '../src/parser.js'
+import type { ProjectSummary } from '../src/types.js'
+
+function makeProject(project: string, projectPath = project): ProjectSummary {
+ return {
+ project,
+ projectPath,
+ sessions: [],
+ totalCostUSD: 0,
+ totalApiCalls: 0,
+ }
+}
+
+describe('filterProjectsByName', () => {
+ const projects = [
+ makeProject('codeburn', '/Users/alice/codeburn'),
+ makeProject('AgentSeal', '/Users/alice/projects/AgentSeal'),
+ makeProject('dashboard', '/Users/alice/AgentSeal/dashboard'),
+ makeProject('sandbox', '/tmp/sandbox'),
+ ]
+
+ it('returns all projects when no filters given', () => {
+ expect(filterProjectsByName(projects)).toEqual(projects)
+ expect(filterProjectsByName(projects, [], [])).toEqual(projects)
+ expect(filterProjectsByName(projects, undefined, undefined)).toEqual(projects)
+ })
+
+ it('include matches project name (case-insensitive substring)', () => {
+ const result = filterProjectsByName(projects, ['codeburn'])
+ expect(result.map(p => p.project)).toEqual(['codeburn'])
+ })
+
+ it('include is case-insensitive', () => {
+ const result = filterProjectsByName(projects, ['AGENTSEAL'])
+ expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
+ })
+
+ it('include matches substring in path when name does not match', () => {
+ const result = filterProjectsByName(projects, ['alice/projects'])
+ expect(result.map(p => p.project)).toEqual(['AgentSeal'])
+ })
+
+ it('include uses OR semantics across patterns', () => {
+ const result = filterProjectsByName(projects, ['codeburn', 'sandbox'])
+ expect(result.map(p => p.project).sort()).toEqual(['codeburn', 'sandbox'])
+ })
+
+ it('exclude removes matching projects (AND-negation across patterns)', () => {
+ const result = filterProjectsByName(projects, undefined, ['codeburn', 'sandbox'])
+ expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
+ })
+
+ it('exclude matches path substring', () => {
+ const result = filterProjectsByName(projects, undefined, ['/tmp'])
+ expect(result.map(p => p.project)).not.toContain('sandbox')
+ })
+
+ it('exclude is applied after include', () => {
+ const result = filterProjectsByName(projects, ['AgentSeal'], ['dashboard'])
+ expect(result.map(p => p.project)).toEqual(['AgentSeal'])
+ })
+
+ it('returns empty array when no project matches include', () => {
+ expect(filterProjectsByName(projects, ['does-not-exist'])).toEqual([])
+ })
+
+ it('empty-string pattern matches every project', () => {
+ const resultInclude = filterProjectsByName(projects, [''])
+ expect(resultInclude).toHaveLength(projects.length)
+ const resultExclude = filterProjectsByName(projects, undefined, [''])
+ expect(resultExclude).toEqual([])
+ })
+
+ it('does not mutate the input array', () => {
+ const input = [makeProject('a'), makeProject('b')]
+ const snapshot = [...input]
+ filterProjectsByName(input, ['a'], ['b'])
+ expect(input).toEqual(snapshot)
+ })
+})
From d15532af0bd760770745475dcf060746629743dd Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 15:34:38 -0700
Subject: [PATCH 12/13] fix: apply --project/--exclude to menubar per-provider
today totals
The menubar status output computes per-provider today costs by iterating
all providers after the main period blocks. That loop bypassed the
project filter, so --project/--exclude affected the main totals but not
the provider breakdown shown below them.
---
src/cli.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/cli.ts b/src/cli.ts
index 79844f50..3cfe7528 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -286,7 +286,7 @@ program
const monthData = buildPeriodData('Month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const todayProviders: ProviderCost[] = []
for (const p of await getAllProviders()) {
- const data = await parseAllSessions(todayRange, p.name)
+ const data = fp(await parseAllSessions(todayRange, p.name))
const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
if (cost > 0) todayProviders.push({ name: p.displayName, cost })
}
From 3ea2c0b25555062b118d32f0999866cafa38c48b Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 15:55:29 -0700
Subject: [PATCH 13/13] chore: release 0.6.1 -- JSON output, project filters,
claude-opus-4-7, Top Sessions fix
---
CHANGELOG.md | 27 +++++++++++++++++++++++++++
package-lock.json | 4 ++--
package.json | 2 +-
3 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34e4b466..bd0b560e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,32 @@
# Changelog
+## 0.6.1 - 2026-04-16
+
+### Added
+- **JSON output on `report`, `today`, `month`.** `--format json` writes the
+ full dashboard (overview, daily, projects, models, activities, tools, MCP
+ servers, shell commands, top sessions) to stdout. Contributed by @mallek.
+- **Project filters.** `--project ` and `--exclude ` on all
+ commands (`report`, `today`, `month`, `status`, `export`). Case-insensitive
+ substring match against project name and path. Both flags are repeatable.
+ Contributed by @mallek.
+- **claude-opus-4-7 model mapping and pricing.** Displays as `Opus 4.7` with
+ the same Opus pricing as 4.6 and a 6x fast multiplier. Contributed by @mallek.
+- **Unit tests for `filterProjectsByName`** covering include/exclude
+ semantics, case-insensitivity, path matching, and input immutability.
+
+### Fixed
+- **Top Sessions panel truncating the calls column.** Row width filled the
+ full panel width without leaving room for the border and padding, so Ink
+ truncated the last 4 characters -- landing exactly on the calls column and
+ producing rows like `$182.58 ...` with no value.
+- **SwiftBar custom plugin directory** now honoured when installing the
+ menubar widget. Reads the configured path from SwiftBar's defaults before
+ falling back to the standard location. Contributed by @Galeas.
+- **`status --format menubar` per-provider today totals** now respect
+ `--project`/`--exclude`. The main period blocks already did, the provider
+ breakdown loop was the one spot that bypassed the filter.
+
## 0.6.0 - 2026-04-16
### Added
diff --git a/package-lock.json b/package-lock.json
index 282a31db..d40c1298 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codeburn",
- "version": "0.6.0",
+ "version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
- "version": "0.6.0",
+ "version": "0.6.1",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
diff --git a/package.json b/package.json
index 2f91219f..35ad388c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codeburn",
- "version": "0.6.0",
+ "version": "0.6.1",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",