Add OpenCode usage analytics (#1986)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-15 16:33:15 -07:00
committed by GitHub
co-authored by Orca
parent 03a12b82bf
commit bce3ef1776
27 changed files with 3142 additions and 53 deletions
+8
View File
@@ -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)
+43
View File
@@ -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)
)
}
@@ -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,
+4
View File
@@ -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()
+269
View File
@@ -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)
})
})
+931
View File
@@ -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<string[]> {
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<OpenCodeUsageProcessedDatabase> {
const dbStat = await stat(dbPath)
return {
path: dbPath,
mtimeMs: dbStat.mtimeMs,
size: dbStat.size
}
}
async function yieldToEventLoop(): Promise<void> {
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<string, unknown> | null {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>
}
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<string, unknown>)
: 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string> {
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<OpenCodeUsageAttributedEvent | null> {
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<string, OpenCodeUsageSession>()
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
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<string, OpenCodeUsageSession>): 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<string, OpenCodeUsageSession>,
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<string, OpenCodeUsageDailyAggregate>,
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<OpenCodeUsagePersistedDatabase> {
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<string, OpenCodeUsageSession>()
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
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<string, { path: string; worktreeId: string; displayName: string }[]>
): 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
}
+309
View File
@@ -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<OpenCodeUsagePersistedState>): 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> = {}): 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> = {}
): 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)
})
})
+464
View File
@@ -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, UsageWorktreeRef[]>): 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<void> | 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<OpenCodeUsageScanState> {
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<OpenCodeUsageScanState> {
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<void> {
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<OpenCodeUsageSummary> {
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<string, number>()
const byProject = new Map<string, number>()
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<OpenCodeUsageDailyPoint[]> {
await this.refresh(false)
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
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<OpenCodeUsageBreakdownRow[]> {
await this.refresh(false)
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
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<OpenCodeUsageSessionRow[]> {
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<string> {
const repos = this.store.getRepos()
return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
}
}
+124
View File
@@ -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
}
+35
View File
@@ -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<CodexUsageSessionRow[]>
}
export type OpenCodeUsageApi = {
getScanState: () => Promise<OpenCodeUsageScanState>
setEnabled: (args: { enabled: boolean }) => Promise<OpenCodeUsageScanState>
refresh: (args?: { force?: boolean }) => Promise<OpenCodeUsageScanState>
getSummary: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
}) => Promise<OpenCodeUsageSummary>
getDaily: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
}) => Promise<OpenCodeUsageDailyPoint[]>
getBreakdown: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
kind: OpenCodeUsageBreakdownKind
}) => Promise<OpenCodeUsageBreakdownRow[]>
getRecentSessions: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
limit?: number
}) => Promise<OpenCodeUsageSessionRow[]>
}
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<DirEntry[]>
readFile: (args: {
+16
View File
@@ -2418,6 +2418,22 @@ const api = {
ipcRenderer.invoke('codexUsage:getRecentSessions', args)
},
openCodeUsage: {
getScanState: (): Promise<unknown> => ipcRenderer.invoke('openCodeUsage:getScanState'),
setEnabled: (args: { enabled: boolean }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:setEnabled', args),
refresh: (args?: { force?: boolean }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:refresh', args),
getSummary: (args: { scope: string; range: string }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getSummary', args),
getDaily: (args: { scope: string; range: string }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getDaily', args),
getBreakdown: (args: { scope: string; range: string; kind: string }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getBreakdown', args),
getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getRecentSessions', args)
},
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeStatus> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
@@ -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
},
@@ -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<OpenCodeUsageRange, string> = {
'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 (
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">OpenCode Usage Tracking</h3>
<p className="text-sm text-muted-foreground">
Reads local OpenCode usage logs to show token, model, and session stats.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
aria-label="Enable OpenCode usage analytics"
onClick={() => void setOpenCodeUsageEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>
)
}
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
return (
<ClaudeUsageLoadingState
title="OpenCode Usage Tracking"
summaryCardCount={6}
summaryGridClassName="md:grid-cols-3"
/>
)
}
const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData
return (
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">OpenCode Usage Tracking</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(scanState.lastScanCompletedAt)}
{scanState.lastScanError ? ` • Last scan error: ${scanState.lastScanError}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<DropdownMenu>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label="OpenCode usage options">
<SlidersHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Filters
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Scope</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => void setOpenCodeUsageScope(value as OpenCodeUsageScope)}
>
{SCOPE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Range</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={range}
onValueChange={(value) => void setOpenCodeUsageRange(value as OpenCodeUsageRange)}
>
{RANGE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
{RANGE_LABELS[option]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshOpenCodeUsage()}
disabled={scanState.isScanning}
aria-label="Refresh OpenCode usage"
>
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
aria-label="Enable OpenCode usage analytics"
onClick={() => void setOpenCodeUsageEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{SCOPE_OPTIONS.find((option) => option.value === scope)?.label} {RANGE_LABELS[range]}
</p>
</div>
{!hasAnyData ? (
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
No local OpenCode usage found yet for this scope.
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-3">
<StatCard
label="Input tokens"
value={formatTokens(summary?.inputTokens ?? 0)}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label="Output tokens"
value={formatTokens(summary?.outputTokens ?? 0)}
icon={<Activity className="size-4" />}
/>
<StatCard
label="Cached input"
value={formatTokens(summary?.cachedInputTokens ?? 0)}
icon={<DatabaseZap className="size-4" />}
/>
<StatCard
label="Reasoning output"
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
icon={<Brain className="size-4" />}
/>
<StatCard
label="Sessions / Events"
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
icon={<FolderKanban className="size-4" />}
/>
<StatCard
label="Recorded cost"
value={formatCost(summary?.estimatedCostUsd ?? null)}
icon={<Coins className="size-4" />}
/>
</div>
<p className="px-1 text-xs text-muted-foreground">
Cost comes from the local OpenCode database when the assistant message recorded one.
</p>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">By model</h4>
<p className="text-xs text-muted-foreground">
Top model: {summary?.topModel ?? 'n/a'}
</p>
</div>
<div className="space-y-3">
{modelBreakdown.slice(0, 5).map((row) => (
<div key={row.key} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-foreground">{row.label}</span>
<span className="shrink-0 text-muted-foreground">
{formatTokens(row.totalTokens)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} sessions {row.events} events
{row.estimatedCostUsd !== null
? `${formatCost(row.estimatedCostUsd)}`
: ''}
</div>
</div>
))}
</div>
</section>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">By project</h4>
<p className="text-xs text-muted-foreground">
Top project: {summary?.topProject ?? 'n/a'}
</p>
</div>
<div className="space-y-3">
{projectBreakdown.slice(0, 5).map((row) => (
<div key={row.key} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-foreground">{row.label}</span>
<span className="shrink-0 text-muted-foreground">
{formatTokens(row.totalTokens)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} sessions {row.events} events
</div>
</div>
))}
</div>
</section>
</div>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">Recent sessions</h4>
<p className="text-xs text-muted-foreground">
Most recent local OpenCode sessions in this scope.
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">Last active</th>
<th className="px-2 py-2 font-medium">Project</th>
<th className="px-2 py-2 font-medium">Model</th>
<th className="px-2 py-2 font-medium">Events</th>
<th className="px-2 py-2 font-medium">Input</th>
<th className="px-2 py-2 font-medium">Output</th>
<th className="px-2 py-2 font-medium">Total</th>
</tr>
</thead>
<tbody>
{recentSessions.map((row) => (
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
<td className="px-2 py-2 text-muted-foreground">
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">{row.model ?? 'Unknown'}</td>
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.inputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.outputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.totalTokens)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
)
}
+67 -25
View File
@@ -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 <BarChart3 className="size-3.5 text-muted-foreground" />
}
return <AgentIcon agent={tab} size={14} />
}
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<UsageTab>('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 {
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Usage Analytics</h3>
<div
role="group"
aria-label="Usage analytics provider"
className="inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground"
>
{(['overview', 'claude', 'codex'] as const).map((tab) => (
<button
key={tab}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-pressed={activeUsageTab === tab}
onClick={() => setActiveUsageTab(tab)}
className={cn(
'inline-flex h-8 items-center justify-center rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all',
activeUsageTab === tab
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30'
: 'text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground'
)}
variant="outline"
size="sm"
data-testid="usage-provider-select"
aria-label={`Usage analytics provider: ${activeUsageOption.label}`}
className="min-w-36 justify-between"
>
{tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
</button>
))}
</div>
<span className="flex min-w-0 items-center gap-2">
<UsageAnalyticsOptionIcon tab={activeUsageOption.id} />
<span className="truncate">{activeUsageOption.label}</span>
</span>
<ChevronDown className="ml-1 size-3.5 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{USAGE_ANALYTICS_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setActiveUsageTab(option.id)}>
<span className="flex min-w-0 items-center gap-2">
<UsageAnalyticsOptionIcon tab={option.id} />
<span className="truncate">{option.label}</span>
</span>
<Check
className={`ml-auto size-3.5 ${
activeUsageTab === option.id ? 'opacity-100' : 'opacity-0'
}`}
aria-hidden
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the
@@ -138,8 +178,10 @@ export function StatsPane(): React.JSX.Element {
<UsageOverviewPane />
) : activeUsageTab === 'claude' ? (
<ClaudeUsagePane />
) : (
) : activeUsageTab === 'codex' ? (
<CodexUsagePane />
) : (
<OpenCodeUsagePane />
)}
</div>
</div>
@@ -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({
<div>
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
<p className="text-xs text-muted-foreground">
Recent combined Claude and Codex token activity.
Recent combined Claude, Codex, and OpenCode token activity.
</p>
</div>
{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 {
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
Enable Codex
</Button>
<Button variant="outline" size="sm" onClick={() => void enableOpenCodeUsage()}>
Enable OpenCode
</Button>
</div>
</div>
</div>
@@ -364,8 +392,8 @@ export function UsageOverviewPane(): React.JSX.Element {
{!overview.hasAnyData ? (
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
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.
</div>
) : (
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
@@ -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()
}
}}
/>
@@ -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)
@@ -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<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
@@ -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
+2
View File
@@ -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<AppState>()((...a) => ({
...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createOpenCodeUsageSlice(...a),
...createBrowserSlice(...a),
...createRateLimitSlice(...a),
...createSshSlice(...a),
@@ -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),
@@ -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<void>
setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise<void>
setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise<void>
fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise<void>
enableOpenCodeUsage: () => Promise<void>
refreshOpenCodeUsage: () => Promise<void>
}
export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUsageSlice> = (
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<OpenCodeUsageSummary>,
window.api.openCodeUsage.getDaily({
scope: openCodeUsageScope,
range: openCodeUsageRange
}) as Promise<OpenCodeUsageDailyPoint[]>,
window.api.openCodeUsage.getBreakdown({
scope: openCodeUsageScope,
range: openCodeUsageRange,
kind: 'model'
}) as Promise<OpenCodeUsageBreakdownRow[]>,
window.api.openCodeUsage.getBreakdown({
scope: openCodeUsageScope,
range: openCodeUsageRange,
kind: 'project'
}) as Promise<OpenCodeUsageBreakdownRow[]>,
window.api.openCodeUsage.getRecentSessions({
scope: openCodeUsageScope,
range: openCodeUsageRange,
limit: 10
}) as Promise<OpenCodeUsageSessionRow[]>
])
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 })
}
})
@@ -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),
@@ -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),
@@ -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),
@@ -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([])
}
}
+2
View File
@@ -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 &
+64
View File
@@ -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
}
+17 -5
View File
@@ -19,17 +19,29 @@ test.describe('usage overview', () => {
.poll(async () => getStoreState<string>(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'
)
})
})