Merge origin/main into feat/optimize

Resolve conflicts in src/dashboard.tsx: keep optimize view plumbing
(setOptimizeResult, OptimizeResult state, o/b keys) while integrating
project/exclude filters on reloadData and renderDashboard entry.
This commit is contained in:
AgentSeal 2026-04-16 16:08:20 -07:00
commit 1cd96ea19f
12 changed files with 538 additions and 58 deletions

View file

@ -1,5 +1,57 @@
# 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 <name>` and `--exclude <name>` 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
- **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

View file

@ -19,7 +19,7 @@
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
</p>
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,42 +38,79 @@ 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
```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 --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. 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.
### 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.
```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`.
### 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 |
@ -84,12 +121,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.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "codeburn",
"version": "0.5.7",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
"version": "0.5.7",
"version": "0.6.1",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",

View file

@ -1,6 +1,6 @@
{
"name": "codeburn",
"version": "0.5.7",
"version": "0.6.1",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",

View file

@ -1,7 +1,8 @@
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'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
@ -51,7 +52,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 +62,18 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
return 'week'
}
function collect(val: string, acc: string[]): string[] {
acc.push(val)
return acc
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
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')
@ -68,14 +83,150 @@ program.hook('preAction', async () => {
await loadCurrency()
})
function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: 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
const dailyMap: Record<string, { cost: number; calls: number }> = {}
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,
}))
const projectList = projects.map(p => ({
name: p.project,
path: p.projectPath,
cost: convertCost(p.totalCostUSD),
calls: p.totalApiCalls,
sessions: p.sessions.length,
}))
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
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, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
const catMap: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
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,
}))
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
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<string, number>) =>
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,
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),
topSessions,
}
}
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', '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 runJsonReport(period, opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@ -122,18 +273,21 @@ program
.description('Compact status output (today + week + month)')
.option('--format <format>', 'Output format: terminal, menubar, json', 'terminal')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', '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)
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 })
}
@ -142,8 +296,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,
@ -153,7 +307,7 @@ program
return
}
const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
console.log(renderStatusBar(monthProjects))
})
@ -161,18 +315,32 @@ program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard('today', opts.provider, opts.refresh)
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
await renderDashboard('month', opts.provider, opts.refresh)
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
return
}
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
@ -181,13 +349,16 @@ program
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
.option('-o, --output <path>', 'Output file path')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', '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)) {

View file

@ -4,7 +4,7 @@ import React, { useState, useCallback, useEffect, useRef } 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'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
@ -137,6 +137,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 (
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width} overflowX="hidden">
@ -387,7 +389,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 (
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
@ -572,7 +574,14 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
)
}
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds }: { initialProjects: ProjectSummary[]; initialPeriod: Period; initialProvider: string; refreshSeconds?: number }) {
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<Period>(initialPeriod)
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
@ -632,9 +641,11 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const reloadData = useCallback(async (p: Period, prov: string) => {
setLoading(true)
setOptimizeResult(null)
setProjects(await parseAllSessions(getDateRange(p), prov))
const range = getDateRange(p)
const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
setProjects(data)
setLoading(false)
}, [])
}, [projectFilter, excludeFilter])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
@ -708,12 +719,15 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
)
}
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number): Promise<void> {
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[]): Promise<void> {
await loadPricing()
const projects = await parseAllSessions(getDateRange(period), provider)
const range = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const isTTY = process.stdin.isTTY && process.stdout.isTTY
if (isTTY) {
const { waitUntilExit } = render(<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} />)
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
)
await waitUntilExit()
} else {
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })

View file

@ -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<string> {
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<string> {
export async function uninstallMenubar(): Promise<string> {
const paths = [
join(getSwiftBarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
]

View file

@ -24,6 +24,7 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const WEB_SEARCH_COST = 0.01
const FALLBACK_PRICING: Record<string, ModelCosts> = {
'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<string, string> = {
'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',

View file

@ -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<ProjectSummary[]> {
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)

View file

@ -5,6 +5,7 @@ import { homedir } from 'os'
import type { Provider, SessionSource, SessionParser } from './types.js'
const shortNames: Record<string, string> = {
'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',

57
tests/menubar.test.ts Normal file
View file

@ -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' })
})
})

View file

@ -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)
})
})