Skip to content
Merged
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
18 changes: 15 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
import { allProviderNames, getAllProviders } from './providers/index.js'
import { convertCost, formatCost } from './currency.js'
import { renderStatusBar } from './format.js'
Expand Down Expand Up @@ -2008,10 +2008,13 @@ program
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
.option('--format <format>', 'Output format: table, json', 'table')
.option('--by-pr', 'Group spend by the pull requests each session referenced')
.option('--no-pager', 'Print the complete table directly instead of opening the interactive browser')
.action(async (opts) => {
assertProvider(opts.provider, 'sessions')
assertFormat(opts.format, ['table', 'json'], 'sessions')
const { aggregateSessions, buildPrAttribution, renderJson, renderTable } = await import('./sessions-report.js')
const wantsInteractive = opts.format === 'table' && !opts.byPr && opts.pager !== false && process.stdin.isTTY === true && process.stdout.isTTY === true
if (wantsInteractive) setInteractiveScanUI()
await loadPricing()

let range
Expand Down Expand Up @@ -2076,8 +2079,17 @@ program
return
}
const rows = aggregateSessions(projects)
const output = opts.format === 'json' ? renderJson(rows) : renderTable(rows)
process.stdout.write(output + '\n')
if (opts.format === 'json') {
process.stdout.write(renderJson(rows) + '\n')
return
}

if (wantsInteractive) {
const { runSessionsTui } = await import('./sessions-tui.js')
await runSessionsTui(rows, { period: opts.from || opts.to ? formatDateRangeLabel(opts.from, opts.to) : opts.period, provider: opts.provider })
return
}
process.stdout.write(renderTable(rows) + '\n')
})

program
Expand Down
184 changes: 167 additions & 17 deletions src/sessions-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,173 @@ export function renderJson(rows: SessionRow[]): string {
return JSON.stringify(rows, null, 2)
}

export function renderTable(rows: SessionRow[]): string {
const headers = ['SESSION', 'TITLE', 'PROJECT', 'PROVIDER', 'MODELS', 'COST', 'SAVED', 'CALLS', 'TURNS', 'STARTED']
const values = rows.map(row => [
row.sessionId,
row.title.length > 38 ? row.title.slice(0, 37) + '\u2026' : row.title,
row.project,
row.provider,
row.models.join(', '),
`$${row.cost.toFixed(2)}`,
`$${row.savingsUSD.toFixed(2)}`,
String(row.calls),
String(row.turns),
row.startedAt,
])
const widths = headers.map((header, i) => Math.max(header.length, ...values.map(row => row[i]!.length)))
const format = (row: string[]) => row.map((value, i) => value.padEnd(widths[i]!)).join(' ').trimEnd()
return [format(headers), format(widths.map(width => '-'.repeat(width))), ...values.map(format)].join('\n')
type SessionColumnKey = 'started' | 'session' | 'project' | 'provider' | 'models' | 'cost' | 'saved' | 'calls' | 'turns'
type SessionColumn = {
key: SessionColumnKey
header: string
width: number
minWidth: number
flex?: number
right?: boolean
optional?: boolean
}

export type SessionTableOptions = {
/// Override terminal detection in tests or embedders.
terminalWidth?: number
}

function defaultTerminalWidth(): number {
const cols = process.stdout.columns
if (typeof cols === 'number' && cols > 0) return cols
const env = process.env['COLUMNS'] ? Number.parseInt(process.env['COLUMNS'], 10) : NaN
return Number.isFinite(env) && env > 0 ? env : 120
}

function frameWidth(columns: SessionColumn[]): number {
// Outer borders + one space either side of each cell + inner separators.
return 2 + columns.reduce((sum, col) => sum + col.width + 2, 0) + Math.max(0, columns.length - 1)
}

function fitColumns(columns: SessionColumn[], available: number): SessionColumn[] {
const fitted = columns.map(col => ({ ...col }))

// Remove low-value columns before squeezing names into unreadable slivers.
for (const key of ['turns', 'saved', 'provider', 'calls'] as SessionColumnKey[]) {
if (frameWidth(fitted) <= available) break
const index = fitted.findIndex(col => col.key === key && col.optional)
if (index >= 0) fitted.splice(index, 1)
}

while (frameWidth(fitted) > available) {
const shrinkable = fitted
.filter(col => col.width > col.minWidth)
.sort((a, b) => (b.flex ?? 0) - (a.flex ?? 0) || b.width - a.width)
const col = shrinkable[0]
if (!col) break
col.width--
}
return fitted
}

function truncate(value: string, width: number): string {
if (value.length <= width) return value
if (width <= 1) return value.slice(0, width)
return value.slice(0, width - 1) + '\u2026'
}

export function cleanSessionProjectLabel(raw: string): string {
const normalized = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
if (!normalized) return 'Unknown'

if (normalized.startsWith('/')) {
const parts = normalized.split('/').filter(Boolean)
if (normalized === '/private/tmp' || normalized === '/tmp') return 'Temporary'
return parts.at(-1) || 'Unknown'
}

// Claude stores a cwd such as /Users/alex/Projects/acme/app as the lossy
// directory slug -Users-alex-Projects-acme-app. Strip the private home
// prefix and known workspace container without ever exposing the username.
let label = normalized.replace(/^-?Users-[^-]+-/, '')
label = label.replace(/^(Projects|Developer|Documents|Desktop|Downloads)-/, '')
if (label === 'private-tmp' || label === '-private-tmp') return 'Temporary'

const worktreeMarker = label.match(/(?:--|-\.claude-|-)(?:claude-)?worktrees-/)
if (worktreeMarker?.index !== undefined) {
const base = label.slice(0, worktreeMarker.index)
let branch = label.slice(worktreeMarker.index + worktreeMarker[0].length)
const baseParts = base.split('-').filter(Boolean)
const repo = baseParts.at(-1) || base
if (branch.startsWith('agent-')) branch = `agent ${branch.slice(6, 14)}`
return branch ? `${repo} · ${branch}` : repo
}

// Repeated repository leaves are common after canonical worktree folding
// (Projects/eywa/eywa). Collapse that noisy shape.
const parts = label.split('-').filter(Boolean)
if (parts.length >= 2 && parts.at(-1) === parts.at(-2)) return parts.at(-1)!
return label || 'Unknown'
}

export function shortSessionId(value: string): string {
const id = value.trim()
if (id.startsWith('agent-')) return `Agent ${id.slice(6, 14)}`
if (id.startsWith('rollout-')) {
const tail = id.match(/([0-9a-f]{8})-[0-9a-f-]{27,}$/i)?.[1]
return `Codex ${tail ?? id.slice(-8)}`
}
if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(id)) return `${id.slice(0, 8)}\u2026${id.slice(-4)}`
return id.length > 24 ? `${id.slice(0, 12)}\u2026${id.slice(-6)}` : id || 'Unknown session'
}

