diff --git a/src/main.ts b/src/main.ts index bebcce28..ae6867cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' @@ -2008,10 +2008,13 @@ program .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') .option('--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 @@ -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 diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 00f120b0..8570d00a 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -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 !== '') + 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 = { diff --git a/src/sessions-tui.tsx b/src/sessions-tui.tsx new file mode 100644 index 00000000..1fccedc5 --- /dev/null +++ b/src/sessions-tui.tsx @@ -0,0 +1,256 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Box, Text, render, useApp, useInput, useWindowSize } from 'ink' + +import { formatTokens } from './format.js' +import { patchStdoutForWindows } from './ink-win.js' +import { + cleanSessionProjectLabel, + sessionDisplayName, + sessionModelLabel, + shortSessionId, + type SessionRow, +} from './sessions-report.js' + +const ORANGE = '#FF8C42' +const MUTED = '#71717A' +const BORDER = '#3F3F46' + +type Column = { + key: 'started' | 'session' | 'project' | 'provider' | 'models' | 'cost' | 'calls' | 'turns' + label: string + width: number + right?: boolean +} + +function truncate(value: string, width: number): string { + if (width <= 0) return '' + if (value.length <= width) return value + return width === 1 ? '…' : `${value.slice(0, width - 1)}…` +} + +function pad(value: string, width: number, right = false): string { + const text = truncate(value, width) + return right ? text.padStart(width) : text.padEnd(width) +} + +function startedLabel(value: string): string { + return value ? value.replace('T', ' ').slice(0, 16) : '—' +} + +function durationLabel(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '<1m' + const minutes = Math.round(ms / 60_000) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + const rest = minutes % 60 + return rest ? `${hours}h ${rest}m` : `${hours}h` +} + +function columnsFor(width: number): Column[] { + const usable = Math.max(58, width - 4) + const fixed: Column[] = [ + { key: 'started', label: 'STARTED', width: 16 }, + { key: 'session', label: 'SESSION', width: 26 }, + { key: 'project', label: 'PROJECT', width: 20 }, + { key: 'provider', label: 'PROVIDER', width: 9 }, + { key: 'models', label: 'MODELS', width: 24 }, + { key: 'cost', label: 'COST', width: 9, right: true }, + { key: 'calls', label: 'CALLS', width: 7, right: true }, + { key: 'turns', label: 'TURNS', width: 7, right: true }, + ] + + const keep = width >= 150 + ? fixed + : width >= 115 + ? fixed.filter(column => column.key !== 'turns' && column.key !== 'calls') + : width >= 88 + ? fixed.filter(column => !['provider', 'calls', 'turns'].includes(column.key)) + : fixed.filter(column => ['started', 'session', 'project', 'cost'].includes(column.key)) + + const gaps = (keep.length - 1) * 2 + const current = keep.reduce((sum, column) => sum + column.width, 0) + gaps + const flexible = keep.filter(column => ['session', 'project', 'models'].includes(column.key)) + let remaining = usable - current + const result = keep.map(column => ({ ...column })) + + while (remaining > 0 && flexible.length > 0) { + for (const column of flexible) { + if (remaining <= 0) break + const target = result.find(candidate => candidate.key === column.key)! + const cap = column.key === 'session' ? 36 : column.key === 'project' ? 30 : 32 + if (target.width < cap) { + target.width++ + remaining-- + } + } + if (flexible.every(column => result.find(candidate => candidate.key === column.key)!.width >= (column.key === 'session' ? 36 : column.key === 'project' ? 30 : 32))) break + } + + while (remaining < 0) { + const target = [...result] + .filter(column => ['session', 'project', 'models'].includes(column.key) && column.width > 12) + .sort((a, b) => b.width - a.width)[0] + if (!target) break + target.width-- + remaining++ + } + return result +} + +function rowValue(row: SessionRow, key: Column['key']): string { + switch (key) { + case 'started': return startedLabel(row.startedAt) + 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 'calls': return row.calls.toLocaleString('en-US') + case 'turns': return row.turns.toLocaleString('en-US') + } +} + +function tableLine(row: SessionRow, columns: Column[]): string { + return columns.map(column => pad(rowValue(row, column.key), column.width, column.right)).join(' ') +} + +function headerLine(columns: Column[]): string { + return columns.map(column => pad(column.label, column.width, column.right)).join(' ') +} + +function searchText(row: SessionRow): string { + return [row.sessionId, row.title, row.project, cleanSessionProjectLabel(row.project), row.provider, ...row.models, sessionModelLabel(row.models)] + .join(' ') + .toLowerCase() +} + +function DetailPanel({ row, width }: { row: SessionRow; width: number }) { + const compact = width < 100 + const tokens = row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens + const context = `${shortSessionId(row.sessionId)} · ${cleanSessionProjectLabel(row.project)} · ${row.provider} · ${sessionModelLabel(row.models)}` + const metrics = `${row.calls.toLocaleString('en-US')} calls ${row.turns.toLocaleString('en-US')} turns ${durationLabel(row.durationMs)}${compact ? '' : ` ${formatTokens(tokens)} tokens ${startedLabel(row.startedAt)}`}` + return ( + + {truncate(sessionDisplayName(row), Math.max(30, width - 8))} + {truncate(context, Math.max(30, width - 8))} + + ${row.cost.toFixed(2)} + {truncate(` cost ${metrics}`, Math.max(24, width - 18))} + + + ) +} + +function SessionsTui({ rows, period, initialProvider }: { rows: SessionRow[]; period: string; initialProvider: string }) { + const { exit } = useApp() + const { columns: terminalWidth, rows: terminalHeight } = useWindowSize() + const width = Math.max(60, terminalWidth) + const [cursor, setCursor] = useState(0) + const [query, setQuery] = useState('') + const [searching, setSearching] = useState(false) + const [provider, setProvider] = useState(initialProvider) + const [showDetails, setShowDetails] = useState(true) + + const providers = useMemo(() => ['all', ...new Set(rows.map(row => row.provider).filter(Boolean))], [rows]) + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase() + return [...rows] + .filter(row => (provider === 'all' || row.provider === provider) && (!needle || searchText(row).includes(needle))) + .sort((a, b) => b.startedAt.localeCompare(a.startedAt)) + }, [rows, provider, query]) + + useEffect(() => { + setCursor(current => Math.min(current, Math.max(0, filtered.length - 1))) + }, [filtered.length]) + + const selected = filtered[cursor] + const detailLines = showDetails && selected ? 5 : 0 + const pageSize = Math.max(4, terminalHeight - 10 - detailLines) + const first = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), filtered.length - pageSize)) + const visible = filtered.slice(first, first + pageSize) + const tableColumns = columnsFor(width) + const totalCost = filtered.reduce((sum, row) => sum + row.cost, 0) + const help = width < 92 + ? '↑↓ move · / search · p provider · enter details · q quit' + : '↑↓/jk move · pgup/pgdn jump · / search · p provider · enter details · q quit' + + useInput((input, key) => { + if (searching) { + if (key.escape) { + setSearching(false) + setQuery('') + } else if (key.return) { + setSearching(false) + } else if (key.backspace || key.delete) { + setQuery(value => value.slice(0, -1)) + } else if (input && !key.ctrl && !key.meta) { + setQuery(value => value + input.replace(/[\r\n]/g, '')) + } + return + } + + if (input === 'q' || key.escape) return exit() + if (input === '/') { + setSearching(true) + return + } + if (key.upArrow || input === 'k') setCursor(value => Math.max(0, value - 1)) + if (key.downArrow || input === 'j') setCursor(value => Math.max(0, Math.min(filtered.length - 1, value + 1))) + if (key.pageUp) setCursor(value => Math.max(0, value - pageSize)) + if (key.pageDown) setCursor(value => Math.max(0, Math.min(filtered.length - 1, value + pageSize))) + if (key.home || input === 'g') setCursor(0) + if (key.end || input === 'G') setCursor(Math.max(0, filtered.length - 1)) + if (key.return || input === ' ') setShowDetails(value => !value) + if (input === 'p' || key.tab) { + setProvider(value => providers[(providers.indexOf(value) + 1) % providers.length] ?? 'all') + setCursor(0) + } + if (input === 'c') { + setQuery('') + setCursor(0) + } + }) + + return ( + + + Sessions {period} + {filtered.length.toLocaleString('en-US')} sessions · ${totalCost.toFixed(2)} total + + + Provider + {provider === 'all' ? 'All providers' : provider} + Search + {searching ? `/${query}▌` : query || 'press /'} + {query && !searching ? · c clear : null} + + + {` ${headerLine(tableColumns)}`} + {'─'.repeat(Math.min(width - 2, headerLine(tableColumns).length + 2))} + {visible.map((row, index) => { + const absoluteIndex = first + index + const active = absoluteIndex === cursor + return ( + + {active ? '› ' : ' '}{tableLine(row, tableColumns)} + + ) + })} + {filtered.length === 0 ? No sessions match this filter. : null} + + + {selected && showDetails ? : null} + + + {help} + {filtered.length ? `${cursor + 1} / ${filtered.length}` : ''} + + + ) +} + +export async function runSessionsTui(rows: SessionRow[], opts: { period: string; provider: string }): Promise { + patchStdoutForWindows() + const instance = render() + await instance.waitUntilExit() +} diff --git a/tests/sessions-report.test.ts b/tests/sessions-report.test.ts index f413cea3..158daa9b 100644 --- a/tests/sessions-report.test.ts +++ b/tests/sessions-report.test.ts @@ -97,9 +97,49 @@ describe('sessions JSON emitter', () => { }) it('renders a simple table', () => { - const output = renderTable(aggregateSessions([makeProject()])) - expect(output).toContain('SESSION') + const output = renderTable(aggregateSessions([makeProject()]), { terminalWidth: 120 }) + expect(output).toContain('Session') expect(output).toContain('session-1') - expect(output).toContain('claude-sonnet-4-5') + expect(output).toContain('Sonnet 4.5') + expect(output).toContain('1 sessions') + }) + + it('hides home-directory slugs and renders compact agent/worktree names', () => { + const rows = aggregateSessions([makeProject()]) + rows[0]!.sessionId = 'agent-a72cc958e305e4957' + rows[0]!.project = '-Users-torukmakto-Projects-eywa-eywa--claude-worktrees-issue-131' + const output = renderTable(rows, { terminalWidth: 160 }) + + expect(output).toContain('Agent a72cc958') + expect(output).toContain('eywa · issue-131') + expect(output).not.toContain('Users-torukmakto') + }) + + it('normalizes Claude dot-worktree slugs without exposing the home directory', () => { + const rows = aggregateSessions([makeProject()]) + rows[0]!.project = 'Users-torukmakto-codeburn-.claude-worktrees-agent-a213f7c77871f483f' + const output = renderTable(rows, { terminalWidth: 120 }) + + expect(output).toContain('codeburn · agent a213f7c7') + expect(output).not.toContain('Users-torukmakto') + expect(output).not.toContain('.claude-worktrees') + }) + + it('uses captured titles, sorts newest first, and fits the requested width', () => { + const rows = aggregateSessions([makeProject()]) + const older = { ...rows[0]!, sessionId: 'old', title: 'Older task', startedAt: '2026-07-01T10:00:00.000Z' } + const newer = { + ...rows[0]!, + sessionId: 'new', + title: 'Review the authentication migration without exposing filesystem details', + project: '-Users-private-Projects-codeburn', + startedAt: '2026-07-20T10:00:00.000Z', + } + const output = renderTable([older, newer], { terminalWidth: 80 }) + const lines = output.split('\n') + + expect(output.indexOf('Review the')).toBeLessThan(output.indexOf('Older task')) + expect(output).not.toContain('Users-private') + expect(Math.max(...lines.slice(0, -1).map(line => line.length))).toBeLessThanOrEqual(80) }) })