@dropalltables/yacu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/yacu.js +1782 -0
- package/package.json +62 -0
- package/src/data/jsonl.ts +81 -0
- package/src/data/load.ts +52 -0
- package/src/data/pricing.test.ts +22 -0
- package/src/data/pricing.ts +49 -0
- package/src/data/sources/claude.test.ts +40 -0
- package/src/data/sources/claude.ts +118 -0
- package/src/data/sources/codex.ts +73 -0
- package/src/data/sources/cursor.test.ts +16 -0
- package/src/data/sources/cursor.ts +83 -0
- package/src/data/sources/gemini.test.ts +27 -0
- package/src/data/sources/gemini.ts +112 -0
- package/src/data/sources/grok.ts +60 -0
- package/src/data/sources/opencode.ts +53 -0
- package/src/data/types.ts +23 -0
- package/src/domain/aggregate.test.ts +62 -0
- package/src/domain/aggregate.ts +143 -0
- package/src/domain/dates.ts +24 -0
- package/src/domain/types.ts +48 -0
- package/src/index.tsx +15 -0
- package/src/tui/App.tsx +170 -0
- package/src/tui/ThemeContext.tsx +12 -0
- package/src/tui/chart.test.ts +48 -0
- package/src/tui/chart.ts +130 -0
- package/src/tui/components/Breakdown.tsx +85 -0
- package/src/tui/components/Chart.test.tsx +67 -0
- package/src/tui/components/Chart.tsx +159 -0
- package/src/tui/components/Footer.tsx +11 -0
- package/src/tui/components/Header.tsx +52 -0
- package/src/tui/components/PointerButton.tsx +40 -0
- package/src/tui/components/ScanBoot.test.tsx +30 -0
- package/src/tui/components/ScanBoot.tsx +30 -0
- package/src/tui/components/Segmented.test.tsx +32 -0
- package/src/tui/components/Segmented.tsx +59 -0
- package/src/tui/components/Summary.tsx +67 -0
- package/src/tui/components/Totals.tsx +27 -0
- package/src/tui/format.ts +44 -0
- package/src/tui/theme.ts +99 -0
- package/src/tui/usePointer.ts +11 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { basename, join } from "node:path"
|
|
3
|
+
import { localDate } from "../../domain/dates"
|
|
4
|
+
import type { UsageRecord, UsageSession } from "../../domain/types"
|
|
5
|
+
import { estimateCacheSavings, estimateCost } from "../pricing"
|
|
6
|
+
import { asObject, globFiles, numberValue, stringValue } from "../jsonl"
|
|
7
|
+
import type { SourceLoadResult } from "../types"
|
|
8
|
+
|
|
9
|
+
type ParsedConversation = {
|
|
10
|
+
sessionId: string
|
|
11
|
+
startTime: string | null
|
|
12
|
+
messages: Array<Record<string, unknown>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadGeminiUsage(): Promise<SourceLoadResult> {
|
|
16
|
+
const root = process.env.GEMINI_HOME ?? join(homedir(), ".gemini")
|
|
17
|
+
const files = [
|
|
18
|
+
...await globFiles(join(root, "tmp"), "**/chats/*.json"),
|
|
19
|
+
...await globFiles(join(root, "tmp"), "**/chats/*.jsonl"),
|
|
20
|
+
]
|
|
21
|
+
const records: UsageRecord[] = []
|
|
22
|
+
const sessions = new Map<string, UsageSession>()
|
|
23
|
+
|
|
24
|
+
for (const path of files) {
|
|
25
|
+
let parsed: ParsedConversation | null = null
|
|
26
|
+
try {
|
|
27
|
+
parsed = parseGeminiConversation(await Bun.file(path).text(), basename(path))
|
|
28
|
+
} catch {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (parsed == null) continue
|
|
32
|
+
const sessionId = `gemini:${parsed.sessionId}`
|
|
33
|
+
|
|
34
|
+
for (const message of parsed.messages) {
|
|
35
|
+
if (message.type !== "gemini") continue
|
|
36
|
+
const usage = asObject(message.tokens) ?? asObject(message.usageMetadata)
|
|
37
|
+
if (usage == null) continue
|
|
38
|
+
const cached = numberValue(usage.cached) || numberValue(usage.cachedContentTokenCount)
|
|
39
|
+
const prompt = numberValue(usage.input) || numberValue(usage.promptTokenCount)
|
|
40
|
+
const candidates = numberValue(usage.output) || numberValue(usage.candidatesTokenCount)
|
|
41
|
+
const thoughts = numberValue(usage.thoughts) || numberValue(usage.thoughtsTokenCount)
|
|
42
|
+
const tool = numberValue(usage.tool) || numberValue(usage.toolUsePromptTokenCount)
|
|
43
|
+
const tokens = {
|
|
44
|
+
inputTokens: Math.max(0, prompt - cached) + tool,
|
|
45
|
+
outputTokens: candidates + thoughts,
|
|
46
|
+
cacheCreationTokens: 0,
|
|
47
|
+
cacheReadTokens: cached,
|
|
48
|
+
}
|
|
49
|
+
if (Object.values(tokens).every((value) => value === 0)) continue
|
|
50
|
+
const model = stringValue(message.model) ?? "gemini"
|
|
51
|
+
const timestamp = stringValue(message.timestamp) ?? parsed.startTime ?? new Date().toISOString()
|
|
52
|
+
const date = localDate(timestamp)
|
|
53
|
+
records.push({
|
|
54
|
+
date,
|
|
55
|
+
source: "gemini",
|
|
56
|
+
model,
|
|
57
|
+
sessionId,
|
|
58
|
+
...tokens,
|
|
59
|
+
costUsd: estimateCost(model, tokens),
|
|
60
|
+
cacheSavingsUsd: estimateCacheSavings(model, cached),
|
|
61
|
+
})
|
|
62
|
+
sessions.set(sessionId, { id: sessionId, source: "gemini", date })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { records, sessions: [...sessions.values()], files: files.length }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseGeminiConversation(text: string, fallbackId = "session"): ParsedConversation | null {
|
|
70
|
+
const trimmed = text.trim()
|
|
71
|
+
if (trimmed === "") return null
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const full = asObject(JSON.parse(trimmed))
|
|
75
|
+
if (full != null && Array.isArray(full.messages)) {
|
|
76
|
+
return {
|
|
77
|
+
sessionId: stringValue(full.sessionId) ?? fallbackId,
|
|
78
|
+
startTime: stringValue(full.startTime),
|
|
79
|
+
messages: full.messages.flatMap((message) => {
|
|
80
|
+
const row = asObject(message)
|
|
81
|
+
return row == null ? [] : [row]
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// JSONL is parsed below.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let sessionId = fallbackId
|
|
90
|
+
let startTime: string | null = null
|
|
91
|
+
const messages: Array<Record<string, unknown>> = []
|
|
92
|
+
for (const line of trimmed.split("\n")) {
|
|
93
|
+
let row: Record<string, unknown> | null = null
|
|
94
|
+
try {
|
|
95
|
+
row = asObject(JSON.parse(line))
|
|
96
|
+
} catch {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (row == null) continue
|
|
100
|
+
const metadata = asObject(row.$set) ?? row
|
|
101
|
+
sessionId = stringValue(metadata.sessionId) ?? sessionId
|
|
102
|
+
startTime = stringValue(metadata.startTime) ?? startTime
|
|
103
|
+
const rewindTo = stringValue(row.$rewindTo)
|
|
104
|
+
if (rewindTo != null) {
|
|
105
|
+
const index = messages.findIndex((message) => message.id === rewindTo)
|
|
106
|
+
if (index >= 0) messages.splice(index + 1)
|
|
107
|
+
} else if (stringValue(row.type) != null) {
|
|
108
|
+
messages.push(row)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { sessionId, startTime, messages }
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { basename, dirname, join } from "node:path"
|
|
3
|
+
import { localDate } from "../../domain/dates"
|
|
4
|
+
import type { UsageRecord, UsageSession } from "../../domain/types"
|
|
5
|
+
import { estimateCacheSavings, estimateCost } from "../pricing"
|
|
6
|
+
import { asObject, forEachJsonLine, globFiles, numberValue, stringValue } from "../jsonl"
|
|
7
|
+
import type { SourceLoadResult } from "../types"
|
|
8
|
+
|
|
9
|
+
export async function loadGrokUsage(): Promise<SourceLoadResult> {
|
|
10
|
+
const root = process.env.GROK_HOME ?? join(homedir(), ".grok")
|
|
11
|
+
const files = await globFiles(join(root, "sessions"), "**/updates.jsonl")
|
|
12
|
+
const records: UsageRecord[] = []
|
|
13
|
+
const sessions = new Map<string, UsageSession>()
|
|
14
|
+
|
|
15
|
+
for (const path of files) {
|
|
16
|
+
const sessionId = `grok:${basename(dirname(path))}`
|
|
17
|
+
await forEachJsonLine(path, (unknownValue) => {
|
|
18
|
+
const value = asObject(unknownValue)
|
|
19
|
+
const update = asObject(value?.update)
|
|
20
|
+
const usage = asObject(update?.usage)
|
|
21
|
+
if (update?.sessionUpdate !== "turn_completed" || usage == null) return
|
|
22
|
+
const metadata = asObject(value?._meta)
|
|
23
|
+
const timestamp = stringValue(value?.timestamp) ?? (numberValue(metadata?.agentTimestampMs) || Date.now())
|
|
24
|
+
const date = localDate(timestamp)
|
|
25
|
+
const models = asObject(usage.modelUsage)
|
|
26
|
+
const entries = models == null ? [["grok-build", usage] as const] : Object.entries(models)
|
|
27
|
+
const cost = numberValue(usage.costUsdTicks) / 1_000_000_000
|
|
28
|
+
const tokenWeights = entries.map(([, entry]) => {
|
|
29
|
+
const tokens = asObject(entry)
|
|
30
|
+
return numberValue(tokens?.inputTokens) + numberValue(tokens?.outputTokens)
|
|
31
|
+
})
|
|
32
|
+
const totalWeight = tokenWeights.reduce((sum, value) => sum + value, 0)
|
|
33
|
+
|
|
34
|
+
entries.forEach(([model, entry], index) => {
|
|
35
|
+
const raw = asObject(entry)
|
|
36
|
+
const cached = numberValue(raw?.cachedReadTokens)
|
|
37
|
+
const input = Math.max(0, numberValue(raw?.inputTokens) - cached)
|
|
38
|
+
const tokens = {
|
|
39
|
+
inputTokens: input,
|
|
40
|
+
outputTokens: numberValue(raw?.outputTokens),
|
|
41
|
+
cacheCreationTokens: 0,
|
|
42
|
+
cacheReadTokens: cached,
|
|
43
|
+
}
|
|
44
|
+
const allocatedCost = totalWeight > 0 ? cost * (tokenWeights[index]! / totalWeight) : 0
|
|
45
|
+
records.push({
|
|
46
|
+
date,
|
|
47
|
+
source: "grok",
|
|
48
|
+
model,
|
|
49
|
+
sessionId,
|
|
50
|
+
...tokens,
|
|
51
|
+
costUsd: allocatedCost || estimateCost(model, tokens),
|
|
52
|
+
cacheSavingsUsd: estimateCacheSavings(model, cached),
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
sessions.set(sessionId, { id: sessionId, source: "grok", date })
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { records, sessions: [...sessions.values()], files: files.length }
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { localDate } from "../../domain/dates"
|
|
4
|
+
import type { UsageRecord, UsageSession } from "../../domain/types"
|
|
5
|
+
import { estimateCacheSavings, estimateCost } from "../pricing"
|
|
6
|
+
import { asObject, globFiles, numberValue, stringValue } from "../jsonl"
|
|
7
|
+
import type { SourceLoadResult } from "../types"
|
|
8
|
+
|
|
9
|
+
export async function loadOpenCodeUsage(): Promise<SourceLoadResult> {
|
|
10
|
+
const root = process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode")
|
|
11
|
+
const files = await globFiles(join(root, "storage", "message"), "**/*.json")
|
|
12
|
+
const seen = new Set<string>()
|
|
13
|
+
const records: UsageRecord[] = []
|
|
14
|
+
const sessions = new Map<string, UsageSession>()
|
|
15
|
+
|
|
16
|
+
for (const path of files) {
|
|
17
|
+
try {
|
|
18
|
+
const message = asObject(await Bun.file(path).json())
|
|
19
|
+
const id = stringValue(message?.id)
|
|
20
|
+
const model = stringValue(message?.modelID)
|
|
21
|
+
const tokensValue = asObject(message?.tokens)
|
|
22
|
+
if (id == null || model == null || tokensValue == null || seen.has(id)) continue
|
|
23
|
+
seen.add(id)
|
|
24
|
+
const cache = asObject(tokensValue.cache)
|
|
25
|
+
const tokens = {
|
|
26
|
+
inputTokens: numberValue(tokensValue.input),
|
|
27
|
+
outputTokens: numberValue(tokensValue.output),
|
|
28
|
+
cacheCreationTokens: numberValue(cache?.write),
|
|
29
|
+
cacheReadTokens: numberValue(cache?.read),
|
|
30
|
+
}
|
|
31
|
+
if (Object.values(tokens).every((value) => value === 0)) continue
|
|
32
|
+
const time = asObject(message?.time)
|
|
33
|
+
const date = localDate(numberValue(time?.created) || Date.now())
|
|
34
|
+
const rawSession = stringValue(message?.sessionID) ?? id
|
|
35
|
+
const sessionId = `opencode:${rawSession}`
|
|
36
|
+
const localCost = typeof message?.cost === "number" ? message.cost : null
|
|
37
|
+
records.push({
|
|
38
|
+
date,
|
|
39
|
+
source: "opencode",
|
|
40
|
+
model,
|
|
41
|
+
sessionId,
|
|
42
|
+
...tokens,
|
|
43
|
+
costUsd: localCost ?? estimateCost(model, tokens),
|
|
44
|
+
cacheSavingsUsd: estimateCacheSavings(model, tokens.cacheReadTokens),
|
|
45
|
+
})
|
|
46
|
+
sessions.set(sessionId, { id: sessionId, source: "opencode", date })
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { records, sessions: [...sessions.values()], files: files.length }
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SourceId, UsageRecord, UsageSession } from "../domain/types"
|
|
2
|
+
|
|
3
|
+
export type SourceLoadResult = {
|
|
4
|
+
records: UsageRecord[]
|
|
5
|
+
sessions: UsageSession[]
|
|
6
|
+
files: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ScanStatus = "pending" | "scanning" | "done" | "error"
|
|
10
|
+
|
|
11
|
+
export type ScanProgress = {
|
|
12
|
+
source: SourceId
|
|
13
|
+
label: string
|
|
14
|
+
status: ScanStatus
|
|
15
|
+
completed: number
|
|
16
|
+
total: number
|
|
17
|
+
files?: number
|
|
18
|
+
records?: number
|
|
19
|
+
sessions?: number
|
|
20
|
+
error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ScanProgressHandler = (progress: ScanProgress) => void
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { localDate } from "./dates"
|
|
3
|
+
import { buildDashboard } from "./aggregate"
|
|
4
|
+
import type { UsageDataset } from "./types"
|
|
5
|
+
|
|
6
|
+
const today = localDate(new Date())
|
|
7
|
+
const dataset: UsageDataset = {
|
|
8
|
+
scannedAt: new Date(),
|
|
9
|
+
errors: [],
|
|
10
|
+
sessions: [
|
|
11
|
+
{ id: "claude-1", source: "claude", date: today },
|
|
12
|
+
{ id: "codex-1", source: "codex", date: today },
|
|
13
|
+
],
|
|
14
|
+
records: [
|
|
15
|
+
{
|
|
16
|
+
date: today,
|
|
17
|
+
source: "claude",
|
|
18
|
+
model: "claude-opus-4-1",
|
|
19
|
+
sessionId: "claude-1",
|
|
20
|
+
inputTokens: 100,
|
|
21
|
+
outputTokens: 20,
|
|
22
|
+
cacheCreationTokens: 10,
|
|
23
|
+
cacheReadTokens: 70,
|
|
24
|
+
costUsd: 2,
|
|
25
|
+
cacheSavingsUsd: 0.1,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
date: today,
|
|
29
|
+
source: "codex",
|
|
30
|
+
model: "gpt-5-codex",
|
|
31
|
+
sessionId: "codex-1",
|
|
32
|
+
inputTokens: 50,
|
|
33
|
+
outputTokens: 25,
|
|
34
|
+
cacheCreationTokens: 0,
|
|
35
|
+
cacheReadTokens: 25,
|
|
36
|
+
costUsd: 1,
|
|
37
|
+
cacheSavingsUsd: 0.05,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("buildDashboard", () => {
|
|
43
|
+
test("aggregates providers, sessions, and models", () => {
|
|
44
|
+
const dashboard = buildDashboard(dataset, 1, "cost")
|
|
45
|
+
|
|
46
|
+
expect(dashboard.totals.processedTokens).toBe(300)
|
|
47
|
+
expect(dashboard.totals.costUsd).toBe(3)
|
|
48
|
+
expect(dashboard.sessions).toBe(2)
|
|
49
|
+
expect(dashboard.providers.map((provider) => provider.source)).toEqual(["codex", "claude"])
|
|
50
|
+
expect(dashboard.providers[0]?.share).toBeCloseTo(1 / 3)
|
|
51
|
+
expect(dashboard.models[0]?.label).toBe("claude-opus-4-1")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("uses token share when token mode is selected", () => {
|
|
55
|
+
const dashboard = buildDashboard(dataset, 1, "tokens")
|
|
56
|
+
|
|
57
|
+
expect(dashboard.providers[0]?.share).toBeCloseTo(1 / 3)
|
|
58
|
+
expect(dashboard.providers[1]?.share).toBeCloseTo(2 / 3)
|
|
59
|
+
expect(dashboard.series.claude.at(-1)).toBe(200)
|
|
60
|
+
expect(dashboard.series.codex.at(-1)).toBe(100)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { datesInRange } from "./dates"
|
|
2
|
+
import {
|
|
3
|
+
SOURCE_ORDER,
|
|
4
|
+
type Metric,
|
|
5
|
+
type RangeDays,
|
|
6
|
+
type SourceId,
|
|
7
|
+
type UsageDataset,
|
|
8
|
+
type UsageRecord,
|
|
9
|
+
} from "./types"
|
|
10
|
+
|
|
11
|
+
type Totals = {
|
|
12
|
+
inputTokens: number
|
|
13
|
+
outputTokens: number
|
|
14
|
+
cacheCreationTokens: number
|
|
15
|
+
cacheReadTokens: number
|
|
16
|
+
processedTokens: number
|
|
17
|
+
costUsd: number
|
|
18
|
+
cacheSavingsUsd: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ProviderStat = Totals & {
|
|
22
|
+
source: SourceId
|
|
23
|
+
sessions: number
|
|
24
|
+
share: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BreakdownRow = Totals & {
|
|
28
|
+
key: string
|
|
29
|
+
label: string
|
|
30
|
+
source?: SourceId
|
|
31
|
+
share: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Dashboard = {
|
|
35
|
+
days: string[]
|
|
36
|
+
records: UsageRecord[]
|
|
37
|
+
totals: Totals
|
|
38
|
+
sessions: number
|
|
39
|
+
providers: ProviderStat[]
|
|
40
|
+
models: BreakdownRow[]
|
|
41
|
+
daily: BreakdownRow[]
|
|
42
|
+
series: Record<SourceId, number[]>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyTotals(): Totals {
|
|
46
|
+
return {
|
|
47
|
+
inputTokens: 0,
|
|
48
|
+
outputTokens: 0,
|
|
49
|
+
cacheCreationTokens: 0,
|
|
50
|
+
cacheReadTokens: 0,
|
|
51
|
+
processedTokens: 0,
|
|
52
|
+
costUsd: 0,
|
|
53
|
+
cacheSavingsUsd: 0,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addRecord(target: Totals, record: UsageRecord): void {
|
|
58
|
+
target.inputTokens += record.inputTokens
|
|
59
|
+
target.outputTokens += record.outputTokens
|
|
60
|
+
target.cacheCreationTokens += record.cacheCreationTokens
|
|
61
|
+
target.cacheReadTokens += record.cacheReadTokens
|
|
62
|
+
target.processedTokens +=
|
|
63
|
+
record.inputTokens + record.outputTokens + record.cacheCreationTokens + record.cacheReadTokens
|
|
64
|
+
target.costUsd += record.costUsd
|
|
65
|
+
target.cacheSavingsUsd += record.cacheSavingsUsd
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function metricValue(totals: Totals, metric: Metric): number {
|
|
69
|
+
return metric === "cost" ? totals.costUsd : totals.processedTokens
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildDashboard(dataset: UsageDataset, range: RangeDays, metric: Metric): Dashboard {
|
|
73
|
+
const days = datesInRange(range)
|
|
74
|
+
const dateSet = new Set(days)
|
|
75
|
+
const records = dataset.records.filter((record) => dateSet.has(record.date))
|
|
76
|
+
const sessions = dataset.sessions.filter((session) => dateSet.has(session.date))
|
|
77
|
+
const totals = emptyTotals()
|
|
78
|
+
for (const record of records) addRecord(totals, record)
|
|
79
|
+
|
|
80
|
+
const providerMap = new Map<SourceId, Totals>()
|
|
81
|
+
const providerSessions = new Map<SourceId, Set<string>>()
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
const aggregate = providerMap.get(record.source) ?? emptyTotals()
|
|
84
|
+
addRecord(aggregate, record)
|
|
85
|
+
providerMap.set(record.source, aggregate)
|
|
86
|
+
}
|
|
87
|
+
for (const session of sessions) {
|
|
88
|
+
const ids = providerSessions.get(session.source) ?? new Set<string>()
|
|
89
|
+
ids.add(session.id)
|
|
90
|
+
providerSessions.set(session.source, ids)
|
|
91
|
+
}
|
|
92
|
+
const metricTotal = metricValue(totals, metric)
|
|
93
|
+
const providers = SOURCE_ORDER.filter((source) => providerMap.has(source)).map((source) => {
|
|
94
|
+
const values = providerMap.get(source) ?? emptyTotals()
|
|
95
|
+
return {
|
|
96
|
+
...values,
|
|
97
|
+
source,
|
|
98
|
+
sessions: providerSessions.get(source)?.size ?? 0,
|
|
99
|
+
share: metricTotal > 0 ? metricValue(values, metric) / metricTotal : 0,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const modelMap = new Map<string, BreakdownRow>()
|
|
104
|
+
for (const record of records) {
|
|
105
|
+
const key = `${record.source}\0${record.model}`
|
|
106
|
+
const row = modelMap.get(key) ?? {
|
|
107
|
+
...emptyTotals(),
|
|
108
|
+
key,
|
|
109
|
+
label: record.model,
|
|
110
|
+
source: record.source,
|
|
111
|
+
share: 0,
|
|
112
|
+
}
|
|
113
|
+
addRecord(row, record)
|
|
114
|
+
modelMap.set(key, row)
|
|
115
|
+
}
|
|
116
|
+
const models = [...modelMap.values()]
|
|
117
|
+
.map((row) => ({ ...row, share: metricTotal > 0 ? metricValue(row, metric) / metricTotal : 0 }))
|
|
118
|
+
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
|
|
119
|
+
|
|
120
|
+
const dailyMap = new Map<string, BreakdownRow>()
|
|
121
|
+
for (const day of days) {
|
|
122
|
+
dailyMap.set(day, { ...emptyTotals(), key: day, label: day, share: 0 })
|
|
123
|
+
}
|
|
124
|
+
for (const record of records) addRecord(dailyMap.get(record.date)!, record)
|
|
125
|
+
const daily = [...dailyMap.values()]
|
|
126
|
+
.map((row) => ({ ...row, share: metricTotal > 0 ? metricValue(row, metric) / metricTotal : 0 }))
|
|
127
|
+
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
|
|
128
|
+
|
|
129
|
+
const series = Object.fromEntries(
|
|
130
|
+
SOURCE_ORDER.map((source) => {
|
|
131
|
+
const values = days.map((day) => {
|
|
132
|
+
const dayTotals = emptyTotals()
|
|
133
|
+
for (const record of records) {
|
|
134
|
+
if (record.source === source && record.date === day) addRecord(dayTotals, record)
|
|
135
|
+
}
|
|
136
|
+
return metricValue(dayTotals, metric)
|
|
137
|
+
})
|
|
138
|
+
return [source, values]
|
|
139
|
+
}),
|
|
140
|
+
) as Record<SourceId, number[]>
|
|
141
|
+
|
|
142
|
+
return { days, records, totals, sessions: new Set(sessions.map((session) => session.id)).size, providers, models, daily, series }
|
|
143
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function localDate(value: string | number | Date): string {
|
|
2
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
3
|
+
const year = date.getFullYear()
|
|
4
|
+
const month = String(date.getMonth() + 1).padStart(2, "0")
|
|
5
|
+
const day = String(date.getDate()).padStart(2, "0")
|
|
6
|
+
return `${year}-${month}-${day}`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function dateDaysAgo(daysAgo: number): string {
|
|
10
|
+
const date = new Date()
|
|
11
|
+
date.setHours(0, 0, 0, 0)
|
|
12
|
+
date.setDate(date.getDate() - daysAgo)
|
|
13
|
+
return localDate(date)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function datesInRange(days: number): string[] {
|
|
17
|
+
return Array.from({ length: days }, (_, index) => dateDaysAgo(days - index - 1))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shortDate(value: string): string {
|
|
21
|
+
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(
|
|
22
|
+
new Date(`${value}T12:00:00`),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type SourceId = "claude" | "codex" | "cursor" | "gemini" | "opencode" | "grok"
|
|
2
|
+
|
|
3
|
+
export type UsageRecord = {
|
|
4
|
+
date: string
|
|
5
|
+
source: SourceId
|
|
6
|
+
model: string
|
|
7
|
+
sessionId?: string
|
|
8
|
+
inputTokens: number
|
|
9
|
+
outputTokens: number
|
|
10
|
+
cacheCreationTokens: number
|
|
11
|
+
cacheReadTokens: number
|
|
12
|
+
costUsd: number
|
|
13
|
+
cacheSavingsUsd: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type UsageSession = {
|
|
17
|
+
id: string
|
|
18
|
+
source: SourceId
|
|
19
|
+
date: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type UsageDataset = {
|
|
23
|
+
records: UsageRecord[]
|
|
24
|
+
sessions: UsageSession[]
|
|
25
|
+
errors: string[]
|
|
26
|
+
scannedAt: Date
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Metric = "cost" | "tokens"
|
|
30
|
+
export type RangeDays = 1 | 7 | 30 | 90
|
|
31
|
+
export type BreakdownMode = "model" | "day"
|
|
32
|
+
|
|
33
|
+
export type SourceMeta = {
|
|
34
|
+
id: SourceId
|
|
35
|
+
label: string
|
|
36
|
+
mark: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SOURCE_META: Record<SourceId, SourceMeta> = {
|
|
40
|
+
claude: { id: "claude", label: "Claude Code", mark: "*" },
|
|
41
|
+
codex: { id: "codex", label: "Codex", mark: "o" },
|
|
42
|
+
cursor: { id: "cursor", label: "Cursor", mark: ">" },
|
|
43
|
+
gemini: { id: "gemini", label: "Gemini CLI", mark: "g" },
|
|
44
|
+
grok: { id: "grok", label: "Grok Build", mark: "x" },
|
|
45
|
+
opencode: { id: "opencode", label: "OpenCode", mark: "+" },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SOURCE_ORDER: SourceId[] = ["codex", "claude", "cursor", "gemini", "grok", "opencode"]
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createCliRenderer, RGBA } from "@opentui/core"
|
|
3
|
+
import { createRoot } from "@opentui/react"
|
|
4
|
+
import { App } from "./tui/App"
|
|
5
|
+
|
|
6
|
+
const renderer = await createCliRenderer({
|
|
7
|
+
backgroundColor: RGBA.defaultBackground("#1e1e1e"),
|
|
8
|
+
exitOnCtrlC: true,
|
|
9
|
+
targetFps: 30,
|
|
10
|
+
useMouse: true,
|
|
11
|
+
enableMouseMovement: true,
|
|
12
|
+
autoFocus: false,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
createRoot(renderer).render(<App />)
|