Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
| `codeburn models --format markdown` | Emit a paste-friendly markdown table |
| `codeburn models --task feature` | Filter to feature-development work |
| `codeburn models --provider claude` | Filter to a single provider |
| `codeburn models --unpriced` | List models counted at $0 because pricing is unknown; JSON preserves exact raw IDs for `model-alias` |

Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects.

Expand Down
6 changes: 4 additions & 2 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -652,8 +652,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
)
})}
{unpriced.length > 0 && (
<Text color="yellow" wrap="truncate-end">
{`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`}
<Text color="yellow" wrap={pw <= 44 ? 'wrap' : 'truncate-end'}>
{pw <= 44
? 'codeburn models --unpriced'
: `! ${unpriced.length} unpriced: codeburn models --unpriced`}
</Text>
)}
{anyEstimated && (
Expand Down
40 changes: 32 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isAbsolute } from 'path'
import { Command, Option } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { findUnpricedModels, loadPricing, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
import { allProviderNames, getAllProviders } from './providers/index.js'
import { getProvider } from './providers/index.js'
Expand Down Expand Up @@ -2075,6 +2075,7 @@ program
.option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"')
.option('--top <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
.option('--unpriced', 'Show only models with usage that currently price at $0')
.option('--no-totals', 'Suppress the footer totals row')
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
.action(async (opts) => {
Expand All @@ -2099,27 +2100,50 @@ program
}

const projects = await parseAllSessions(range, opts.provider)
const rows = await aggregateModels(projects, {
const topN = typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined
const minCost = typeof opts.minCost === 'number' && Number.isFinite(opts.minCost)
? opts.minCost
: opts.unpriced ? undefined : 0.01
let rows = await aggregateModels(projects, {
byTask: !!opts.byTask,
byAgent: !!opts.byAgent,
taskFilter: opts.task,
topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
topN: opts.unpriced ? undefined : topN,
minCost,
})
if (opts.unpriced) {
rows = rows
.filter(row => findUnpricedModels([{
model: row.model,
calls: row.calls,
cost: row.costUSD,
tokens: row.totalTokens,
}]).length > 0)
.sort((a, b) => (b.totalTokens - a.totalTokens) || (b.calls - a.calls)
|| (a.provider < b.provider ? -1 : a.provider > b.provider ? 1 : 0)
|| (a.model < b.model ? -1 : a.model > b.model ? 1 : 0))
if (topN !== undefined) rows = rows.slice(0, topN)
}

const fmt = (opts.format ?? 'table').toLowerCase()
if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
process.stdout.write('No model usage found for the selected period.\n')
process.stdout.write(opts.unpriced
? 'No unpriced models found for the selected period.\n'
: 'No model usage found for the selected period.\n')
return
}
const renderRows = opts.unpriced && fmt !== 'json'
? rows.map(row => ({ ...row, modelDisplayName: sanitizeModelForDisplay(row.model) }))
: rows
if (fmt === 'json') {
process.stdout.write(renderJson(rows) + '\n')
} else if (fmt === 'csv') {
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n')
process.stdout.write(renderCsv(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n')
} else if (fmt === 'markdown' || fmt === 'md') {
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
if (opts.unpriced) process.stdout.write('Fix: codeburn model-alias "<model>" <known-model>\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)
Expand Down
7 changes: 6 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,11 @@ function shouldWarnAboutUnknownModel(name: string): boolean {
return true
}

/** Render provider-supplied model IDs without terminal control characters. */
export function sanitizeModelForDisplay(model: string): string {
return model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
}

export function calculateCost(
model: string,
inputTokens: number,
Expand All @@ -812,7 +817,7 @@ export function calculateCost(
// Strip control characters and cap length: model names come from JSONL
// payloads written by external tools, so a hostile or corrupt file
// could embed terminal escape sequences here.
const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
const safeName = sanitizeModelForDisplay(model)
const aliasHint = `Map it with: codeburn model-alias "${safeName}" <known-model>, or track local-model savings with: codeburn model-savings "${safeName}" <baseline-model>`
process.stderr.write(
`codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` +
Expand Down
149 changes: 149 additions & 0 deletions tests/cli-models-unpriced.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { spawnSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { describe, expect, it } from 'vitest'

function runCli(args: string[], home: string, locale?: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
TZ: 'UTC',
...(locale ? { LANG: locale, LC_ALL: locale } : {}),
},
encoding: 'utf-8',
timeout: 30_000,
})
}

function userLine(timestamp: string): string {
return JSON.stringify({
type: 'user', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969',
message: { role: 'user', content: 'inspect pricing coverage' },
})
}

function assistantLine(model: string, timestamp: string, messageId: string, input: number): string {
return JSON.stringify({
type: 'assistant', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969',
message: {
id: messageId, type: 'message', role: 'assistant', model,
content: [{ type: 'text', text: 'done' }],
usage: {
input_tokens: input, output_tokens: 100,
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
},
},
})
}

async function withFixture(lines: string[], run: (home: string) => void): Promise<void> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-'))
try {
const projectDir = join(home, '.claude', 'projects', 'unpriced-969')
await mkdir(projectDir, { recursive: true })
await writeFile(join(projectDir, 'session.jsonl'), `${lines.join('\n')}\n`)
run(home)
} finally {
await rm(home, { recursive: true, force: true })
}
}

const range = ['--from', '2026-05-20', '--to', '2026-05-20', '--provider', 'claude']

describe('codeburn models --unpriced public CLI', () => {
it('filters before --top and returns the largest unpriced raw ID deterministically', async () => {
await withFixture([
userLine('2026-05-20T10:00:00.000Z'),
assistantLine('acme/unknown-small-969', '2026-05-20T10:01:00.000Z', 'small', 1_000),
assistantLine('claude-opus-4-6', '2026-05-20T10:02:00.000Z', 'priced', 20_000),
assistantLine('acme/unknown-large-969', '2026-05-20T10:03:00.000Z', 'large', 9_000),
], home => {
const result = runCli(['models', '--unpriced', '--top', '1', '--format', 'json', ...range], home)
expect(result.status, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual([
expect.objectContaining({ model: 'acme/unknown-large-969', totalTokens: 9_100 }),
])
})
})

it('orders tied unpriced rows identically across host locales', async () => {
await withFixture([
userLine('2026-05-20T10:00:00.000Z'),
assistantLine('acme/z-unknown-969', '2026-05-20T10:01:00.000Z', 'z-model', 1_000),
assistantLine('acme/ä-unknown-969', '2026-05-20T10:02:00.000Z', 'a-umlaut-model', 1_000),
], home => {
const args = ['models', '--unpriced', '--format', 'json', ...range]
const english = runCli(args, home, 'en_US.UTF-8')
const swedish = runCli(args, home, 'sv_SE.UTF-8')
expect(english.status, english.stderr).toBe(0)
expect(swedish.status, swedish.stderr).toBe(0)
const models = (stdout: string) => (JSON.parse(stdout) as Array<{ model: string }>).map(row => row.model)
expect(models(english.stdout)).toEqual(['acme/z-unknown-969', 'acme/ä-unknown-969'])
expect(models(swedish.stdout)).toEqual(models(english.stdout))
})
})

it('honors an explicitly supplied finite --min-cost threshold', async () => {
await withFixture([
userLine('2026-05-20T10:00:00.000Z'),
assistantLine('acme/unknown-zero-969', '2026-05-20T10:01:00.000Z', 'zero', 1_000),
], home => {
const result = runCli(['models', '--unpriced', '--min-cost', '0.01', '--format', 'json', ...range], home)
expect(result.status, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual([])
})
})

it('lists every unpriced model in table output with an actionable hint', async () => {
await withFixture([
userLine('2026-05-20T10:00:00.000Z'),
assistantLine('acme/unknown-alpha-969', '2026-05-20T10:01:00.000Z', 'alpha', 1_000),
assistantLine('acme/unknown-beta-969', '2026-05-20T10:02:00.000Z', 'beta', 2_000),
assistantLine('claude-opus-4-6', '2026-05-20T10:03:00.000Z', 'priced', 3_000),
], home => {
const result = runCli(['models', '--unpriced', ...range], home)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('acme/unknown-alpha-969')
expect(result.stdout).toContain('acme/unknown-beta-969')
expect(result.stdout).not.toContain('claude-opus-4-6')
expect(result.stdout).toContain('codeburn model-alias "<model>" <known-model>')
})
})

it('reports a clean period explicitly', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-empty-'))
try {
const result = runCli(['models', '--unpriced', ...range], home)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toBe('No unpriced models found for the selected period.\n')
} finally {
await rm(home, { recursive: true, force: true })
}
})

it('sanitizes hostile IDs in human formats while JSON stays lossless', async () => {
const hostile = `acme/alpha\u001b]0;forged\u0007click\u001b[31m\nforged-row-${'x'.repeat(300)}`
await withFixture([
userLine('2026-05-20T10:00:00.000Z'),
assistantLine(hostile, '2026-05-20T10:01:00.000Z', 'hostile', 1_000),
], home => {
for (const format of ['table', 'markdown', 'csv']) {
const result = runCli(['models', '--unpriced', '--format', format, ...range], home)
expect(result.status, `${format}: ${result.stderr}`).toBe(0)
expect(result.stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/)
expect(result.stdout).not.toContain('\nforged-row-')
expect(result.stdout).not.toContain('x'.repeat(201))
}
const json = runCli(['models', '--unpriced', '--format', 'json', ...range], home)
expect(json.status, json.stderr).toBe(0)
expect((JSON.parse(json.stdout) as Array<{ model: string }>)[0]?.model).toBe(hostile)
})
}, 15_000)
})
51 changes: 51 additions & 0 deletions tests/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,57 @@ describe('interactive terminal rendering', () => {
expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true })
})

it.each([
{ columns: 42, expected: 'codeburn models --unpriced' },
{ columns: 43, expected: 'codeburn models --unpriced' },
{ columns: 44, expected: 'codeburn models --unpriced' },
{ columns: 80, expected: '! 10 unpriced: codeburn models --unpriced' },
])('shows an actionable unpriced-model command in a real $columns-column Ink frame', async ({ columns, expected }) => {
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
stdin.isTTY = true
stdin.setRawMode = () => stdin
stdin.ref = () => stdin
stdin.unref = () => stdin
stdout.isTTY = true
stdout.columns = columns
stdout.rows = 100
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))

const session = makeSession('unpriced-session', 0)
for (let index = 0; index < 10; index++) {
const model = `vendor-${index}/unknown-model-${index}-969`
session.modelBreakdown[model] = {
calls: 1,
costUSD: 0,
savingsUSD: 0,
tokens: {
inputTokens: 1_000,
outputTokens: 100,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
}
}

const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [makeProject('unpriced-project', [session])],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: columns,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()

const frame = frames.filter(value => value.trim()).at(-1) ?? ''
expect(frame).toContain(expected)
})

it('leaves resize frame synchronization entirely to Ink', () => {
const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8')
expect(source).not.toContain('process.stdout.write(BSU)')
Expand Down
Loading
Loading