feat(cli): add interactive sessions browser

This commit is contained in:
reviewer 2026-07-21 12:49:33 +02:00
parent 8e1caae33a
commit 02dd4cc53b
4 changed files with 481 additions and 23 deletions

View file

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

View file

@ -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 = {

256
src/sessions-tui.tsx Normal file
View file

@ -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 (
<Box borderStyle="round" borderColor={BORDER} paddingX={1} flexDirection="column" width={Math.max(58, width - 2)}>
<Text bold>{truncate(sessionDisplayName(row), Math.max(30, width - 8))}</Text>
<Text color={MUTED}>{truncate(context, Math.max(30, width - 8))}</Text>
<Text>
<Text color={ORANGE} bold>${row.cost.toFixed(2)}</Text>
<Text color={MUTED}>{truncate(` cost ${metrics}`, Math.max(24, width - 18))}</Text>
</Text>
</Box>
)
}
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 (
<Box flexDirection="column" paddingX={1} paddingTop={1} width={width}>
<Box justifyContent="space-between">
<Text><Text color={ORANGE}></Text> <Text bold>Sessions</Text> <Text color={MUTED}>{period}</Text></Text>
<Text color={MUTED}>{filtered.length.toLocaleString('en-US')} sessions · <Text color="white" bold>${totalCost.toFixed(2)}</Text> total</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<Text color={MUTED}>Provider </Text>
<Text backgroundColor="#27272A" bold> {provider === 'all' ? 'All providers' : provider} </Text>
<Text color={MUTED}> Search </Text>
<Text color={query || searching ? 'white' : MUTED}>{searching ? `/${query}` : query || 'press /'}</Text>
{query && !searching ? <Text color={MUTED}> · c clear</Text> : null}
</Box>
<Text color={MUTED} bold>{` ${headerLine(tableColumns)}`}</Text>
<Text color={BORDER}>{'─'.repeat(Math.min(width - 2, headerLine(tableColumns).length + 2))}</Text>
{visible.map((row, index) => {
const absoluteIndex = first + index
const active = absoluteIndex === cursor
return (
<Text key={`${row.provider}:${row.sessionId}:${row.startedAt}`} backgroundColor={active ? '#27272A' : undefined} color={active ? 'white' : undefined} bold={active}>
<Text color={active ? ORANGE : MUTED}>{active ? ' ' : ' '}</Text>{tableLine(row, tableColumns)}
</Text>
)
})}
{filtered.length === 0 ? <Text color={MUTED}> No sessions match this filter.</Text> : null}
<Box marginTop={1}>
{selected && showDetails ? <DetailPanel row={selected} width={width} /> : null}
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={MUTED}>{help}</Text>
<Text color={MUTED}>{filtered.length ? `${cursor + 1} / ${filtered.length}` : ''}</Text>
</Box>
</Box>
)
}
export async function runSessionsTui(rows: SessionRow[], opts: { period: string; provider: string }): Promise<void> {
patchStdoutForWindows()
const instance = render(<SessionsTui rows={rows} period={opts.period} initialProvider={opts.provider} />)
await instance.waitUntilExit()
}

View file

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