mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 23:24:32 +00:00
feat(cli): add interactive sessions browser
This commit is contained in:
parent
8e1caae33a
commit
02dd4cc53b
4 changed files with 481 additions and 23 deletions
18
src/main.ts
18
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 <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
|
||||
|
|
|
|||
|
|
@ -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
256
src/sessions-tui.tsx
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue