From cfd046a95c2ac9a69a7e736571a9ce689f369242 Mon Sep 17 00:00:00 2001 From: Ninym Date: Thu, 16 Apr 2026 13:14:34 +0200 Subject: [PATCH 1/4] feat: add All Time period, avg/s per project, and Top Sessions panel - Add 'all' period (key 5) to dashboard showing data from all time - Daily Activity panel shows all available days when All Time is active - Add avg cost per session column to Project breakdown - Add Top Sessions panel highlighting the 5 most expensive sessions --- src/cli.ts | 5 +-- src/dashboard.tsx | 82 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2c83efc..2c5fcd1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -50,10 +50,11 @@ function getDateRange(period: string): { range: DateRange; label: string } { } } -function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' { +function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' { if (s === 'today') return 'today' if (s === 'month') return 'month' if (s === '30days') return '30days' + if (s === 'all') return 'all' return 'week' } @@ -69,7 +70,7 @@ program.hook('preAction', async () => { program .command('report', { isDefault: true }) .description('Interactive usage dashboard') - .option('-p, --period ', 'Starting period: today, week, 30days, month', 'week') + .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week') .option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all') .option('--refresh ', 'Auto-refresh interval in seconds', parseInt) .action(async (opts) => { diff --git a/src/dashboard.tsx b/src/dashboard.tsx index e8aabf6..ed64d4f 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -8,14 +8,15 @@ import { parseAllSessions } from './parser.js' import { loadPricing } from './models.js' import { getAllProviders } from './providers/index.js' -type Period = 'today' | 'week' | '30days' | 'month' +type Period = 'today' | 'week' | '30days' | 'month' | 'all' -const PERIODS: Period[] = ['today', 'week', '30days', 'month'] +const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] const PERIOD_LABELS: Record = { today: 'Today', week: '7 Days', '30days': '30 Days', month: 'This Month', + all: 'All Time', } const MIN_WIDE = 90 @@ -37,6 +38,7 @@ const PANEL_COLORS = { overview: '#FF8C42', daily: '#5B9EF5', project: '#5BF5A0', + sessions: '#FF6B6B', model: '#E05BF5', activity: '#F5C85B', tools: '#5BF5E0', @@ -99,6 +101,7 @@ function getDateRange(period: Period): { start: Date; end: Date } { case 'week': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7), end } case '30days': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30), end } case 'month': return { start: new Date(now.getFullYear(), now.getMonth(), 1), end } + case 'all': return { start: new Date(0), end } } } @@ -191,7 +194,7 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma } } } - const sortedDays = Object.keys(dailyCosts).sort().slice(-days) + const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort() const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0)) return ( @@ -230,20 +233,28 @@ function shortProject(encoded: string): string { return parts.slice(-3).join('/') } +const PROJECT_COL_AVG = 7 + function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const maxCost = Math.max(...projects.map(p => p.totalCostUSD)) - const nw = Math.max(8, pw - bw - 23) + const nw = Math.max(8, pw - bw - 30) return ( - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'sess'.padStart(6)} - {projects.slice(0, 8).map((project, i) => ( - - - {fit(shortProject(project.project), nw)} - {formatCost(project.totalCostUSD).padStart(8)} - {String(project.sessions.length).padStart(6)} - - ))} + {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)} + {projects.slice(0, 8).map((project, i) => { + const avgCost = project.sessions.length > 0 + ? formatCost(project.totalCostUSD / project.sessions.length) + : '-' + return ( + + + {fit(shortProject(project.project), nw)} + {formatCost(project.totalCostUSD).padStart(8)} + {avgCost.padStart(PROJECT_COL_AVG)} + {String(project.sessions.length).padStart(6)} + + ) + })} ) } @@ -364,6 +375,42 @@ 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 + +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) + + 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.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 McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const mcpTotals: Record = {} for (const project of projects) { @@ -477,7 +524,9 @@ function StatusBar({ width, showProvider }: { width: number; showProvider?: bool 3 30 days 4 - month + month + 5 + all time {showProvider && ( <> @@ -508,7 +557,7 @@ function DashboardContent({ projects, period, columns, activeProvider }: { proje } const pw = wide ? halfWidth : dashWidth - const days = period === 'month' || period === '30days' ? 31 : 14 + const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) return ( @@ -519,6 +568,8 @@ function DashboardContent({ projects, period, columns, activeProvider }: { proje + + @@ -629,6 +680,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, else if (input === '2') switchPeriodImmediate('week') else if (input === '3') switchPeriodImmediate('30days') else if (input === '4') switchPeriodImmediate('month') + else if (input === '5') switchPeriodImmediate('all') }) if (loading) { From a8517d32357b59f933f29fa143ca3814bdd4a591 Mon Sep 17 00:00:00 2001 From: Teo Delis Date: Thu, 16 Apr 2026 15:40:22 +0300 Subject: [PATCH 2/4] feat: add GitHub Copilot provider - Parse ~/.copilot/session-state/*/events.jsonl - Track model via session.model_change events - Extract tools from assistant.message toolRequests - Add fallback pricing for gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-5-mini, o3, o4-mini - Factory function createCopilotProvider(sessionStateDir?) for testability - Typed event variants (ModelChangeData, UserMessageData, AssistantMessageData) - bashCommands: [] in yield (Copilot does not log bash commands) - 13 tests covering parsing, model tracking, tool extraction, dedup, discoverSessions - Note: only outputTokens available (Copilot does not log input tokens) --- src/models.ts | 12 ++ src/providers/copilot.ts | 205 ++++++++++++++++++++++++++++++ src/providers/index.ts | 3 +- tests/provider-registry.test.ts | 2 +- tests/providers/copilot.test.ts | 217 ++++++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 src/providers/copilot.ts create mode 100644 tests/providers/copilot.test.ts diff --git a/src/models.ts b/src/models.ts index 4a53112..f995912 100644 --- a/src/models.ts +++ b/src/models.ts @@ -42,6 +42,12 @@ const FALLBACK_PRICING: Record = { 'gpt-5.4': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gpt-5.4-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.2e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, 'gpt-5': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gpt-5-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.2e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gpt-4.1': { inputCostPerToken: 2e-6, outputCostPerToken: 8e-6, cacheWriteCostPerToken: 2e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gpt-4.1-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.1e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'gpt-4.1-nano': { inputCostPerToken: 0.1e-6, outputCostPerToken: 0.4e-6, cacheWriteCostPerToken: 0.1e-6, cacheReadCostPerToken: 0.025e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'o3': { inputCostPerToken: 10e-6, outputCostPerToken: 40e-6, cacheWriteCostPerToken: 10e-6, cacheReadCostPerToken: 2.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, + 'o4-mini': { inputCostPerToken: 1.1e-6, outputCostPerToken: 4.4e-6, cacheWriteCostPerToken: 1.1e-6, cacheReadCostPerToken: 0.275e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 }, } let pricingCache: Map | null = null @@ -182,11 +188,17 @@ export function getShortModelName(model: string): string { 'claude-3-5-haiku': 'Haiku 3.5', 'gpt-4o-mini': 'GPT-4o Mini', 'gpt-4o': 'GPT-4o', + 'gpt-4.1-nano': 'GPT-4.1 Nano', + 'gpt-4.1-mini': 'GPT-4.1 Mini', + 'gpt-4.1': 'GPT-4.1', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5-mini': 'GPT-5 Mini', 'gpt-5': 'GPT-5', 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'o4-mini': 'o4-mini', + 'o3': 'o3', } for (const [key, name] of Object.entries(shortNames)) { if (canonical.startsWith(key)) return name diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts new file mode 100644 index 0000000..365c1d4 --- /dev/null +++ b/src/providers/copilot.ts @@ -0,0 +1,205 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const modelDisplayNames: Record = { + 'gpt-4.1-nano': 'GPT-4.1 Nano', + 'gpt-4.1-mini': 'GPT-4.1 Mini', + 'gpt-4.1': 'GPT-4.1', + 'gpt-4o-mini': 'GPT-4o Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-5-mini': 'GPT-5 Mini', + 'gpt-5': 'GPT-5', + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-sonnet-4': 'Sonnet 4', + 'claude-3-7-sonnet': 'Sonnet 3.7', + 'claude-3-5-sonnet': 'Sonnet 3.5', + 'o4-mini': 'o4-mini', + 'o3': 'o3', +} + +const toolNameMap: Record = { + bash: 'Bash', + read_file: 'Read', + write_file: 'Edit', + edit_file: 'Edit', + create_file: 'Write', + delete_file: 'Edit', + search_files: 'Grep', + find_files: 'Glob', + list_directory: 'LS', + web_search: 'WebSearch', + fetch_webpage: 'WebFetch', + github_repo: 'GitHub', +} + +// Pre-sorted by key length descending so longer/more-specific keys match first +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + +type ToolRequest = { + name?: string + toolCallId?: string + type?: string +} + +type ModelChangeData = { + newModel: string + previousModel?: string +} + +type UserMessageData = { + content: string + interactionId?: string +} + +type AssistantMessageData = { + messageId: string + outputTokens: number + interactionId?: string + toolRequests?: ToolRequest[] +} + +type CopilotEvent = + | { type: 'session.model_change'; timestamp?: string; data: ModelChangeData } + | { type: 'user.message'; timestamp?: string; data: UserMessageData } + | { type: 'assistant.message'; timestamp?: string; data: AssistantMessageData } + +function getCopilotSessionStateDir(override?: string): string { + return override ?? join(homedir(), '.copilot', 'session-state') +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + let content: string + try { + content = await readFile(source.path, 'utf-8') + } catch { + return + } + + const sessionId = basename(dirname(source.path)) + const lines = content.split('\n').filter(l => l.trim()) + let currentModel = 'gpt-4.1' + let pendingUserMessage = '' + + for (const line of lines) { + let event: CopilotEvent + try { + event = JSON.parse(line) as CopilotEvent + } catch { + continue + } + + if (event.type === 'session.model_change') { + currentModel = event.data.newModel ?? currentModel + continue + } + + if (event.type === 'user.message') { + pendingUserMessage = event.data.content ?? '' + continue + } + + if (event.type === 'assistant.message') { + const { messageId, outputTokens, toolRequests = [] } = event.data + if (outputTokens === 0) continue + + const dedupKey = `copilot:${sessionId}:${messageId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const tools = toolRequests + .map(t => t.name ?? '') + .filter(Boolean) + .map(n => toolNameMap[n] ?? n) + + const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0) + + yield { + provider: 'copilot', + model: currentModel, + inputTokens: 0, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands: [], + timestamp: event.timestamp ?? '', + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + } + + pendingUserMessage = '' + } + } + }, + } +} + +async function discoverSessionsInDir(sessionStateDir: string): Promise { + const sources: SessionSource[] = [] + + let sessionDirs: string[] + try { + sessionDirs = await readdir(sessionStateDir) + } catch { + return sources + } + + for (const sessionId of sessionDirs) { + const eventsPath = join(sessionStateDir, sessionId, 'events.jsonl') + const s = await stat(eventsPath).catch(() => null) + if (!s?.isFile()) continue + + let project = sessionId + try { + const yaml = await readFile(join(sessionStateDir, sessionId, 'workspace.yaml'), 'utf-8') + const cwdMatch = yaml.match(/^cwd:\s*(.+)$/m) + if (cwdMatch?.[1]) project = basename(cwdMatch[1].trim()) + } catch {} + + sources.push({ path: eventsPath, project, provider: 'copilot' }) + } + + return sources +} + +export function createCopilotProvider(sessionStateDir?: string): Provider { + const dir = getCopilotSessionStateDir(sessionStateDir) + + return { + name: 'copilot', + displayName: 'Copilot', + + modelDisplayName(model: string): string { + for (const [key, name] of modelDisplayEntries) { + if (model === key || model.startsWith(key + '-')) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const copilot = createCopilotProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 0ac83c6..208a4fa 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +1,6 @@ import { claude } from './claude.js' import { codex } from './codex.js' +import { copilot } from './copilot.js' import { pi } from './pi.js' import type { Provider, SessionSource } from './types.js' @@ -33,7 +34,7 @@ async function loadOpenCode(): Promise { } } -const coreProviders: Provider[] = [claude, codex, pi] +const coreProviders: Provider[] = [claude, codex, copilot, pi] export async function getAllProviders(): Promise { const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()]) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 79e5e24..8c452f6 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'pi']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts new file mode 100644 index 0000000..2e06d6a --- /dev/null +++ b/tests/providers/copilot.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { copilot, createCopilotProvider } from '../../src/providers/copilot.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +async function createSessionDir(sessionId: string, lines: string[], cwd = '/home/user/myproject') { + const sessionDir = join(tmpDir, sessionId) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'workspace.yaml'), `id: ${sessionId}\ncwd: ${cwd}\n`) + await writeFile(join(sessionDir, 'events.jsonl'), lines.join('\n') + '\n') + return join(sessionDir, 'events.jsonl') +} + +function modelChange(newModel: string, previousModel?: string) { + return JSON.stringify({ type: 'session.model_change', timestamp: '2026-04-15T10:00:01Z', data: { newModel, previousModel } }) +} + +function userMessage(content: string) { + return JSON.stringify({ type: 'user.message', timestamp: '2026-04-15T10:00:10Z', data: { content, interactionId: 'int-1' } }) +} + +function assistantMessage(opts: { messageId: string; outputTokens: number; tools?: string[]; timestamp?: string }) { + return JSON.stringify({ + type: 'assistant.message', + timestamp: opts.timestamp ?? '2026-04-15T10:00:15Z', + data: { + messageId: opts.messageId, + outputTokens: opts.outputTokens, + interactionId: 'int-1', + toolRequests: (opts.tools ?? []).map(name => ({ name, toolCallId: `call-${name}`, type: 'function' })), + }, + }) +} + +describe('copilot provider - JSONL parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses a basic assistant message', async () => { + const eventsPath = await createSessionDir('sess-001', [ + modelChange('gpt-4.1'), + userMessage('write a function'), + assistantMessage({ messageId: 'msg-1', outputTokens: 150 }), + ]) + + const source = { path: eventsPath, project: 'myproject', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('copilot') + expect(call.model).toBe('gpt-4.1') + expect(call.outputTokens).toBe(150) + expect(call.inputTokens).toBe(0) + expect(call.userMessage).toBe('write a function') + expect(call.sessionId).toBe('sess-001') + expect(call.bashCommands).toEqual([]) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('tracks model changes mid-session', async () => { + const eventsPath = await createSessionDir('sess-002', [ + modelChange('gpt-5-mini'), + userMessage('first'), + assistantMessage({ messageId: 'msg-1', outputTokens: 50, timestamp: '2026-04-15T10:00:10Z' }), + modelChange('gpt-4.1', 'gpt-5-mini'), + userMessage('second'), + assistantMessage({ messageId: 'msg-2', outputTokens: 80, timestamp: '2026-04-15T10:01:00Z' }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls[0]!.model).toBe('gpt-5-mini') + expect(calls[1]!.model).toBe('gpt-4.1') + }) + + it('extracts tool names from toolRequests', async () => { + const eventsPath = await createSessionDir('sess-003', [ + modelChange('gpt-4.1'), + userMessage('run tests'), + assistantMessage({ messageId: 'msg-1', outputTokens: 60, tools: ['bash', 'read_file', 'write_file'] }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Edit']) + }) + + it('skips assistant messages with zero outputTokens', async () => { + const eventsPath = await createSessionDir('sess-004', [ + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-empty', outputTokens: 0 }), + assistantMessage({ messageId: 'msg-real', outputTokens: 42 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(42) + }) + + it('deduplicates messages across parser runs', async () => { + const eventsPath = await createSessionDir('sess-005', [ + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-dup', outputTokens: 100 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const seenKeys = new Set() + + const calls1: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('returns empty for missing file', async () => { + const source = { path: '/nonexistent/events.jsonl', project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) +}) + +describe('copilot provider - discoverSessions', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers sessions from directory', async () => { + await createSessionDir('sess-disc-001', [modelChange('gpt-4.1')]) + await createSessionDir('sess-disc-002', [modelChange('gpt-4.1')]) + + const provider = createCopilotProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'copilot')).toBe(true) + expect(sessions.every(s => s.path.endsWith('events.jsonl'))).toBe(true) + }) + + it('reads project name from workspace.yaml cwd', async () => { + await createSessionDir('sess-disc-003', [modelChange('gpt-4.1')], '/home/user/myapp') + + const provider = createCopilotProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + }) + + it('returns empty when directory does not exist', async () => { + const provider = createCopilotProvider('/nonexistent/path') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips entries without events.jsonl', async () => { + const emptyDir = join(tmpDir, 'empty-session') + await mkdir(emptyDir, { recursive: true }) + + const provider = createCopilotProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) + +describe('copilot provider - metadata', () => { + it('has correct name and displayName', () => { + expect(copilot.name).toBe('copilot') + expect(copilot.displayName).toBe('Copilot') + }) + + it('normalizes tool display names', () => { + expect(copilot.toolDisplayName('bash')).toBe('Bash') + expect(copilot.toolDisplayName('read_file')).toBe('Read') + expect(copilot.toolDisplayName('write_file')).toBe('Edit') + expect(copilot.toolDisplayName('web_search')).toBe('WebSearch') + expect(copilot.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('normalizes model display names', () => { + expect(copilot.modelDisplayName('gpt-4.1')).toBe('GPT-4.1') + expect(copilot.modelDisplayName('gpt-4.1-mini')).toBe('GPT-4.1 Mini') + expect(copilot.modelDisplayName('gpt-4.1-nano')).toBe('GPT-4.1 Nano') + expect(copilot.modelDisplayName('gpt-5-mini')).toBe('GPT-5 Mini') + expect(copilot.modelDisplayName('o3')).toBe('o3') + expect(copilot.modelDisplayName('o4-mini')).toBe('o4-mini') + expect(copilot.modelDisplayName('unknown-model-xyz')).toBe('unknown-model-xyz') + }) +}) From 04611938196d731d72a7f1daf87d44231c9e8fb7 Mon Sep 17 00:00:00 2001 From: Ninym Date: Thu, 16 Apr 2026 15:48:24 +0200 Subject: [PATCH 3/4] fix: handle empty firstTimestamp in TopSessions, add dashboard tests - TopSessions: show '----------' placeholder when session.firstTimestamp is empty (Copilot provider yields '' when timestamp is missing) - DashboardContent: add comment explaining undefined days tri-state - tests/dashboard.test.ts: cover top-5 selection (fewer than 5 sessions, tied costs, descending sort) and avg/s returning '-' for zero sessions Authored-by: AgentSeal --- src/dashboard.tsx | 5 +- tests/dashboard.test.ts | 112 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/dashboard.test.ts diff --git a/src/dashboard.tsx b/src/dashboard.tsx index ed64d4f..55c055b 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -396,7 +396,9 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)} {top.map((session, i) => { - const date = session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) + const date = session.firstTimestamp + ? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) + : '----------' const label = `${date} ${shortProject(session.projectName)}` return ( @@ -557,6 +559,7 @@ function DashboardContent({ projects, period, columns, activeProvider }: { proje } const pw = wide ? halfWidth : dashWidth + // undefined = no cutoff (show all days); 31 for month/30-day ranges; 14 for shorter periods const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) return ( diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts new file mode 100644 index 0000000..a29ae38 --- /dev/null +++ b/tests/dashboard.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' + +import { formatCost } from '../src/format.js' +import type { ProjectSummary, SessionSummary } from '../src/types.js' + +const EMPTY_CATEGORY_BREAKDOWN = { + 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 }, +} satisfies SessionSummary['categoryBreakdown'] + +function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary { + return { + sessionId: id, + project: 'test-project', + firstTimestamp: timestamp, + lastTimestamp: timestamp, + totalCostUSD: cost, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN }, + } +} + +function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary { + return { + project: name, + projectPath: name, + sessions, + totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0), + totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0), + } +} + +// Logic replicated from TopSessions component +function getTopSessions(projects: ProjectSummary[], n = 5) { + const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project }))) + return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n) +} + +// Logic replicated from ProjectBreakdown component +function avgCostLabel(project: ProjectSummary): string { + return project.sessions.length > 0 + ? formatCost(project.totalCostUSD / project.sessions.length) + : '-' +} + +describe('TopSessions - top-5 selection', () => { + it('returns all sessions when fewer than 5 exist', () => { + const project = makeProject('proj', [ + makeSession('s1', 1.0), + makeSession('s2', 2.0), + ]) + const top = getTopSessions([project]) + expect(top).toHaveLength(2) + expect(top[0].totalCostUSD).toBe(2.0) + expect(top[1].totalCostUSD).toBe(1.0) + }) + + it('returns exactly 5 when more than 5 sessions exist', () => { + const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) => + makeSession(`s${i}`, cost) + ) + const project = makeProject('proj', sessions) + const top = getTopSessions([project]) + expect(top).toHaveLength(5) + expect(top[0].totalCostUSD).toBe(3.0) + expect(top[4].totalCostUSD).toBe(0.5) + }) + + it('is stable on tied costs - preserves input order for equal values', () => { + const sessions = [ + makeSession('s1', 1.0), + makeSession('s2', 1.0), + makeSession('s3', 1.0), + ] + const project = makeProject('proj', sessions) + const top = getTopSessions([project]) + expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3']) + }) +}) + +describe('avg/s in ProjectBreakdown', () => { + it('returns dash for a project with no sessions', () => { + const project = makeProject('proj', []) + expect(avgCostLabel(project)).toBe('-') + }) + + it('returns formatted average cost across sessions', () => { + const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)] + const project = makeProject('proj', sessions) + expect(avgCostLabel(project)).toBe(formatCost(3.0)) + }) +}) From e7633d932be5a92d5129c224581c5ecccec04bb0 Mon Sep 17 00:00:00 2001 From: Teo Delis Date: Thu, 16 Apr 2026 19:30:08 +0300 Subject: [PATCH 4/4] fix: address PR review feedback on Copilot provider - init currentModel to '' and skip assistant messages before first session.model_change to avoid silent misattribution - add comment documenting why inputTokens is always 0 - fix delete_file tool mapping ('Edit' -> 'Delete') - add schema doc comment to ToolRequest optional fields - remove catch-all from CopilotEvent union for proper TS narrowing - add tests: pre-model-change skip, workspace.yaml quote/comment strip, longest-prefix model display name match --- src/providers/copilot.ts | 23 +++++++++++++++++---- tests/providers/copilot.test.ts | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 365c1d4..9cb7983 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -27,7 +27,7 @@ const toolNameMap: Record = { write_file: 'Edit', edit_file: 'Edit', create_file: 'Write', - delete_file: 'Edit', + delete_file: 'Delete', search_files: 'Grep', find_files: 'Glob', list_directory: 'LS', @@ -39,6 +39,7 @@ const toolNameMap: Record = { // Pre-sorted by key length descending so longer/more-specific keys match first const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) +// Fields marked optional document the on-disk schema; they are not read by the parser type ToolRequest = { name?: string toolCallId?: string @@ -71,6 +72,16 @@ function getCopilotSessionStateDir(override?: string): string { return override ?? join(homedir(), '.copilot', 'session-state') } +function parseCwd(yaml: string): string | null { + const match = yaml.match(/^cwd:\s*(.+)$/m) + if (!match?.[1]) return null + const raw = match[1] + .replace(/\s*#.*$/, '') // strip trailing comment + .replace(/^['"]|['"]$/g, '') // strip surrounding quotes + .trim() + return raw || null +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { @@ -83,7 +94,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const sessionId = basename(dirname(source.path)) const lines = content.split('\n').filter(l => l.trim()) - let currentModel = 'gpt-4.1' + let currentModel = '' let pendingUserMessage = '' for (const line of lines) { @@ -107,6 +118,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (event.type === 'assistant.message') { const { messageId, outputTokens, toolRequests = [] } = event.data if (outputTokens === 0) continue + // Skip if no model has been identified yet - avoids silent misattribution + if (!currentModel) continue const dedupKey = `copilot:${sessionId}:${messageId}` if (seenKeys.has(dedupKey)) continue @@ -117,6 +130,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars .filter(Boolean) .map(n => toolNameMap[n] ?? n) + // Copilot only logs outputTokens; inputTokens are not available in session logs. + // Cost will be lower than actual API cost. const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0) yield { @@ -164,8 +179,8 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise { for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) expect(calls).toHaveLength(0) }) + + it('skips assistant messages before the first model_change event', async () => { + const eventsPath = await createSessionDir('sess-no-model', [ + assistantMessage({ messageId: 'msg-early', outputTokens: 50 }), + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-after', outputTokens: 80 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.messageId).toBeUndefined() + expect(calls[0]!.outputTokens).toBe(80) + expect(calls[0]!.model).toBe('gpt-4.1') + }) }) describe('copilot provider - discoverSessions', () => { @@ -175,6 +192,19 @@ describe('copilot provider - discoverSessions', () => { expect(sessions[0]!.project).toBe('myapp') }) + it('strips quotes and trailing comments from workspace.yaml cwd', async () => { + const sessionDir = join(tmpDir, 'sess-quoted') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'workspace.yaml'), 'cwd: "/home/user/myapp" # project root\n') + await writeFile(join(sessionDir, 'events.jsonl'), '\n') + + const provider = createCopilotProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + }) + it('returns empty when directory does not exist', async () => { const provider = createCopilotProvider('/nonexistent/path') const sessions = await provider.discoverSessions() @@ -214,4 +244,10 @@ describe('copilot provider - metadata', () => { expect(copilot.modelDisplayName('o4-mini')).toBe('o4-mini') expect(copilot.modelDisplayName('unknown-model-xyz')).toBe('unknown-model-xyz') }) + + it('longest-prefix match wins for versioned model IDs', () => { + // gpt-5-mini-2026-01-01 must match gpt-5-mini, not gpt-5 + expect(copilot.modelDisplayName('gpt-5-mini-2026-01-01')).toBe('GPT-5 Mini') + expect(copilot.modelDisplayName('gpt-4.1-mini-2026-01-01')).toBe('GPT-4.1 Mini') + }) })