export function sessionDisplayName(row: SessionRow): string {
return row.title.trim() || shortSessionId(row.sessionId)
}

export function sessionModelLabel(models: string[]): string {
const visible = models.filter(model => model !== '<synthetic>')
return (visible.length > 0 ? visible : models).map(getShortModelName).join(', ') || 'Unknown'
}

function cellValue(row: SessionRow, key: SessionColumnKey): string {
switch (key) {
case 'started': return row.startedAt ? row.startedAt.replace('T', ' ').slice(0, 16) : '-'
case 'session': return sessionDisplayName(row)
case 'project': return cleanSessionProjectLabel(row.project)
case 'provider': return row.provider
case 'models': return sessionModelLabel(row.models)
case 'cost': return `$${row.cost.toFixed(2)}`
case 'saved': return `$${row.savingsUSD.toFixed(2)}`
case 'calls': return row.calls.toLocaleString('en-US')
case 'turns': return row.turns.toLocaleString('en-US')
}
}

export function renderTable(rows: SessionRow[], opts: SessionTableOptions = {}): string {
const sorted = [...rows].sort((a, b) => b.startedAt.localeCompare(a.startedAt))
const hasSavings = sorted.some(row => row.savingsUSD > 0)
const allColumns: SessionColumn[] = [
{ key: 'started', header: 'Started', width: 16, minWidth: 10 },
{ key: 'session', header: 'Session', width: 30, minWidth: 12, flex: 3 },
{ key: 'project', header: 'Project', width: 24, minWidth: 10, flex: 2 },
{ key: 'provider', header: 'Provider', width: 9, minWidth: 8, optional: true },
{ key: 'models', header: 'Models', width: 22, minWidth: 10, flex: 2 },
{ key: 'cost', header: 'Cost', width: 9, minWidth: 7, right: true },
...(hasSavings ? [{ key: 'saved' as const, header: 'Saved', width: 9, minWidth: 7, right: true, optional: true }] : []),
{ key: 'calls', header: 'Calls', width: 7, minWidth: 5, right: true, optional: true },
{ key: 'turns', header: 'Turns', width: 7, minWidth: 5, right: true, optional: true },
]
const available = Math.max(60, opts.terminalWidth ?? defaultTerminalWidth())
const columns = fitColumns(allColumns, available)
const values = sorted.map(row => columns.map(col => cellValue(row, col.key)))
// Content can use spare room, but never grow beyond the terminal frame.
for (let i = 0; i < columns.length; i++) {
const col = columns[i]!
const longest = Math.max(col.header.length, ...values.map(row => row[i]?.length ?? 0))
let spare = available - frameWidth(columns)
const wanted = Math.max(0, Math.min(longest - col.width, spare))
col.width += wanted
}

const border = (left: string, middle: string, right: string): string =>
left + columns.map(col => '\u2500'.repeat(col.width + 2)).join(middle) + right
const line = (cells: string[]): string => '\u2502' + columns.map((col, i) => {
const value = truncate(cells[i] ?? '', col.width)
const padding = ' '.repeat(Math.max(0, col.width - value.length))
return ` ${col.right ? padding + value : value + padding} `
}).join('\u2502') + '\u2502'

const totalCost = sorted.reduce((sum, row) => sum + row.cost, 0)
const footer = `${sorted.length.toLocaleString('en-US')} sessions \u2022 $${totalCost.toFixed(2)} total \u2022 newest first`
return [
border('\u250c', '\u252c', '\u2510'),
line(columns.map(col => col.header)),
border('\u251c', '\u253c', '\u2524'),
...values.map(line),
border('\u2514', '\u2534', '\u2518'),
footer,
].join('\n')
}

export type PrRow = {
Expand Down
Loading