From bce3ef17762a09ee799fc3286846ec769d3f71c3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 19:33:15 -0400 Subject: [PATCH] Add OpenCode usage analytics (#1986) Co-authored-by: Orca --- src/main/index.ts | 8 + src/main/ipc/opencode-usage.ts | 43 + src/main/ipc/register-core-handlers.test.ts | 12 + src/main/ipc/register-core-handlers.ts | 4 + src/main/opencode-usage/scanner.test.ts | 269 +++++ src/main/opencode-usage/scanner.ts | 931 ++++++++++++++++++ src/main/opencode-usage/store.test.ts | 309 ++++++ src/main/opencode-usage/store.ts | 464 +++++++++ src/main/opencode-usage/types.ts | 124 +++ src/preload/api-types.ts | 35 + src/preload/index.ts | 16 + .../src/components/settings/Settings.tsx | 2 +- .../components/stats/OpenCodeUsagePane.tsx | 371 +++++++ .../src/components/stats/StatsPane.tsx | 92 +- .../components/stats/UsageOverviewPane.tsx | 44 +- .../stats/usage-overview-model.test.ts | 79 +- .../components/stats/usage-overview-model.ts | 76 +- src/renderer/src/store/index.ts | 2 + .../src/store/slices/diffComments.test.ts | 18 + .../src/store/slices/opencode-usage.ts | 154 +++ .../slices/store-session-cascades.test.ts | 18 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 18 + .../store/slices/terminals-hydration.test.ts | 16 + src/renderer/src/store/types.ts | 2 + src/shared/opencode-usage-types.ts | 64 ++ tests/e2e/usage-overview.spec.ts | 22 +- 27 files changed, 3142 insertions(+), 53 deletions(-) create mode 100644 src/main/ipc/opencode-usage.ts create mode 100644 src/main/opencode-usage/scanner.test.ts create mode 100644 src/main/opencode-usage/scanner.ts create mode 100644 src/main/opencode-usage/store.test.ts create mode 100644 src/main/opencode-usage/store.ts create mode 100644 src/main/opencode-usage/types.ts create mode 100644 src/renderer/src/components/stats/OpenCodeUsagePane.tsx create mode 100644 src/renderer/src/store/slices/opencode-usage.ts create mode 100644 src/shared/opencode-usage-types.ts diff --git a/src/main/index.ts b/src/main/index.ts index af7af86db47..65170134d19 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { Store, initDataPath } from './persistence' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' +import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -78,6 +79,7 @@ let store: Store | null = null let stats: StatsCollector | null = null let claudeUsage: ClaudeUsageStore | null = null let codexUsage: CodexUsageStore | null = null +let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null let claudeAccounts: ClaudeAccountService | null = null @@ -190,6 +192,7 @@ if (hasSingleInstanceLock) { initStatsPath() initClaudeUsagePath() initCodexUsagePath() + initOpenCodeUsagePath() enableMainProcessGpuFeatures() } @@ -209,6 +212,9 @@ function openMainWindow(): BrowserWindow { if (!codexUsage) { throw new Error('Codex usage store must be initialized before opening the main window') } + if (!openCodeUsage) { + throw new Error('OpenCode usage store must be initialized before opening the main window') + } if (!rateLimits) { throw new Error('Rate limit service must be initialized before opening the main window') } @@ -272,6 +278,7 @@ function openMainWindow(): BrowserWindow { stats, claudeUsage, codexUsage, + openCodeUsage, codexAccounts, claudeAccounts, rateLimits, @@ -664,6 +671,7 @@ app.whenReady().then(async () => { stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) + openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts new file mode 100644 index 00000000000..180403f370b --- /dev/null +++ b/src/main/ipc/opencode-usage.ts @@ -0,0 +1,43 @@ +import { ipcMain } from 'electron' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../shared/opencode-usage-types' + +export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { + ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) + ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => + openCodeUsage.setEnabled(args.enabled) + ) + ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => + openCodeUsage.refresh(args?.force ?? false) + ) + ipcMain.handle( + 'openCodeUsage:getSummary', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getSummary(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getDaily', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getBreakdown', + ( + _event, + args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + } + ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + 'openCodeUsage:getRecentSessions', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => + openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index f81db6fddf8..82e700e592b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -7,6 +7,7 @@ const { registerPreflightHandlersMock, registerClaudeUsageHandlersMock, registerCodexUsageHandlersMock, + registerOpenCodeUsageHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -48,6 +49,7 @@ const { registerPreflightHandlersMock: vi.fn(), registerClaudeUsageHandlersMock: vi.fn(), registerCodexUsageHandlersMock: vi.fn(), + registerOpenCodeUsageHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -110,6 +112,10 @@ vi.mock('./codex-usage', () => ({ registerCodexUsageHandlers: registerCodexUsageHandlersMock })) +vi.mock('./opencode-usage', () => ({ + registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +})) + vi.mock('./github', () => ({ registerGitHubHandlers: registerGitHubHandlersMock })) @@ -245,6 +251,7 @@ describe('registerCoreHandlers', () => { registerPreflightHandlersMock.mockReset() registerClaudeUsageHandlersMock.mockReset() registerCodexUsageHandlersMock.mockReset() + registerOpenCodeUsageHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -288,6 +295,7 @@ describe('registerCoreHandlers', () => { const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } + const openCodeUsage = { marker: 'openCodeUsage' } const codexAccounts = { marker: 'codexAccounts' } const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } @@ -298,6 +306,7 @@ describe('registerCoreHandlers', () => { stats as never, claudeUsage as never, codexUsage as never, + openCodeUsage as never, codexAccounts as never, claudeAccounts as never, rateLimits as never @@ -305,6 +314,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) + expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() expect(registerPetHandlersMock).toHaveBeenCalled() @@ -348,6 +358,7 @@ describe('registerCoreHandlers', () => { const stats2 = { marker: 'stats2' } const claudeUsage2 = { marker: 'claudeUsage2' } const codexUsage2 = { marker: 'codexUsage2' } + const openCodeUsage2 = { marker: 'openCodeUsage2' } const codexAccounts2 = { marker: 'codexAccounts2' } const claudeAccounts2 = { marker: 'claudeAccounts2' } const rateLimits2 = { marker: 'rateLimits2' } @@ -358,6 +369,7 @@ describe('registerCoreHandlers', () => { stats2 as never, claudeUsage2 as never, codexUsage2 as never, + openCodeUsage2 as never, codexAccounts2 as never, claudeAccounts2 as never, rateLimits2 as never, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f3e5bcce61a..8d2c6a50642 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -11,6 +11,7 @@ import { import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' +import { registerOpenCodeUsageHandlers } from './opencode-usage' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' @@ -50,6 +51,7 @@ import { } from '../window/attach-main-window-services' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' import type { RateLimitService } from '../rate-limits/service' import type { CodexAccountService } from '../codex-accounts/service' import type { ClaudeAccountService } from '../claude-accounts/service' @@ -64,6 +66,7 @@ export function registerCoreHandlers( stats: StatsCollector, claudeUsage: ClaudeUsageStore, codexUsage: CodexUsageStore, + openCodeUsage: OpenCodeUsageStore, codexAccounts: CodexAccountService, claudeAccounts: ClaudeAccountService, rateLimits: RateLimitService, @@ -88,6 +91,7 @@ export function registerCoreHandlers( registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) + registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() registerAgentTrustHandlers() diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts new file mode 100644 index 00000000000..625effd4ce6 --- /dev/null +++ b/src/main/opencode-usage/scanner.test.ts @@ -0,0 +1,269 @@ +import Database from 'better-sqlite3' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner' + +const WORKTREE = '/workspace/repo' + +let tempDirs: string[] = [] + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function worktrees() { + return [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: WORKTREE, + displayName: 'Repo', + canonicalPath: WORKTREE + } + ] +} + +describe('parseOpenCodeUsageRow', () => { + it('reads assistant message tokens, cost, model, cwd, and timestamp', () => { + const parsed = parseOpenCodeUsageRow({ + id: 'message-1', + session_id: 'session-1', + time_created: 1_777_777_700_000, + time_updated: null, + directory: null, + title: null, + worktree: null, + session_model: null, + data: JSON.stringify({ + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + path: { cwd: `${WORKTREE}/packages/app` }, + cost: 0.0123, + tokens: { + input: 1000, + output: 250, + reasoning: 100, + total: 1350, + cache: { read: 400, write: 25 } + }, + time: { + completed: 1_777_777_800_000 + } + }) + }) + + expect(parsed).toEqual({ + sessionId: 'session-1', + timestamp: new Date(1_777_777_800_000).toISOString(), + cwd: `${WORKTREE}/packages/app`, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.0123, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + }) + }) +}) + +describe('parseOpenCodeUsageDatabase', () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] + }) + + it('uses materialized session token totals when the OpenCode DB has them', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + `) + db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE) + db.prepare( + `INSERT INTO session ( + id, project_id, directory, title, model, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + time_created, time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'session-1', + 'project-1', + `${WORKTREE}/packages/app`, + 'Build feature', + JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }), + 0.06, + 1000, + 500, + 100, + 250, + 1_777_777_700_000, + 1_777_777_800_000 + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]).toMatchObject({ + sessionId: 'session-1', + primaryModel: 'anthropic/claude-sonnet-4-5', + primaryProjectLabel: 'Repo', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 250, + totalOutputTokens: 500, + totalReasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + expect(parsed.dailyAggregates).toEqual([ + expect.objectContaining({ + projectLabel: 'Repo', + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + ]) + }) + + it('supports session_message tables without a type column', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + cost: 0.03, + tokens: { + input: 800, + output: 200, + reasoning: 50, + cache: { read: 100, write: 0 } + } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]).toMatchObject({ + primaryModel: 'openai/gpt-5.5', + primaryProjectLabel: 'Repo', + totalTokens: 1050, + estimatedCostUsd: 0.03 + }) + }) + + it('prefers session_message rows over legacy message rows to avoid double counting', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + type TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + 'session-message-1', + 'session-1', + 'assistant', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } } + }) + ) + db.prepare( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'legacy-message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + role: 'assistant', + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]?.totalTokens).toBe(120) + expect(parsed.sessions[0]?.eventCount).toBe(1) + }) +}) diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts new file mode 100644 index 00000000000..3066d707e7b --- /dev/null +++ b/src/main/opencode-usage/scanner.ts @@ -0,0 +1,931 @@ +/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ +import Database from 'better-sqlite3' +import { existsSync } from 'fs' +import { readdir, realpath, stat } from 'fs/promises' +import { homedir } from 'os' +import { isAbsolute, join, posix, win32 } from 'path' +import type { Repo } from '../../shared/types' +import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import type { + OpenCodeUsageAttributedEvent, + OpenCodeUsageDailyAggregate, + OpenCodeUsageLocationBreakdown, + OpenCodeUsageLocationModelBreakdown, + OpenCodeUsageModelBreakdown, + OpenCodeUsageParsedEvent, + OpenCodeUsagePersistedDatabase, + OpenCodeUsageProcessedDatabase, + OpenCodeUsageSession +} from './types' + +export type OpenCodeUsageWorktreeRef = { + repoId: string + worktreeId: string + path: string + displayName: string +} + +type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + directory: string | null + title: string | null + worktree: string | null + session_model: string | null + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + +const YIELD_EVERY_DATABASES = 2 + +function ensureNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function normalizeComparablePath(pathValue: string, platform = process.platform): string { + const normalized = pathValue.replace(/\\/g, '/') + return platform === 'win32' || looksLikeWindowsPath(pathValue) + ? normalized.toLowerCase() + : normalized +} + +function normalizeFsPath(pathValue: string, platform = process.platform): string { + if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { + return win32.normalize(win32.resolve(pathValue)) + } + return posix.normalize(posix.resolve(pathValue)) +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function getXdgDataHome(): string { + if (process.env.XDG_DATA_HOME?.trim()) { + return process.env.XDG_DATA_HOME.trim() + } + if (process.platform === 'win32') { + return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local') + } + return join(homedir(), '.local', 'share') +} + +function getOpenCodeDataDirectory(): string { + return join(getXdgDataHome(), 'opencode') +} + +function getOpenCodeDatabasePathFromEnv(): string | null { + const raw = process.env.OPENCODE_DB?.trim() + if (!raw) { + return null + } + if (raw === ':memory:') { + return null + } + return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw) +} + +export async function listOpenCodeDatabases(): Promise { + const envPath = getOpenCodeDatabasePathFromEnv() + if (envPath) { + return existsSync(envPath) ? [envPath] : [] + } + + try { + const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .sort() + } catch { + return [] + } +} + +export async function getProcessedDatabaseInfo( + dbPath: string +): Promise { + const dbStat = await stat(dbPath) + return { + path: dbPath, + mtimeMs: dbStat.mtimeMs, + size: dbStat.size + } +} + +async function yieldToEventLoop(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function tableExists(db: Database.Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" + : "json_extract(data, '$.tokens.input') IS NOT NULL" + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + FROM session s + ${projectJoin} + WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + ORDER BY s.time_created, s.id` + ) + .all() as OpenCodeSessionUsageRow[] + + return rows.map((row) => ({ + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: 0 + } + } + }) + })) +} + +function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { + if (!tableExists(db, 'session')) { + return [] + } + + // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading + // one aggregate row per session is faster than parsing every message blob. + if (getSessionUsageRowCount(db) > 0) { + return selectSessionUsageRows(db) + } + + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + return db + .prepare( + `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM session_message sm + JOIN session s ON s.id = sm.session_id + ${projectJoin} + WHERE ${assistantPredicate} + ORDER BY sm.time_created, sm.id` + ) + .all() as OpenCodeUsageRow[] + } + + if (!tableExists(db, 'message')) { + return [] + } + + return db + .prepare( + `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM message m + JOIN session s ON s.id = m.session_id + ${projectJoin} + WHERE json_extract(m.data, '$.role') = 'assistant' + ORDER BY m.time_created, m.id` + ) + .all() as OpenCodeUsageRow[] +} + +function parseJsonObject(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== 'string') { + return null + } + try { + const parsed = JSON.parse(value) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function extractString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function extractModelLabel(data: Record, sessionModel: unknown): string | null { + const directModel = extractString(data.modelID) ?? extractString(data.modelId) + const directProvider = extractString(data.providerID) ?? extractString(data.providerId) + if (directModel) { + return directProvider ? `${directProvider}/${directModel}` : directModel + } + + const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel) + if (!modelObject) { + return null + } + const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id) + const providerID = extractString(modelObject.providerID) + if (!modelID) { + return null + } + return providerID ? `${providerID}/${modelID}` : modelID +} + +function extractCwd(data: Record, row: OpenCodeUsageRow): string | null { + const pathData = parseJsonObject(data.path) + return ( + extractString(pathData?.cwd) ?? + extractString(row.directory) ?? + extractString(row.worktree) ?? + null + ) +} + +function normalizeMillis(value: unknown): number | null { + const numeric = ensureNumber(value) + if (numeric <= 0) { + return null + } + return numeric < 10_000_000_000 ? numeric * 1000 : numeric +} + +function extractTimestamp(data: Record, row: OpenCodeUsageRow): string | null { + const timeData = parseJsonObject(data.time) + const millis = + normalizeMillis(timeData?.completed) ?? + normalizeMillis(timeData?.created) ?? + normalizeMillis(row.time_updated) ?? + normalizeMillis(row.time_created) + return millis ? new Date(millis).toISOString() : null +} + +export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null { + const data = parseJsonObject(row.data) + if (!data) { + return null + } + + const tokens = parseJsonObject(data.tokens) + if (!tokens) { + return null + } + const cache = parseJsonObject(tokens.cache) + const inputTokens = ensureNumber(tokens.input) + const outputTokens = ensureNumber(tokens.output) + const reasoningOutputTokens = ensureNumber(tokens.reasoning) + const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens) + const totalTokens = + ensureNumber(tokens.total) > 0 + ? ensureNumber(tokens.total) + : inputTokens + outputTokens + reasoningOutputTokens + + if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) { + return null + } + + const timestamp = extractTimestamp(data, row) + if (!timestamp) { + return null + } + + return { + sessionId: row.session_id, + timestamp, + cwd: extractCwd(data, row), + model: extractModelLabel(data, row.session_model), + estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + } +} + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +function localDayFromTimestamp(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function isContainingPath(candidatePath: string, targetPath: string): boolean { + const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) + const relativePath = useWin32 + ? win32.relative(candidatePath, targetPath) + : posix.relative(candidatePath, targetPath) + if (!relativePath) { + return true + } + const isAbsoluteRelative = useWin32 + ? win32.isAbsolute(relativePath) + : posix.isAbsolute(relativePath) + return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' +} + +async function buildWorktreesWithCanonicalPaths( + worktrees: OpenCodeUsageWorktreeRef[] +): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { + const canonicalized = await Promise.all( + worktrees.map(async (worktree) => ({ + ...worktree, + canonicalPath: await canonicalizePath(worktree.path) + })) + ) + return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) +} + +async function canonicalizePath(pathValue: string): Promise { + try { + return normalizeFsPath(await realpath(pathValue)) + } catch { + return normalizeFsPath(pathValue) + } +} + +function findContainingWorktree( + cwd: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): OpenCodeUsageWorktreeRef | null { + const normalizedCwd = normalizeFsPath(cwd) + for (const worktree of worktrees) { + if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + } + return null +} + +export async function attributeOpenCodeUsageEvent( + event: OpenCodeUsageParsedEvent, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const day = localDayFromTimestamp(event.timestamp) + if (!day) { + return null + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(event.cwd) + + if (event.cwd) { + const worktree = findContainingWorktree(event.cwd, worktrees) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktree.worktreeId}` + projectLabel = worktree.displayName + } else { + projectKey = `cwd:${normalizeComparablePath(event.cwd)}` + } + } + + return { + ...event, + day, + projectKey, + projectLabel, + repoId, + worktreeId + } +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession { + return { + sessionId: event.sessionId, + firstTimestamp: event.timestamp, + lastTimestamp: event.timestamp, + primaryModel: event.model, + hasMixedModels: false, + primaryProjectLabel: event.projectLabel, + hasMixedLocations: false, + primaryWorktreeId: event.worktreeId, + primaryRepoId: event.repoId, + eventCount: 0, + totalInputTokens: 0, + totalCachedInputTokens: 0, + totalOutputTokens: 0, + totalReasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + locationBreakdown: [], + modelBreakdown: [], + locationModelBreakdown: [] + } +} + +function createEmptyDailyAggregate( + event: OpenCodeUsageAttributedEvent +): OpenCodeUsageDailyAggregate { + return { + day: event.day, + model: event.model, + projectKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } +} + +function mergeLocationBreakdown( + target: OpenCodeUsageLocationBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeModelBreakdown( + target: OpenCodeUsageModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const key = event.model ?? 'unknown' + const existing = target.find((entry) => entry.modelKey === key) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + modelKey: key, + modelLabel: event.model ?? 'Unknown model', + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeLocationModelBreakdown( + target: OpenCodeUsageLocationModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const modelKey = event.model ?? 'unknown' + const existing = + target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ?? + null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + modelKey, + modelLabel: event.model ?? 'Unknown model', + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} { + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const event of events) { + const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) + if (!sessionsById.has(event.sessionId)) { + sessionsById.set(event.sessionId, session) + } + if (event.timestamp < session.firstTimestamp) { + session.firstTimestamp = event.timestamp + } + if (event.timestamp >= session.lastTimestamp) { + session.lastTimestamp = event.timestamp + } + session.eventCount++ + session.totalInputTokens += event.inputTokens + session.totalCachedInputTokens += event.cachedInputTokens + session.totalOutputTokens += event.outputTokens + session.totalReasoningOutputTokens += event.reasoningOutputTokens + session.totalTokens += event.totalTokens + session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd) + mergeLocationBreakdown(session.locationBreakdown, event) + mergeModelBreakdown(session.modelBreakdown, event) + mergeLocationModelBreakdown(session.locationModelBreakdown, event) + + const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::') + const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) + if (!dailyByKey.has(dailyKey)) { + dailyByKey.set(dailyKey, daily) + } + daily.eventCount++ + daily.inputTokens += event.inputTokens + daily.cachedInputTokens += event.cachedInputTokens + daily.outputTokens += event.outputTokens + daily.reasoningOutputTokens += event.reasoningOutputTokens + daily.totalTokens += event.totalTokens + daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd) + } + + return { + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +function finalizeSessions(sessionsById: Map): OpenCodeUsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + const primaryLocation = session.locationBreakdown[0] ?? null + const primaryModel = session.modelBreakdown[0] ?? null + session.primaryProjectLabel = + session.locationBreakdown.length <= 1 + ? (primaryLocation?.projectLabel ?? 'Unknown location') + : 'Multiple locations' + session.hasMixedLocations = session.locationBreakdown.length > 1 + session.primaryWorktreeId = primaryLocation?.worktreeId ?? null + session.primaryRepoId = primaryLocation?.repoId ?? null + session.primaryModel = + session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' + session.hasMixedModels = session.modelBreakdown.length > 1 + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) +} + +function mergeSessions( + target: Map, + sessions: OpenCodeUsageSession[] +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, structuredClone(session)) + continue + } + + existing.firstTimestamp = + session.firstTimestamp < existing.firstTimestamp + ? session.firstTimestamp + : existing.firstTimestamp + existing.lastTimestamp = + session.lastTimestamp > existing.lastTimestamp + ? session.lastTimestamp + : existing.lastTimestamp + existing.eventCount += session.eventCount + existing.totalInputTokens += session.totalInputTokens + existing.totalCachedInputTokens += session.totalCachedInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens + existing.totalTokens += session.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd) + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.eventCount += location.eventCount + existingLocation.inputTokens += location.inputTokens + existingLocation.cachedInputTokens += location.cachedInputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.reasoningOutputTokens += location.reasoningOutputTokens + existingLocation.totalTokens += location.totalTokens + existingLocation.estimatedCostUsd = addCost( + existingLocation.estimatedCostUsd, + location.estimatedCostUsd + ) + } else { + existing.locationBreakdown.push({ ...location }) + } + } + + for (const model of session.modelBreakdown) { + const existingModel = + existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null + if (existingModel) { + existingModel.eventCount += model.eventCount + existingModel.inputTokens += model.inputTokens + existingModel.cachedInputTokens += model.cachedInputTokens + existingModel.outputTokens += model.outputTokens + existingModel.reasoningOutputTokens += model.reasoningOutputTokens + existingModel.totalTokens += model.totalTokens + existingModel.estimatedCostUsd = addCost( + existingModel.estimatedCostUsd, + model.estimatedCostUsd + ) + } else { + existing.modelBreakdown.push({ ...model }) + } + } + + for (const locationModel of session.locationModelBreakdown) { + const existingLocationModel = + existing.locationModelBreakdown.find( + (entry) => + entry.locationKey === locationModel.locationKey && + entry.modelKey === locationModel.modelKey + ) ?? null + if (existingLocationModel) { + existingLocationModel.eventCount += locationModel.eventCount + existingLocationModel.inputTokens += locationModel.inputTokens + existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens + existingLocationModel.outputTokens += locationModel.outputTokens + existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens + existingLocationModel.totalTokens += locationModel.totalTokens + existingLocationModel.estimatedCostUsd = addCost( + existingLocationModel.estimatedCostUsd, + locationModel.estimatedCostUsd + ) + } else { + existing.locationModelBreakdown.push({ ...locationModel }) + } + } + } +} + +function mergeDailyAggregates( + target: Map, + dailyAggregates: OpenCodeUsageDailyAggregate[] +): void { + for (const aggregate of dailyAggregates) { + const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.eventCount += aggregate.eventCount + existing.inputTokens += aggregate.inputTokens + existing.cachedInputTokens += aggregate.cachedInputTokens + existing.outputTokens += aggregate.outputTokens + existing.reasoningOutputTokens += aggregate.reasoningOutputTokens + existing.totalTokens += aggregate.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd) + } +} + +export async function parseOpenCodeUsageDatabase( + dbPath: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const processedDatabase = await getProcessedDatabaseInfo(dbPath) + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + db.pragma('query_only = ON') + const events: OpenCodeUsageAttributedEvent[] = [] + for (const row of selectUsageRows(db)) { + const parsed = parseOpenCodeUsageRow(row) + if (!parsed) { + continue + } + const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) + if (attributed) { + events.push(attributed) + } + } + return { + ...processedDatabase, + ...aggregateOpenCodeUsage(events) + } + } finally { + db.close() + } +} + +export async function scanOpenCodeUsageDatabases( + worktrees: OpenCodeUsageWorktreeRef[], + previousProcessedDatabases: OpenCodeUsagePersistedDatabase[] +): Promise<{ + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +}> { + const dbPaths = await listOpenCodeDatabases() + const previousByPath = new Map( + previousProcessedDatabases.map((database) => [database.path, database]) + ) + const processedDatabases: OpenCodeUsagePersistedDatabase[] = [] + const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const [index, dbPath] of dbPaths.entries()) { + const databaseInfo = await getProcessedDatabaseInfo(dbPath) + const previous = previousByPath.get(dbPath) + const canReuse = + previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size + const processed = canReuse + ? previous + : await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths) + + processedDatabases.push(processed) + mergeSessions(sessionsById, processed.sessions) + mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + + if ((index + 1) % YIELD_EVERY_DATABASES === 0) { + await yieldToEventLoop() + } + } + + return { + processedDatabases, + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +export function createWorktreeRefs( + repos: Repo[], + worktreesByRepo: Map +): OpenCodeUsageWorktreeRef[] { + const refs: OpenCodeUsageWorktreeRef[] = [] + for (const repo of repos) { + for (const worktree of worktreesByRepo.get(repo.id) ?? []) { + refs.push({ + repoId: repo.id, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + } + } + return refs +} diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts new file mode 100644 index 00000000000..bca001390c2 --- /dev/null +++ b/src/main/opencode-usage/store.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OpenCodeUsageDailyAggregate, + OpenCodeUsagePersistedState, + OpenCodeUsageSession +} from './types' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { OpenCodeUsageStore, normalizePersistedState } from './store' + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +function createStoreWithState(state: Partial): OpenCodeUsageStore { + const store = new OpenCodeUsageStore({ + getRepos: () => [], + getWorktreeMeta: () => undefined + } as never) + + ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { + ...getDefaultState(), + ...state + } + + return store +} + +function makeSession(overrides: Partial = {}): OpenCodeUsageSession { + const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo' + const repoId = overrides.primaryRepoId ?? 'repo-1' + const projectLabel = overrides.primaryProjectLabel ?? 'Repo' + const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5' + return { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T10:00:00.000Z', + lastTimestamp: '2026-04-09T10:10:00.000Z', + primaryModel: model, + hasMixedModels: false, + primaryProjectLabel: projectLabel, + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: repoId, + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + locationBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel, + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + modelBreakdown: [ + { + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + locationModelBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + ...overrides + } +} + +function makeDaily( + overrides: Partial = {} +): OpenCodeUsageDailyAggregate { + const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo' + return { + day: '2026-04-09', + model: 'anthropic/claude-sonnet-4-5', + projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel: worktreeId ? 'Repo' : 'outside/repo', + repoId: worktreeId ? 'repo-1' : null, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + ...overrides + } +} + +describe('OpenCodeUsageStore', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ + primaryProjectLabel: 'outside/repo', + primaryWorktreeId: null, + primaryRepoId: null + }) + ], + dailyAggregates: [ + makeDaily({ + projectKey: 'cwd:/outside/repo', + projectLabel: 'outside/repo', + repoId: null, + worktreeId: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.hasAnyOpenCodeData).toBe(false) + expect(summary.sessions).toBe(0) + expect(summary.events).toBe(0) + }) + + it('uses recorded OpenCode costs and token totals without model pricing inference', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ sessionId: 'session-1' }), + makeSession({ + sessionId: 'session-2', + primaryModel: 'openai/gpt-5.5', + totalTokens: 2000, + estimatedCostUsd: null, + modelBreakdown: [ + { + modelKey: 'openai/gpt-5.5', + modelLabel: 'openai/gpt-5.5', + eventCount: 1, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + } + ] + }) + ], + dailyAggregates: [ + makeDaily(), + makeDaily({ + model: 'openai/gpt-5.5', + eventCount: 2, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + const daily = await store.getDaily('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary).toMatchObject({ + sessions: 2, + events: 3, + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350, + estimatedCostUsd: 0.05, + topModel: 'openai/gpt-5.5', + topProject: 'Repo', + hasAnyOpenCodeData: true + }) + expect(daily).toEqual([ + { + day: '2026-04-09', + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350 + } + ]) + expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({ + sessions: 1, + estimatedCostUsd: null + }) + }) + + it('returns recent sessions with OpenCode event and token fields', async () => { + const store = createStoreWithState({ + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + + const sessions = await store.getRecentSessions('orca', '30d', 5) + + expect(sessions).toEqual([ + { + sessionId: 'session-1', + lastActiveAt: '2026-04-09T10:10:00.000Z', + durationMinutes: 10, + projectLabel: 'Repo', + model: 'anthropic/claude-sonnet-4-5', + events: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + } + ]) + }) + + it('normalizes persisted OpenCode state by schema version', () => { + expect( + normalizePersistedState({ + ...getDefaultState(), + schemaVersion: 0, + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + } + ], + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + ).toEqual(getDefaultState()) + + expect( + normalizePersistedState({ + ...getDefaultState(), + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [], + dailyAggregates: [] + } + ] + }).processedDatabases + ).toHaveLength(1) + }) +}) diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts new file mode 100644 index 00000000000..1fd7d50830c --- /dev/null +++ b/src/main/opencode-usage/store.ts @@ -0,0 +1,464 @@ +/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +import { app } from 'electron' +import { dirname, join } from 'path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../shared/opencode-usage-types' +import type { Store } from '../persistence' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' +import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' +import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' + +const SCHEMA_VERSION = 1 +const STALE_MS = 5 * 60_000 + +let _openCodeUsageFile: string | null = null + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: SCHEMA_VERSION, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +export function normalizePersistedState( + state: OpenCodeUsagePersistedState +): OpenCodeUsagePersistedState { + if (state.schemaVersion !== SCHEMA_VERSION) { + return getDefaultState() + } + return { + ...state, + processedDatabases: (state.processedDatabases ?? []).map((database) => ({ + ...database, + sessions: (database.sessions ?? []).map(normalizeSessionCost), + dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost) + })), + sessions: state.sessions.map(normalizeSessionCost), + dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost) + } +} + +export function initOpenCodeUsagePath(): void { + _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') +} + +function getOpenCodeUsageFile(): string { + if (!_openCodeUsageFile) { + _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json') + } + return _openCodeUsageFile +} + +function getRangeCutoff(range: OpenCodeUsageRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const now = new Date() + now.setHours(0, 0, 0, 0) + now.setDate(now.getDate() - (days - 1)) + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function getLocalDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function getWorktreeFingerprint(worktreesByRepo: Map): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function normalizeDailyAggregateCost( + entry: OpenCodeUsageDailyAggregate +): OpenCodeUsageDailyAggregate { + return { + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + } +} + +function normalizeSessionCost( + session: OpenCodeUsagePersistedState['sessions'][number] +): OpenCodeUsagePersistedState['sessions'][number] { + return { + ...session, + estimatedCostUsd: session.estimatedCostUsd ?? null, + locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })) + } +} + +export class OpenCodeUsageStore { + private state: OpenCodeUsagePersistedState + private readonly store: Store + private scanPromise: Promise | null = null + + constructor(store: Store) { + this.store = store + this.state = this.load() + } + + private load(): OpenCodeUsagePersistedState { + try { + const usageFile = getOpenCodeUsageFile() + if (!existsSync(usageFile)) { + return getDefaultState() + } + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState + return normalizePersistedState({ + ...getDefaultState(), + ...parsed, + scanState: { + ...getDefaultState().scanState, + ...parsed.scanState + } + }) + } catch (error) { + console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) + return getDefaultState() + } + } + + private writeToDisk(): void { + const usageFile = getOpenCodeUsageFile() + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + renameSync(tmpFile, usageFile) + } + + async setEnabled(enabled: boolean): Promise { + this.state.scanState.enabled = enabled + this.writeToDisk() + return this.getScanState() + } + + getScanState(): OpenCodeUsageScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } + } + + async refresh(force = false): Promise { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) + const result = await scanOpenCodeUsageDatabases( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state.processedDatabases + : [] + ) + this.state.processedDatabases = result.processedDatabases + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + this.writeToDisk() + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + async getSummary( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + let estimatedCostUsd: number | null = null + const byModel = new Map() + const byProject = new Map() + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) + byModel.set( + row.model ?? 'Unknown model', + (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens + ) + byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) + } + + const topModel = + [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd, + topModel, + topProject, + hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 + } + } + + async getDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const byDay = new Map() + for (const row of this.getFilteredDaily(scope, range)) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.cachedInputTokens += row.cachedInputTokens + existing.outputTokens += row.outputTokens + existing.reasoningOutputTokens += row.reasoningOutputTokens + existing.totalTokens += row.totalTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + } + + async getBreakdown( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + kind: OpenCodeUsageBreakdownKind + ): Promise { + await this.refresh(false) + const rows = new Map() + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } + existing.events += daily.eventCount + existing.inputTokens += daily.inputTokens + existing.cachedInputTokens += daily.cachedInputTokens + existing.outputTokens += daily.outputTokens + existing.reasoningOutputTokens += daily.reasoningOutputTokens + existing.totalTokens += daily.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) + rows.set(key, existing) + } + + if (kind === 'model') { + for (const session of filteredSessions) { + for (const entry of session.modelBreakdown) { + const row = rows.get(entry.modelKey) + if (row) { + row.sessions++ + } + } + } + } else { + for (const session of filteredSessions) { + for (const entry of session.locationBreakdown) { + const row = rows.get(entry.locationKey) + if (row) { + row.sessions++ + } + } + } + } + + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) + } + + async getRecentSessions( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + limit = 10 + ): Promise { + await this.refresh(false) + return this.getFilteredSessions(scope, range) + .slice(0, limit) + .map( + (session): OpenCodeUsageSessionRow => ({ + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes: Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - + new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ), + projectLabel: session.primaryProjectLabel, + model: session.primaryModel, + events: session.eventCount, + inputTokens: session.totalInputTokens, + cachedInputTokens: session.totalCachedInputTokens, + outputTokens: session.totalOutputTokens, + reasoningOutputTokens: session.totalReasoningOutputTokens, + totalTokens: session.totalTokens + }) + ) + } + + private getFilteredDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): OpenCodeUsageDailyAggregate[] { + const cutoff = getRangeCutoff(range) + return this.state.dailyAggregates.filter((row) => { + if (scope === 'orca' && !row.worktreeId) { + return false + } + if (cutoff && row.day < cutoff) { + return false + } + return true + }) + } + + private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { + const cutoff = getRangeCutoff(range) + return this.state.sessions.filter((session) => { + if (scope === 'orca' && !session.primaryWorktreeId) { + return false + } + if (cutoff) { + const day = getLocalDay(session.lastTimestamp) + if (!day || day < cutoff) { + return false + } + } + return true + }) + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/opencode-usage/types.ts b/src/main/opencode-usage/types.ts new file mode 100644 index 00000000000..ed7f3d2143c --- /dev/null +++ b/src/main/opencode-usage/types.ts @@ -0,0 +1,124 @@ +export type OpenCodeUsageProcessedDatabase = { + path: string + mtimeMs: number + size: number +} + +export type OpenCodeUsageLocationBreakdown = { + locationKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageModelBreakdown = { + modelKey: string + modelLabel: string + estimatedCostUsd: number | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageLocationModelBreakdown = { + locationKey: string + modelKey: string + modelLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSession = { + sessionId: string + firstTimestamp: string + lastTimestamp: string + primaryModel: string | null + hasMixedModels: boolean + primaryProjectLabel: string + hasMixedLocations: boolean + primaryWorktreeId: string | null + primaryRepoId: string | null + eventCount: number + totalInputTokens: number + totalCachedInputTokens: number + totalOutputTokens: number + totalReasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + locationBreakdown: OpenCodeUsageLocationBreakdown[] + modelBreakdown: OpenCodeUsageModelBreakdown[] + locationModelBreakdown: OpenCodeUsageLocationModelBreakdown[] +} + +export type OpenCodeUsageDailyAggregate = { + day: string + model: string | null + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} + +export type OpenCodeUsagePersistedState = { + schemaVersion: number + worktreeFingerprint: string | null + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] + scanState: { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + } +} + +export type OpenCodeUsageParsedEvent = { + sessionId: string + timestamp: string + model: string | null + cwd: string | null + estimatedCostUsd: number | null + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageAttributedEvent = OpenCodeUsageParsedEvent & { + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index df7aaa2b59c..0fdce54eb6b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -211,6 +211,16 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../shared/codex-usage-types' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../shared/opencode-usage-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { @@ -449,6 +459,30 @@ export type CodexUsageApi = { }) => Promise } +export type OpenCodeUsageApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSummary: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getDaily: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getBreakdown: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + }) => Promise + getRecentSessions: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + limit?: number + }) => Promise +} + export type AppApi = { /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; * in packaged builds this is file:// resources. Renderer appends filenames. */ @@ -1162,6 +1196,7 @@ export type PreloadApi = { memory: MemoryApi claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi + openCodeUsage: OpenCodeUsageApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index e7330fd1b9c..f6348d1b7b6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2418,6 +2418,22 @@ const api = { ipcRenderer.invoke('codexUsage:getRecentSessions', args) }, + openCodeUsage: { + getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), + setEnabled: (args: { enabled: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:setEnabled', args), + refresh: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:refresh', args), + getSummary: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getSummary', args), + getDaily: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getDaily', args), + getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getBreakdown', args), + getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => + ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2938353c754..050eb72bb44 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -570,7 +570,7 @@ function Settings(): React.JSX.Element { { id: 'stats', title: 'Stats & Usage', - description: 'Orca stats plus Claude and Codex usage analytics.', + description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.', icon: BarChart3, searchEntries: STATS_PANE_SEARCH_ENTRIES }, diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx new file mode 100644 index 00000000000..a8718b62b1c --- /dev/null +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -0,0 +1,371 @@ +import { useEffect } from 'react' +import { + Activity, + Brain, + Coins, + DatabaseZap, + FolderKanban, + RefreshCw, + SlidersHorizontal, + Sparkles +} from 'lucide-react' +import type { + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../../../shared/opencode-usage-types' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { StatCard } from './StatCard' + +const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all'] +const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ + { value: 'orca', label: 'Orca worktrees only' }, + { value: 'all', label: 'All local OpenCode usage' } +] +const RANGE_LABELS: Record = { + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', + all: 'All time' +} + +function formatTokens(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k` + } + return value.toLocaleString() +} + +function formatCost(value: number | null): string { + if (value === null) { + return 'n/a' + } + return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}` +} + +function formatUpdatedAt(timestamp: number | null): string { + if (!timestamp) { + return 'Not scanned yet' + } + return `Updated ${new Date(timestamp).toLocaleString()}` +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +export function OpenCodeUsagePane(): React.JSX.Element { + const scanState = useAppStore((state) => state.openCodeUsageScanState) + const summary = useAppStore((state) => state.openCodeUsageSummary) + const daily = useAppStore((state) => state.openCodeUsageDaily) + const modelBreakdown = useAppStore((state) => state.openCodeUsageModelBreakdown) + const projectBreakdown = useAppStore((state) => state.openCodeUsageProjectBreakdown) + const recentSessions = useAppStore((state) => state.openCodeUsageRecentSessions) + const scope = useAppStore((state) => state.openCodeUsageScope) + const range = useAppStore((state) => state.openCodeUsageRange) + const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage) + const setOpenCodeUsageEnabled = useAppStore((state) => state.setOpenCodeUsageEnabled) + const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) + const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope) + const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange) + + useEffect(() => { + void fetchOpenCodeUsage() + }, [fetchOpenCodeUsage]) + + if (!scanState?.enabled) { + return ( +
+
+
+

OpenCode Usage Tracking

+

+ Reads local OpenCode usage logs to show token, model, and session stats. +

+
+ +
+
+ ) + } + + if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { + return ( + + ) + } + + const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData + + return ( +
+
+
+

OpenCode Usage Tracking

+

+ {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError ? ` • Last scan error: ${scanState.lastScanError}` : ''} +

+
+
+ + + + + + + + + + Filters + + + + + Scope + void setOpenCodeUsageScope(value as OpenCodeUsageScope)} + > + {SCOPE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Range + void setOpenCodeUsageRange(value as OpenCodeUsageRange)} + > + {RANGE_OPTIONS.map((option) => ( + + {RANGE_LABELS[option]} + + ))} + + + + + + + + + + Refresh + + + + +
+
+ +
+

