diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfdf1fa..5dc68a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Added (CLI) +- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo) + +### Fixed (CLI) +- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805) + ## 0.9.19 - 2026-07-20 One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19. diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 0fad8cd4..42baa17c 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -46,10 +46,9 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean, return historyProjectCount === 0 && !historyLoading } -// The By Model panel now carries six numeric columns. Keep panels stacked until -// each half has enough room for those columns instead of truncating Tok/s at -// ordinary 100–120 column terminals. -const MIN_WIDE = 130 +// The By Model panel drops the Tok/s column when the panel is too narrow, so +// the wider two-column layout can still activate at ordinary terminal widths. +const MIN_WIDE = 90 const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' @@ -198,9 +197,9 @@ function nextTick(): Promise { return new Promise(resolve => setImmediate(resolve)) } -type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number } +export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number } -function getLayout(columns?: number): Layout { +export function getLayout(columns?: number): Layout { const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80 const dashWidth = Math.min(160, termWidth) const wide = dashWidth >= MIN_WIDE @@ -453,6 +452,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const modelEfficiency = aggregateModelEfficiency(projects) const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0) const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0) + // The Tok/s column needs 61 inner columns for the full row; hide it on narrower + // panels and when no model has timing data (non-Codex users get no dead column). + const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ @@ -464,7 +466,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{'Tok/s'.padStart(MODEL_COL_TPS)} + {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''} {sorted.map(([model, data], i) => { const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 @@ -484,7 +486,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} {oneShotLabel.padStart(MODEL_COL_ONESHOT)} - {tpsLabel.padStart(MODEL_COL_TPS)} + {showTps && {tpsLabel.padStart(MODEL_COL_TPS)}} ) })} @@ -496,7 +498,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {anyEstimated && ( ~ estimated cost (priced from estimated tokens) )} - {anyActiveTiming && ( + {showTps && ( ~ Tok/s: generated tokens / active time; tool wait excluded )} diff --git a/src/providers/codex.ts b/src/providers/codex.ts index ae920fa5..6e5c6bea 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -438,7 +438,11 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { invocation, call_id: getRawJsonStringField(pHead, 'call_id'), turn_id: getRawJsonStringField(pHead, 'turn_id'), - duration_ms: timingNumber('duration_ms') ?? timingDuration, + // On mcp_tool_call_end a coincidental `duration_ms` inside the large + // invocation.arguments object can shadow the payload-level duration, so the + // depth-aware value wins. The naive scan stays as the fallback for + // task_complete, which records duration_ms at the payload level directly. + duration_ms: timingDuration ?? timingNumber('duration_ms'), started_at: timingNumber('started_at'), info: compactInfo, }, @@ -453,48 +457,34 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { return entry } -type DiscoveredCodexSession = { - source: SessionSource - sessionId?: string -} - -async function discoverSessionFile(filePath: string): Promise { +async function discoverSessionFile(filePath: string): Promise { const s = await stat(filePath).catch(() => null) if (!s?.isFile()) return null + // Fast path: cached results already know the project, so avoid opening the + // file. This keeps discovery cheap on large session directories. const cachedProject = await getCachedCodexProject(filePath) - const { valid, meta } = await isValidCodexSession(filePath) if (cachedProject) { - return { - source: { path: filePath, project: cachedProject, provider: 'codex' }, - sessionId: valid ? meta?.payload?.session_id : undefined, - } + return { path: filePath, project: cachedProject, provider: 'codex' } } + const { valid, meta } = await isValidCodexSession(filePath) if (!valid || !meta) return null const cwd = meta.payload?.cwd ?? 'unknown' - return { - source: { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }, - sessionId: meta.payload?.session_id, - } + return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' } } async function discoverSessionsInDir(codexDir: string): Promise { const sources: SessionSource[] = [] - // A rollout can exist in both roots during/after archiving. The active root - // is scanned first, and session_id keeps the archived copy from resurfacing. - const seenSessionIds = new Set() + // Codex archives a session by moving it from sessions/YYYY/MM/DD/ to + // archived_sessions/, keeping the same basename. Deduplicate by basename so + // a session does not appear twice while it exists in both roots. This avoids + // reading every file to extract session_id and preserves the cheap cached + // fast path. + const seenBasenames = new Set() const sessionsDir = join(codexDir, 'sessions') - const addSession = (discovered: DiscoveredCodexSession | null): void => { - if (!discovered) return - const sessionId = discovered.sessionId?.trim() - if (sessionId && seenSessionIds.has(sessionId)) return - if (sessionId) seenSessionIds.add(sessionId) - sources.push(discovered.source) - } - const years = await readdir(sessionsDir).catch(() => [] as string[]) for (const year of years) { @@ -514,8 +504,10 @@ async function discoverSessionsInDir(codexDir: string): Promise for (const file of files) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue - const filePath = join(dayDir, file) - addSession(await discoverSessionFile(filePath)) + if (seenBasenames.has(file)) continue + seenBasenames.add(file) + const source = await discoverSessionFile(join(dayDir, file)) + if (source) sources.push(source) } } } @@ -523,11 +515,16 @@ async function discoverSessionsInDir(codexDir: string): Promise // Codex moves archived sessions into a flat directory. Keep them in usage // reports so archiving a conversation does not erase its historical usage. + // Call-level deduplication (seenKeys) already collapses any remaining + // archived copies, while basename dedup above prevents double discovery. const archivedDir = join(codexDir, 'archived_sessions') const archivedFiles = await readdir(archivedDir).catch(() => [] as string[]) for (const file of archivedFiles) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue - addSession(await discoverSessionFile(join(archivedDir, file))) + if (seenBasenames.has(file)) continue + seenBasenames.add(file) + const source = await discoverSessionFile(join(archivedDir, file)) + if (source) sources.push(source) } return sources diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 59ada8fd..0abd36db 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -2,7 +2,7 @@ import { homedir } from 'os' import { describe, it, expect } from 'vitest' -import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js' +import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js' import { getDateRange } from '../src/cli-date.js' import { formatCost } from '../src/format.js' import type { ProjectSummary, SessionSummary } from '../src/types.js' @@ -248,3 +248,25 @@ describe('showEmptyState', () => { expect(showEmptyState(2, false, 0, false)).toBe(false) }) }) + +describe('getLayout - dashboard width breakpoints', () => { + it('uses a single column at 89 columns or below', () => { + expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 }) + }) + + it('switches to two columns at 90 columns', () => { + expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 }) + }) + + it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => { + // Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60, + // inner=56, below the 61-col threshold where Tok/s renders. + expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 }) + expect(getLayout(120).halfWidth - 4).toBeLessThan(61) + }) + + it('keeps two columns and has enough room for Tok/s at 130 columns', () => { + expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 }) + expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61) + }) +}) diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 0b59ecbb..fe4e902a 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -551,6 +551,39 @@ describe('codex provider - JSONL parsing', () => { expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 }) }) + it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => { + // Regression guard: a naive first-match regex would pick up the + // `duration_ms: 9999` inside invocation.arguments instead of the payload-level + // `duration: { secs: 3 }`. The depth-aware payload scan must win. + const largeMcpLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:00:05Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'mcp-duration-collision', + invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } }, + duration: { secs: 3, nanos: 0 }, + result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } }, + }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [ + sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('look up the issue'), + largeMcpLine, + tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 }) + }) + it('omits active timing when recorded tool wait consumes the task duration', async () => { const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [ sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),