From 2ef425951791cea7e6cd5bc4488be564b90b0329 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:02 +0200 Subject: [PATCH 1/8] test(analytics): failing tests for computeOutlierSessions and dominantActivity --- tests/analytics.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/analytics.test.ts diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts new file mode 100644 index 00000000..c49b1b4e --- /dev/null +++ b/tests/analytics.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import { + computeOutlierSessions, + dominantActivity, + TOP_OUTLIER_COUNT, + OUTLIER_MULTIPLIER, +} from '../src/analytics.js' +import type { ProjectSummary, SessionSummary } from '../src/types.js' + +const EMPTY_CATS: SessionSummary['categoryBreakdown'] = { + coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + 'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, +} + +function makeSession(id: string, cost: number, firstTs = '2026-04-10T10:00:00Z'): SessionSummary { + return { + sessionId: id, project: 'p', firstTimestamp: firstTs, lastTimestamp: firstTs, + totalCostUSD: cost, totalInputTokens: 0, totalOutputTokens: 0, + totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, + turns: [], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, + bashBreakdown: {}, categoryBreakdown: structuredClone(EMPTY_CATS), + } +} + +function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary { + const totalCostUSD = sessions.reduce((s, x) => s + x.totalCostUSD, 0) + return { project: name, projectPath: name, sessions, totalCostUSD, totalApiCalls: sessions.length } +} + +describe('dominantActivity', () => { + it('returns label of highest-cost category', () => { + const s = makeSession('s1', 10) + s.categoryBreakdown.coding.costUSD = 5 + s.categoryBreakdown.debugging.costUSD = 8 + expect(dominantActivity(s)).toBe('Debugging') + }) + + it('returns General for empty categoryBreakdown costs', () => { + const s = makeSession('s1', 0) + expect(dominantActivity(s)).toBeTypeOf('string') + }) +}) + +describe('computeOutlierSessions', () => { + it('returns at most TOP_OUTLIER_COUNT', () => { + const sessions = Array.from({ length: 8 }, (_, i) => makeSession(`s${i}`, i + 1)) + const rows = computeOutlierSessions([makeProject('p', sessions)]) + expect(rows.length).toBe(TOP_OUTLIER_COUNT) + }) + + it('sorts by cost descending', () => { + const sessions = [makeSession('a', 3), makeSession('b', 10), makeSession('c', 5)] + const rows = computeOutlierSessions([makeProject('p', sessions)]) + expect(rows.map(r => r.sessionId)).toEqual(['b', 'c', 'a']) + }) + + it('flags isOutlier when cost > OUTLIER_MULTIPLIER x project avg', () => { + const sessions = [ + makeSession('big', 100), + makeSession('s1', 10), + makeSession('s2', 10), + makeSession('s3', 10), + ] + const rows = computeOutlierSessions([makeProject('p', sessions)]) + const big = rows.find(r => r.sessionId === 'big')! + expect(big.isOutlier).toBe(true) + const s1 = rows.find(r => r.sessionId === 's1')! + expect(s1.isOutlier).toBe(false) + expect(OUTLIER_MULTIPLIER).toBe(2) + }) + + it('isOutlier is false for a single-session project (no variance)', () => { + const rows = computeOutlierSessions([makeProject('p', [makeSession('only', 5)])]) + expect(rows[0].isOutlier).toBe(false) + }) + + it('returns empty array for empty projects', () => { + expect(computeOutlierSessions([])).toEqual([]) + }) + + it('includes YYYY-MM-DD date from firstTimestamp', () => { + const s = makeSession('s', 1, '2026-04-10T15:30:00Z') + const rows = computeOutlierSessions([makeProject('p', [s])]) + expect(rows[0].date).toBe('2026-04-10') + }) +}) + +import { computeModelOneShotRates } from '../src/analytics.js' + +describe('computeModelOneShotRates', () => { + function makeTurn(model: string, hasEdits: boolean, retries: number): SessionSummary['turns'][number] { + return { + category: 'coding', + hasEdits, + retries, + assistantCalls: [{ model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUSD: 0 }], + } as unknown as SessionSummary['turns'][number] + } + + it('returns one-shot rate per model', () => { + const s = makeSession('s1', 10) + s.modelBreakdown = { 'Sonnet 4.5': { calls: 3, costUSD: 10, tokens: 100 as unknown as SessionSummary['modelBreakdown'][string]['tokens'] } } + s.turns = [ + makeTurn('claude-sonnet-4-5', true, 0), + makeTurn('claude-sonnet-4-5', true, 1), + makeTurn('claude-sonnet-4-5', true, 0), + ] + const rows = computeModelOneShotRates([makeProject('p', [s])]) + const sonnet = rows.find(r => r.model === 'Sonnet 4.5') + expect(sonnet).toBeDefined() + expect(sonnet!.oneShotTurns).toBe(2) + expect(sonnet!.editTurns).toBe(3) + expect(sonnet!.oneShotRate).toBeCloseTo(2 / 3) + }) + + it('null oneShotRate when no edit turns', () => { + const s = makeSession('s1', 5) + s.modelBreakdown = { 'Haiku': { calls: 1, costUSD: 5, tokens: 50 as unknown as SessionSummary['modelBreakdown'][string]['tokens'] } } + s.turns = [] + const rows = computeModelOneShotRates([makeProject('p', [s])]) + expect(rows[0]?.oneShotRate).toBeNull() + }) + + it('returns empty for empty projects', () => { + expect(computeModelOneShotRates([])).toEqual([]) + }) +}) From ea2190e99337b128cf61a08cc65b4aeda997faea Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:02 +0200 Subject: [PATCH 2/8] feat(analytics): add computeOutlierSessions and dominantActivity --- src/analytics.ts | 120 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/analytics.ts diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 00000000..28d70e97 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,120 @@ +import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js' +import { CATEGORY_LABELS } from './types.js' +import { getShortModelName } from './models.js' + +export const TOP_OUTLIER_COUNT = 5 +export const OUTLIER_MULTIPLIER = 2 + +export type OutlierSession = { + rank: number + project: string + sessionId: string + date: string + totalCostUSD: number + dominantActivity: string + isOutlier: boolean +} + +export type ModelOneShotRow = { + model: string + sessions: number + oneShotTurns: number + editTurns: number + oneShotRate: number | null + costUSD: number +} + +export function dominantActivity(session: SessionSummary): string { + let best: TaskCategory | null = null + let bestCost = -1 + for (const [cat, data] of Object.entries(session.categoryBreakdown)) { + if (data.costUSD > bestCost) { + bestCost = data.costUSD + best = cat as TaskCategory + } + } + return best ? (CATEGORY_LABELS[best] ?? best) : 'General' +} + +export function computeOutlierSessions(projects: ProjectSummary[]): OutlierSession[] { + const projectAvg = new Map() + for (const p of projects) { + const avg = p.sessions.length > 0 ? p.totalCostUSD / p.sessions.length : 0 + projectAvg.set(p.project, avg) + } + + const all = projects.flatMap(p => + p.sessions.map(s => ({ session: s, project: p.project })) + ) + const sorted = [...all].sort((a, b) => b.session.totalCostUSD - a.session.totalCostUSD) + const top = sorted.slice(0, TOP_OUTLIER_COUNT) + + return top.map(({ session, project }, i) => { + const avg = projectAvg.get(project) ?? 0 + return { + rank: i + 1, + project, + sessionId: session.sessionId, + date: session.firstTimestamp ? session.firstTimestamp.slice(0, 10) : '----------', + totalCostUSD: session.totalCostUSD, + dominantActivity: dominantActivity(session), + isOutlier: avg > 0 && session.totalCostUSD > OUTLIER_MULTIPLIER * avg, + } + }) +} + +export function computeModelOneShotRates(projects: ProjectSummary[]): ModelOneShotRow[] { + const modelData = new Map; costUSD: number }>() + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + const primaryModel = turn.assistantCalls[0] + ? getShortModelName(turn.assistantCalls[0].model) + : null + if (!primaryModel) continue + const entry = modelData.get(primaryModel) ?? { + oneShotTurns: 0, + editTurns: 0, + sessions: new Set(), + costUSD: 0, + } + if (turn.hasEdits) { + entry.editTurns++ + if (turn.retries === 0) entry.oneShotTurns++ + } + modelData.set(primaryModel, entry) + } + for (const [model, data] of Object.entries(session.modelBreakdown)) { + const entry = modelData.get(model) ?? { + oneShotTurns: 0, + editTurns: 0, + sessions: new Set(), + costUSD: 0, + } + entry.costUSD += data.costUSD + entry.sessions.add(session.sessionId) + modelData.set(model, entry) + } + } + } + + const rows: ModelOneShotRow[] = [] + for (const [model, data] of modelData) { + rows.push({ + model, + sessions: data.sessions.size, + oneShotTurns: data.oneShotTurns, + editTurns: data.editTurns, + oneShotRate: data.editTurns > 0 ? data.oneShotTurns / data.editTurns : null, + costUSD: data.costUSD, + }) + } + + return rows.sort((a, b) => { + if (a.oneShotRate === null && b.oneShotRate === null) return b.costUSD - a.costUSD + if (a.oneShotRate === null) return 1 + if (b.oneShotRate === null) return -1 + return b.oneShotRate - a.oneShotRate || b.costUSD - a.costUSD + }) +} From d489c9ed7254a5a4ca82f55ce63eade792d75a24 Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:03 +0200 Subject: [PATCH 3/8] feat(dashboard): add outlier sessions and per-model one-shot rate panels --- src/dashboard.tsx | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 21281f89..e0869243 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -3,6 +3,7 @@ import { homedir } from 'os' import React, { useState, useCallback, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' +import { computeOutlierSessions, computeModelOneShotRates, TOP_OUTLIER_COUNT } from './analytics.js' import { formatCost, formatTokens } from './format.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { loadPricing } from './models.js' @@ -378,6 +379,17 @@ const TOP_SESSIONS_DATE_LEN = 10 const TOP_SESSIONS_COST_COL = 8 const TOP_SESSIONS_CALLS_COL = 6 +const OUTLIER_COST_COL = 8 +const OUTLIER_ACT_COL = 13 +const OUTLIER_FLAG = '!' + +const MODEL_ONESHOT_NAME_WIDTH = 14 +const MODEL_ONESHOT_RATE_COL = 7 +const MODEL_ONESHOT_SESS_COL = 6 +const MODEL_ONESHOT_COST_COL = 8 +const MODEL_ONESHOT_HIGH_THRESHOLD = 0.9 +const MODEL_ONESHOT_MID_THRESHOLD = 0.7 + function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const allSessions = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })) @@ -412,6 +424,70 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num ) } +function OutlierSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const rows = computeOutlierSessions(projects) + + if (rows.length === 0) { + return No sessions + } + + const maxCost = rows[0].totalCostUSD + const nw = Math.max(8, pw - bw - OUTLIER_COST_COL - OUTLIER_ACT_COL - 3 - PANEL_CHROME) + + return ( + + {''.padEnd(bw + 1 + nw)}{'cost'.padStart(OUTLIER_COST_COL)}{' activity'.padEnd(OUTLIER_ACT_COL + 1)} + {rows.map((row, i) => { + const label = `${row.date} ${shortProject(row.project)}` + return ( + + + {fit(label, nw - 1)} + {formatCost(row.totalCostUSD).padStart(OUTLIER_COST_COL)} + {fit(row.dominantActivity, OUTLIER_ACT_COL)} + {row.isOutlier ? OUTLIER_FLAG : ' '} + + ) + })} + + ) +} + +function ModelOneShotBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const rows = computeModelOneShotRates(projects) + + if (rows.length === 0) { + return No model data + } + + const maxCost = Math.max(...rows.map(r => r.costUSD)) + + return ( + + {''.padEnd(bw + 1 + MODEL_ONESHOT_NAME_WIDTH)}{'cost'.padStart(MODEL_ONESHOT_COST_COL)}{'sess'.padStart(MODEL_ONESHOT_SESS_COL)}{'1-shot'.padStart(MODEL_ONESHOT_RATE_COL)} + {rows.map((row, i) => { + const rateLabel = row.oneShotRate !== null ? `${Math.round(row.oneShotRate * 100)}%` : '-' + const rateColor = row.oneShotRate === null + ? DIM + : row.oneShotRate >= MODEL_ONESHOT_HIGH_THRESHOLD + ? '#5BF58C' + : row.oneShotRate >= MODEL_ONESHOT_MID_THRESHOLD + ? ORANGE + : '#F55B5B' + return ( + + + {fit(row.model, MODEL_ONESHOT_NAME_WIDTH)} + {formatCost(row.costUSD).padStart(MODEL_ONESHOT_COST_COL)} + {String(row.sessions).padStart(MODEL_ONESHOT_SESS_COL)} + {rateLabel.padStart(MODEL_ONESHOT_RATE_COL)} + + ) + })} + + ) +} + function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const mcpTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } } @@ -564,7 +640,9 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets } + + {isCursor ? ( ) : ( From 2ccc77c02caeaeaabdc4fd71a3cd4c8943443b9f Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:03 +0200 Subject: [PATCH 4/8] feat(report): add outlierSessions and modelOneShotRates to JSON report --- src/cli.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index ac87127e..d6766c69 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { buildMenubarPayload } from './menubar-json.js' import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import { computeOutlierSessions, computeModelOneShotRates } from './analytics.js' import { renderDashboard } from './dashboard.js' import { runOptimize, scanAndDetect } from './optimize.js' import { getAllProviders } from './providers/index.js' @@ -204,6 +205,25 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: .sort((a, b) => b.cost - a.cost) .slice(0, 5) + const outlierSessions = computeOutlierSessions(projects).map(r => ({ + rank: r.rank, + project: r.project, + sessionId: r.sessionId, + date: r.date, + cost: convertCost(r.totalCostUSD), + dominantActivity: r.dominantActivity, + isOutlier: r.isOutlier, + })) + + const modelOneShotRates = computeModelOneShotRates(projects).map(r => ({ + model: r.model, + sessions: r.sessions, + oneShotRate: r.oneShotRate !== null ? Math.round(r.oneShotRate * 1000) / 10 : null, + editTurns: r.editTurns, + oneShotTurns: r.oneShotTurns, + cost: convertCost(r.costUSD), + })) + return { generated: new Date().toISOString(), currency: code, @@ -229,6 +249,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: mcpServers: sortedMap(mcpMap), shellCommands: sortedMap(bashMap), topSessions, + outlierSessions, + modelOneShotRates, } } From 35e7efb21cfd154414e6e1b25ad576e1f178b17e Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:03 +0200 Subject: [PATCH 5/8] fix(analytics): dominantActivity falls back to General when all category costs are zero --- src/analytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analytics.ts b/src/analytics.ts index 28d70e97..7433dc33 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -26,7 +26,7 @@ export type ModelOneShotRow = { export function dominantActivity(session: SessionSummary): string { let best: TaskCategory | null = null - let bestCost = -1 + let bestCost = 0 for (const [cat, data] of Object.entries(session.categoryBreakdown)) { if (data.costUSD > bestCost) { bestCost = data.costUSD From ba776c4b729fd9b044b7be05785cf93af8abc4db Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:03 +0200 Subject: [PATCH 6/8] test(analytics): replace type casts with proper TokenUsage and ApiCall fixtures --- tests/analytics.test.ts | 55 ++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts index c49b1b4e..4893b7cf 100644 --- a/tests/analytics.test.ts +++ b/tests/analytics.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest' import { computeOutlierSessions, + computeModelOneShotRates, dominantActivity, TOP_OUTLIER_COUNT, OUTLIER_MULTIPLIER, } from '../src/analytics.js' -import type { ProjectSummary, SessionSummary } from '../src/types.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js' const EMPTY_CATS: SessionSummary['categoryBreakdown'] = { coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, @@ -96,21 +97,51 @@ describe('computeOutlierSessions', () => { }) }) -import { computeModelOneShotRates } from '../src/analytics.js' +function makeTokenUsage(): TokenUsage { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} -describe('computeModelOneShotRates', () => { - function makeTurn(model: string, hasEdits: boolean, retries: number): SessionSummary['turns'][number] { - return { - category: 'coding', - hasEdits, - retries, - assistantCalls: [{ model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUSD: 0 }], - } as unknown as SessionSummary['turns'][number] +function makeApiCall(model: string): ParsedApiCall { + return { + provider: 'claude', + model, + usage: makeTokenUsage(), + costUSD: 0, + tools: [], + mcpTools: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-04-10T10:00:00Z', + bashCommands: [], + deduplicationKey: `${model}-0`, } +} +function makeTurn(model: string, hasEdits: boolean, retries: number): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: [makeApiCall(model)], + timestamp: '2026-04-10T10:00:00Z', + sessionId: 's1', + category: 'coding', + retries, + hasEdits, + } +} + +describe('computeModelOneShotRates', () => { it('returns one-shot rate per model', () => { const s = makeSession('s1', 10) - s.modelBreakdown = { 'Sonnet 4.5': { calls: 3, costUSD: 10, tokens: 100 as unknown as SessionSummary['modelBreakdown'][string]['tokens'] } } + s.modelBreakdown = { 'Sonnet 4.5': { calls: 3, costUSD: 10, tokens: makeTokenUsage() } } s.turns = [ makeTurn('claude-sonnet-4-5', true, 0), makeTurn('claude-sonnet-4-5', true, 1), @@ -126,7 +157,7 @@ describe('computeModelOneShotRates', () => { it('null oneShotRate when no edit turns', () => { const s = makeSession('s1', 5) - s.modelBreakdown = { 'Haiku': { calls: 1, costUSD: 5, tokens: 50 as unknown as SessionSummary['modelBreakdown'][string]['tokens'] } } + s.modelBreakdown = { 'Haiku': { calls: 1, costUSD: 5, tokens: makeTokenUsage() } } s.turns = [] const rows = computeModelOneShotRates([makeProject('p', [s])]) expect(rows[0]?.oneShotRate).toBeNull() From fcb4bf972012e0f7b4eee6cfa9ce3c679abe2d8e Mon Sep 17 00:00:00 2001 From: Ninym Date: Sat, 18 Apr 2026 15:48:03 +0200 Subject: [PATCH 7/8] refactor(dashboard): merge TopSessions and OutlierSessions into one panel Single Top Sessions panel shows 5 highest-cost sessions with activity column and outlier highlighting via red cost color (outlier = >2x project average). Removes the redundant second panel that showed the same session list. --- src/dashboard.tsx | 54 ++++++----------------------------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index e0869243..b66a4889 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -3,7 +3,7 @@ import { homedir } from 'os' import React, { useState, useCallback, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' -import { computeOutlierSessions, computeModelOneShotRates, TOP_OUTLIER_COUNT } from './analytics.js' +import { computeOutlierSessions, computeModelOneShotRates, TOP_OUTLIER_COUNT, OUTLIER_MULTIPLIER } from './analytics.js' import { formatCost, formatTokens } from './format.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { loadPricing } from './models.js' @@ -375,13 +375,8 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr ) } -const TOP_SESSIONS_DATE_LEN = 10 const TOP_SESSIONS_COST_COL = 8 -const TOP_SESSIONS_CALLS_COL = 6 - -const OUTLIER_COST_COL = 8 -const OUTLIER_ACT_COL = 13 -const OUTLIER_FLAG = '!' +const TOP_SESSIONS_ACT_COL = 13 const MODEL_ONESHOT_NAME_WIDTH = 14 const MODEL_ONESHOT_RATE_COL = 7 @@ -391,40 +386,6 @@ const MODEL_ONESHOT_HIGH_THRESHOLD = 0.9 const MODEL_ONESHOT_MID_THRESHOLD = 0.7 function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const allSessions = projects.flatMap(p => - p.sessions.map(s => ({ ...s, projectName: p.project })) - ) - const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5) - - if (top.length === 0) { - return No sessions - } - - const maxCost = top[0].totalCostUSD - const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME) - - return ( - - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)} - {top.map((session, i) => { - const date = session.firstTimestamp - ? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) - : '----------' - const label = `${date} ${shortProject(session.projectName)}` - return ( - - - {fit(label, nw - 1)} - {formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)} - {String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)} - - ) - })} - - ) -} - -function OutlierSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const rows = computeOutlierSessions(projects) if (rows.length === 0) { @@ -432,23 +393,23 @@ function OutlierSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: } const maxCost = rows[0].totalCostUSD - const nw = Math.max(8, pw - bw - OUTLIER_COST_COL - OUTLIER_ACT_COL - 3 - PANEL_CHROME) + const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_ACT_COL - 2 - PANEL_CHROME) return ( - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(OUTLIER_COST_COL)}{' activity'.padEnd(OUTLIER_ACT_COL + 1)} + {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{' activity'.padEnd(TOP_SESSIONS_ACT_COL + 1)} {rows.map((row, i) => { const label = `${row.date} ${shortProject(row.project)}` return ( {fit(label, nw - 1)} - {formatCost(row.totalCostUSD).padStart(OUTLIER_COST_COL)} - {fit(row.dominantActivity, OUTLIER_ACT_COL)} - {row.isOutlier ? OUTLIER_FLAG : ' '} + {formatCost(row.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)} + {fit(row.dominantActivity, TOP_SESSIONS_ACT_COL)} ) })} + red cost = outlier ({'>'}{OUTLIER_MULTIPLIER}x project avg) ) } @@ -640,7 +601,6 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets } - {isCursor ? ( From 44f5f6f99ed957a7dc585b341638af77eb973ea9 Mon Sep 17 00:00:00 2001 From: Ninym Date: Tue, 28 Apr 2026 17:45:31 +0200 Subject: [PATCH 8/8] fix(analytics): attribute model one-shot rate to last assistantCall in turn The first call in a multi-step turn is often a tool-request; the final call is the one that produces the edit. Using assistantCalls[last] correctly attributes the outcome to the model that generated it. --- src/analytics.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/analytics.ts b/src/analytics.ts index 7433dc33..a15fb1d9 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -69,8 +69,9 @@ export function computeModelOneShotRates(projects: ProjectSummary[]): ModelOneSh for (const project of projects) { for (const session of project.sessions) { for (const turn of session.turns) { - const primaryModel = turn.assistantCalls[0] - ? getShortModelName(turn.assistantCalls[0].model) + const lastCall = turn.assistantCalls[turn.assistantCalls.length - 1] + const primaryModel = lastCall + ? getShortModelName(lastCall.model) : null if (!primaryModel) continue const entry = modelData.get(primaryModel) ?? {