+ {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} +

+
+ + {!hasAnyData ? ( +
+ No local OpenCode usage found yet for this scope. +
+ ) : ( + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ Cost comes from the local OpenCode database when the assistant message recorded one. +

+ + + +
+
+
+

By model

+

+ Top model: {summary?.topModel ?? 'n/a'} +

+
+
+ {modelBreakdown.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions} sessions • {row.events} events + {row.estimatedCostUsd !== null + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''} +
+
+ ))} +
+
+ +
+
+

By project

+

+ Top project: {summary?.topProject ?? 'n/a'} +

+
+
+ {projectBreakdown.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions} sessions • {row.events} events +
+
+ ))} +
+
+
+ +
+
+

Recent sessions

+

+ Most recent local OpenCode sessions in this scope. +

+
+
+ + + + + + + + + + + + + + {recentSessions.map((row) => ( + + + + + + + + + + ))} + +
Last activeProjectModelEventsInputOutputTotal
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel}{row.model ?? 'Unknown'}{row.events} + {formatTokens(row.inputTokens)} + + {formatTokens(row.outputTokens)} + + {formatTokens(row.totalTokens)} +
+
+
+ + )} +
+ ) +} diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 266b18a0e9a..80e9e2862f4 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -1,18 +1,26 @@ import { useEffect, useState } from 'react' -import { Bot, Clock, GitPullRequest } from 'lucide-react' +import { BarChart3, Bot, Check, ChevronDown, Clock, GitPullRequest } from 'lucide-react' import { useAppStore } from '../../store' import { StatCard } from './StatCard' import { ClaudeUsagePane } from './ClaudeUsagePane' import { CodexUsagePane } from './CodexUsagePane' +import { OpenCodeUsagePane } from './OpenCodeUsagePane' import { UsageOverviewPane } from './UsageOverviewPane' import type { SettingsSearchEntry } from '../settings/settings-search' -import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { AgentIcon } from '@/lib/agent-catalog' export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Stats & Usage', description: - 'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.', + 'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.', keywords: [ 'stats', 'usage', @@ -23,6 +31,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'tracking', 'claude', 'codex', + 'opencode', 'tokens', 'cache' ] @@ -57,10 +66,29 @@ function formatTrackingSince(timestamp: number | null): string { return `Tracking since ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } +type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' + +const USAGE_ANALYTICS_OPTIONS = [ + { id: 'overview', label: 'Overview' }, + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'opencode', label: 'OpenCode' } +] as const satisfies readonly { id: UsageTab; label: string }[] + +function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element { + if (tab === 'overview') { + return + } + return +} + export function StatsPane(): React.JSX.Element { const summary = useAppStore((s) => s.statsSummary) const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary) - const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview') + const [activeUsageTab, setActiveUsageTab] = useState('overview') + const activeUsageOption = + USAGE_ANALYTICS_OPTIONS.find((option) => option.id === activeUsageTab) ?? + USAGE_ANALYTICS_OPTIONS[0] useEffect(() => { void fetchStatsSummary() @@ -106,28 +134,40 @@ export function StatsPane(): React.JSX.Element {

Usage Analytics

-
- {(['overview', 'claude', 'codex'] as const).map((tab) => ( - - ))} -
+ + + {activeUsageOption.label} + + + + + + {USAGE_ANALYTICS_OPTIONS.map((option) => ( + setActiveUsageTab(option.id)}> + + + {option.label} + + + + ))} + +
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the @@ -138,8 +178,10 @@ export function StatsPane(): React.JSX.Element { ) : activeUsageTab === 'claude' ? ( - ) : ( + ) : activeUsageTab === 'codex' ? ( + ) : ( + )}
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index 202fcdf09d4..f0f7c820975 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the overview keeps its small display components beside the + provider fetch wiring so the combined usage surface stays easy to audit. */ import { useEffect, useMemo } from 'react' import { Activity, @@ -143,7 +145,7 @@ function DailyIntensityGrid({

Daily intensity

- Recent combined Claude and Codex token activity. + Recent combined Claude, Codex, and OpenCode token activity.

{bestDay && bestDay.totalTokens > 0 ? ( @@ -248,17 +250,24 @@ export function UsageOverviewPane(): React.JSX.Element { const codexScanState = useAppStore((state) => state.codexUsageScanState) const codexSummary = useAppStore((state) => state.codexUsageSummary) const codexDaily = useAppStore((state) => state.codexUsageDaily) + const openCodeScanState = useAppStore((state) => state.openCodeUsageScanState) + const openCodeSummary = useAppStore((state) => state.openCodeUsageSummary) + const openCodeDaily = useAppStore((state) => state.openCodeUsageDaily) const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage) const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage) + const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage) const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage) const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage) + const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage) const enableCodexUsage = useAppStore((state) => state.enableCodexUsage) + const enableOpenCodeUsage = useAppStore((state) => state.enableOpenCodeUsage) useEffect(() => { void fetchClaudeUsage() void fetchCodexUsage() - }, [fetchClaudeUsage, fetchCodexUsage]) + void fetchOpenCodeUsage() + }, [fetchClaudeUsage, fetchCodexUsage, fetchOpenCodeUsage]) const overview = useMemo( () => @@ -272,9 +281,24 @@ export function UsageOverviewPane(): React.JSX.Element { scanState: codexScanState, summary: codexSummary, daily: codexDaily + }, + opencode: { + scanState: openCodeScanState, + summary: openCodeSummary, + daily: openCodeDaily } }), - [claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary] + [ + claudeDaily, + claudeScanState, + claudeSummary, + codexDaily, + codexScanState, + codexSummary, + openCodeDaily, + openCodeScanState, + openCodeSummary + ] ) const recentDays = useMemo( () => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT), @@ -285,7 +309,8 @@ export function UsageOverviewPane(): React.JSX.Element { const handleRefresh = (): void => { void Promise.all([ claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(), - codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve() + codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve(), + openCodeScanState?.enabled ? refreshOpenCodeUsage() : Promise.resolve() ]) } @@ -334,6 +359,9 @@ export function UsageOverviewPane(): React.JSX.Element { + @@ -364,8 +392,8 @@ export function UsageOverviewPane(): React.JSX.Element { {!overview.hasAnyData ? (
- No local Claude or Codex usage found yet. The overview will populate after the next - agent session writes token logs. + No local Claude, Codex, or OpenCode usage found yet. The overview will populate + after the next agent session writes token logs.
) : (
@@ -399,8 +427,10 @@ export function UsageOverviewPane(): React.JSX.Element { onEnable={() => { if (provider.id === 'claude') { void enableClaudeUsage() - } else { + } else if (provider.id === 'codex') { void enableCodexUsage() + } else { + void enableOpenCodeUsage() } }} /> diff --git a/src/renderer/src/components/stats/usage-overview-model.test.ts b/src/renderer/src/components/stats/usage-overview-model.test.ts index 490bcc1b8f4..8fa9354d898 100644 --- a/src/renderer/src/components/stats/usage-overview-model.test.ts +++ b/src/renderer/src/components/stats/usage-overview-model.test.ts @@ -9,6 +9,11 @@ import type { CodexUsageScanState, CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' import { buildUsageOverview, formatUsageCost, @@ -38,8 +43,19 @@ function enabledCodexScanState(): CodexUsageScanState { } } +function enabledOpenCodeScanState(): OpenCodeUsageScanState { + return { + enabled: true, + isScanning: false, + lastScanStartedAt: 500, + lastScanCompletedAt: 600, + lastScanError: null, + hasAnyOpenCodeData: true + } +} + describe('usage overview model', () => { - it('combines Claude and Codex totals without double-counting Codex cached input', () => { + it('combines provider totals without double-counting cached input', () => { const claudeSummary: ClaudeUsageSummary = { scope: 'orca', range: '30d', @@ -71,6 +87,21 @@ describe('usage overview model', () => { topProject: 'orca-secondary', hasAnyCodexData: true } + const openCodeSummary: OpenCodeUsageSummary = { + scope: 'orca', + range: '30d', + sessions: 1, + events: 2, + inputTokens: 1_000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1_600, + estimatedCostUsd: 0.03, + topModel: 'anthropic/claude-sonnet-4-5', + topProject: 'orca-third', + hasAnyOpenCodeData: true + } const claudeDaily: ClaudeUsageDailyPoint[] = [ { day: '2026-05-13', @@ -105,6 +136,16 @@ describe('usage overview model', () => { totalTokens: 1_200 } ] + const openCodeDaily: OpenCodeUsageDailyPoint[] = [ + { + day: '2026-05-15', + inputTokens: 1_000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1_600 + } + ] const overview = buildUsageOverview({ claude: { @@ -116,24 +157,30 @@ describe('usage overview model', () => { scanState: enabledCodexScanState(), summary: codexSummary, daily: codexDaily + }, + opencode: { + scanState: enabledOpenCodeScanState(), + summary: openCodeSummary, + daily: openCodeDaily } }) - expect(overview.totalTokens).toBe(9_200) - expect(overview.newInputTokens).toBe(2_200) - expect(overview.cacheTokens).toBe(5_300) - expect(overview.outputTokens).toBe(1_700) - expect(overview.reasoningTokens).toBe(300) - expect(overview.sessions).toBe(3) - expect(overview.activityCount).toBe(7) + expect(overview.totalTokens).toBe(10_800) + expect(overview.newInputTokens).toBe(2_950) + expect(overview.cacheTokens).toBe(5_550) + expect(overview.outputTokens).toBe(2_200) + expect(overview.reasoningTokens).toBe(400) + expect(overview.sessions).toBe(4) + expect(overview.activityCount).toBe(9) expect(overview.activeDays).toBe(3) - expect(overview.estimatedCostUsd).toBeCloseTo(0.06) - expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500) + expect(overview.estimatedCostUsd).toBeCloseTo(0.09) + expect(overview.cacheShare).toBeCloseTo(5_550 / 8_500) expect(overview.bestDay).toMatchObject({ day: '2026-05-14', totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 }) expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({ @@ -141,6 +188,11 @@ describe('usage overview model', () => { cacheTokens: 800, totalTokens: 3_200 }) + expect(overview.providers.find((provider) => provider.id === 'opencode')).toMatchObject({ + newInputTokens: 750, + cacheTokens: 250, + totalTokens: 1_600 + }) }) it('pads recent usage days with zero-token cells', () => { @@ -151,6 +203,7 @@ describe('usage overview model', () => { totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 } ], @@ -164,6 +217,7 @@ describe('usage overview model', () => { totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 }, { @@ -171,6 +225,7 @@ describe('usage overview model', () => { totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 }, { @@ -178,6 +233,7 @@ describe('usage overview model', () => { totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 } ]) @@ -186,7 +242,8 @@ describe('usage overview model', () => { it('reports disabled providers as an empty overview', () => { const overview = buildUsageOverview({ claude: { scanState: null, summary: null, daily: [] }, - codex: { scanState: null, summary: null, daily: [] } + codex: { scanState: null, summary: null, daily: [] }, + opencode: { scanState: null, summary: null, daily: [] } }) expect(overview.hasAnyEnabledProvider).toBe(false) diff --git a/src/renderer/src/components/stats/usage-overview-model.ts b/src/renderer/src/components/stats/usage-overview-model.ts index 47104aec739..ba7be85d717 100644 --- a/src/renderer/src/components/stats/usage-overview-model.ts +++ b/src/renderer/src/components/stats/usage-overview-model.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: provider normalization, totals, and heatmap aggregation share + one tested model so the overview UI cannot drift from the math. */ import type { ClaudeUsageDailyPoint, ClaudeUsageScanState, @@ -8,8 +10,13 @@ import type { CodexUsageScanState, CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' -export type UsageProviderId = 'claude' | 'codex' +export type UsageProviderId = 'claude' | 'codex' | 'opencode' export type UsageProviderOverview = { id: UsageProviderId @@ -38,6 +45,7 @@ export type UsageOverviewDailyPoint = { totalTokens: number claudeTokens: number codexTokens: number + openCodeTokens: number intensity: 0 | 1 | 2 | 3 | 4 } @@ -74,6 +82,11 @@ export type UsageOverviewInput = { summary: CodexUsageSummary | null daily: CodexUsageDailyPoint[] } + opencode: { + scanState: OpenCodeUsageScanState | null + summary: OpenCodeUsageSummary | null + daily: OpenCodeUsageDailyPoint[] + } } function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number { @@ -87,6 +100,13 @@ function getCodexNewInputTokens(summary: CodexUsageSummary | null): number { return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) } +function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number { + if (!summary) { + return 0 + } + return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) +} + function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 { if (totalTokens <= 0 || maxTokens <= 0) { return 0 @@ -169,6 +189,34 @@ function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderO } } +function createOpenCodeProvider(input: UsageOverviewInput['opencode']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => entry.totalTokens > 0) + .map((entry) => entry.day) + return { + id: 'opencode', + label: 'OpenCode', + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'events', + activityCount: summary?.events ?? 0, + totalTokens: summary?.totalTokens ?? 0, + newInputTokens: getOpenCodeNewInputTokens(summary), + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary?.cachedInputTokens ?? 0, + reasoningTokens: summary?.reasoningOutputTokens ?? 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] { const byDay = new Map>() @@ -177,7 +225,8 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[ day: entry.day, totalTokens: 0, claudeTokens: 0, - codexTokens: 0 + codexTokens: 0, + openCodeTokens: 0 } const total = getClaudeDailyTotal(entry) current.totalTokens += total @@ -190,13 +239,27 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[ day: entry.day, totalTokens: 0, claudeTokens: 0, - codexTokens: 0 + codexTokens: 0, + openCodeTokens: 0 } current.totalTokens += entry.totalTokens current.codexTokens += entry.totalTokens byDay.set(entry.day, current) } + for (const entry of input.opencode.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0 + } + current.totalTokens += entry.totalTokens + current.openCodeTokens += entry.totalTokens + byDay.set(entry.day, current) + } + const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens)) return [...byDay.values()] .sort((left, right) => left.day.localeCompare(right.day)) @@ -234,6 +297,7 @@ export function getRecentUsageDays( totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 } ) @@ -242,7 +306,11 @@ export function getRecentUsageDays( } export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel { - const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)] + const providers = [ + createClaudeProvider(input.claude), + createCodexProvider(input.codex), + createOpenCodeProvider(input.opencode) + ] const daily = buildDailyOverview(input) const bestDay = daily.length === 0 diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3337e94403a..3d91c806575 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -16,6 +16,7 @@ import { createMemorySlice } from './slices/memory' import { createWorkspaceSpaceSlice } from './slices/workspace-space' import { createClaudeUsageSlice } from './slices/claude-usage' import { createCodexUsageSlice } from './slices/codex-usage' +import { createOpenCodeUsageSlice } from './slices/opencode-usage' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' @@ -45,6 +46,7 @@ export const useAppStore = create()((...a) => ({ ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 7f7018d7231..8b19aeb58f4 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -81,6 +81,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -103,6 +119,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -131,6 +148,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/opencode-usage.ts b/src/renderer/src/store/slices/opencode-usage.ts new file mode 100644 index 00000000000..b388053bc3a --- /dev/null +++ b/src/renderer/src/store/slices/opencode-usage.ts @@ -0,0 +1,154 @@ +import type { StateCreator } from 'zustand' +import type { + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' +import type { AppState } from '../types' + +export type OpenCodeUsageSlice = { + openCodeUsageScope: OpenCodeUsageScope + openCodeUsageRange: OpenCodeUsageRange + openCodeUsageScanState: OpenCodeUsageScanState | null + openCodeUsageSummary: OpenCodeUsageSummary | null + openCodeUsageDaily: OpenCodeUsageDailyPoint[] + openCodeUsageModelBreakdown: OpenCodeUsageBreakdownRow[] + openCodeUsageProjectBreakdown: OpenCodeUsageBreakdownRow[] + openCodeUsageRecentSessions: OpenCodeUsageSessionRow[] + setOpenCodeUsageEnabled: (enabled: boolean) => Promise + setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise + setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise + fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise + enableOpenCodeUsage: () => Promise + refreshOpenCodeUsage: () => Promise +} + +export const createOpenCodeUsageSlice: StateCreator = ( + set, + get +) => ({ + openCodeUsageScope: 'orca', + openCodeUsageRange: '30d', + openCodeUsageScanState: null, + openCodeUsageSummary: null, + openCodeUsageDaily: [], + openCodeUsageModelBreakdown: [], + openCodeUsageProjectBreakdown: [], + openCodeUsageRecentSessions: [], + + setOpenCodeUsageEnabled: async (enabled) => { + try { + const nextScanState = (await window.api.openCodeUsage.setEnabled({ + enabled + })) as OpenCodeUsageScanState + set({ + openCodeUsageScanState: enabled + ? { + ...nextScanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : nextScanState, + openCodeUsageSummary: null, + openCodeUsageDaily: [], + openCodeUsageModelBreakdown: [], + openCodeUsageProjectBreakdown: [], + openCodeUsageRecentSessions: [] + }) + if (enabled) { + await get().fetchOpenCodeUsage({ forceRefresh: true }) + } + } catch (error) { + console.error('Failed to update OpenCode usage setting:', error) + } + }, + + setOpenCodeUsageScope: async (scope) => { + set({ openCodeUsageScope: scope }) + await get().fetchOpenCodeUsage() + }, + + setOpenCodeUsageRange: async (range) => { + set({ openCodeUsageRange: range }) + await get().fetchOpenCodeUsage() + }, + + fetchOpenCodeUsage: async (opts) => { + try { + const scanState = (await window.api.openCodeUsage.getScanState()) as OpenCodeUsageScanState + const currentScanState = get().openCodeUsageScanState + const shouldPreserveLoadingState = + opts?.forceRefresh === true && + currentScanState?.enabled === true && + get().openCodeUsageSummary === null + set({ + openCodeUsageScanState: shouldPreserveLoadingState + ? { + ...scanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : scanState + }) + if (!scanState.enabled) { + return + } + + const nextScanState = (await window.api.openCodeUsage.refresh({ + force: opts?.forceRefresh ?? false + })) as OpenCodeUsageScanState + const { openCodeUsageScope, openCodeUsageRange } = get() + + const [summary, daily, modelBreakdown, projectBreakdown, recentSessions] = await Promise.all([ + window.api.openCodeUsage.getSummary({ + scope: openCodeUsageScope, + range: openCodeUsageRange + }) as Promise, + window.api.openCodeUsage.getDaily({ + scope: openCodeUsageScope, + range: openCodeUsageRange + }) as Promise, + window.api.openCodeUsage.getBreakdown({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + kind: 'model' + }) as Promise, + window.api.openCodeUsage.getBreakdown({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + kind: 'project' + }) as Promise, + window.api.openCodeUsage.getRecentSessions({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + limit: 10 + }) as Promise + ]) + + set({ + openCodeUsageScanState: nextScanState, + openCodeUsageSummary: summary, + openCodeUsageDaily: daily, + openCodeUsageModelBreakdown: modelBreakdown, + openCodeUsageProjectBreakdown: projectBreakdown, + openCodeUsageRecentSessions: recentSessions + }) + } catch (error) { + console.error('Failed to fetch OpenCode usage:', error) + } + }, + + enableOpenCodeUsage: async () => { + await get().setOpenCodeUsageEnabled(true) + }, + + refreshOpenCodeUsage: async () => { + await get().fetchOpenCodeUsage({ forceRefresh: true }) + } +}) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index bff2a4ff2ae..70524a3c070 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -84,6 +84,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -106,6 +122,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -134,6 +151,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index ab8b589ce24..e154a25224c 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -24,6 +24,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -60,6 +61,7 @@ export function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index a68f34b135f..8f19e24692a 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -78,6 +78,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -100,6 +116,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -130,6 +147,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 7be0c90bb17..ace73b30b77 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -69,6 +69,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index ec246109a53..bed69db258c 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -14,6 +14,7 @@ import type { MemorySlice } from './slices/memory' import type { WorkspaceSpaceSlice } from './slices/workspace-space' import type { ClaudeUsageSlice } from './slices/claude-usage' import type { CodexUsageSlice } from './slices/codex-usage' +import type { OpenCodeUsageSlice } from './slices/opencode-usage' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' @@ -40,6 +41,7 @@ export type AppState = RepoSlice & WorkspaceSpaceSlice & ClaudeUsageSlice & CodexUsageSlice & + OpenCodeUsageSlice & BrowserSlice & RateLimitSlice & SshSlice & diff --git a/src/shared/opencode-usage-types.ts b/src/shared/opencode-usage-types.ts new file mode 100644 index 00000000000..d583fd0a931 --- /dev/null +++ b/src/shared/opencode-usage-types.ts @@ -0,0 +1,64 @@ +export type OpenCodeUsageScope = 'orca' | 'all' +export type OpenCodeUsageRange = '7d' | '30d' | '90d' | 'all' +export type OpenCodeUsageBreakdownKind = 'model' | 'project' + +export type OpenCodeUsageScanState = { + enabled: boolean + isScanning: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + hasAnyOpenCodeData: boolean +} + +export type OpenCodeUsageSummary = { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + topModel: string | null + topProject: string | null + hasAnyOpenCodeData: boolean +} + +export type OpenCodeUsageDailyPoint = { + day: string + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageBreakdownRow = { + key: string + label: string + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSessionRow = { + sessionId: string + lastActiveAt: string + durationMinutes: number + projectLabel: string + model: string | null + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} diff --git a/tests/e2e/usage-overview.spec.ts b/tests/e2e/usage-overview.spec.ts index 07559cbb535..6759a1a875c 100644 --- a/tests/e2e/usage-overview.spec.ts +++ b/tests/e2e/usage-overview.spec.ts @@ -19,17 +19,29 @@ test.describe('usage overview', () => { .poll(async () => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) .toBe('settings') await expect(orcaPage.getByRole('heading', { name: 'Usage Analytics' })).toBeVisible() - const providerTabs = orcaPage.getByRole('group', { name: 'Usage analytics provider' }) - await expect( - providerTabs.getByRole('button', { name: 'Overview', exact: true }) - ).toHaveAttribute('aria-pressed', 'true') + const providerDropdown = orcaPage.getByTestId('usage-provider-select') + await expect(providerDropdown).toHaveAttribute( + 'aria-label', + 'Usage analytics provider: Overview' + ) await expect(orcaPage.getByTestId('usage-overview-pane')).toBeVisible() await expect(orcaPage.getByRole('heading', { name: 'Usage Overview' })).toBeVisible() await expect(orcaPage.getByRole('heading', { name: 'Providers' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Enable Claude' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Enable Codex' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Enable OpenCode' })).toBeVisible() - await providerTabs.getByRole('button', { name: 'Codex', exact: true }).click() + await providerDropdown.click() + await orcaPage.getByRole('menuitem', { name: 'Codex', exact: true }).click() await expect(orcaPage.getByRole('heading', { name: 'Codex Usage Tracking' })).toBeVisible() + await expect(providerDropdown).toHaveAttribute('aria-label', 'Usage analytics provider: Codex') + + await providerDropdown.click() + await orcaPage.getByRole('menuitem', { name: 'OpenCode', exact: true }).click() + await expect(orcaPage.getByRole('heading', { name: 'OpenCode Usage Tracking' })).toBeVisible() + await expect(providerDropdown).toHaveAttribute( + 'aria-label', + 'Usage analytics provider: OpenCode' + ) }) })