@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/yacu.js +1782 -0
  4. package/package.json +62 -0
  5. package/src/data/jsonl.ts +81 -0
  6. package/src/data/load.ts +52 -0
  7. package/src/data/pricing.test.ts +22 -0
  8. package/src/data/pricing.ts +49 -0
  9. package/src/data/sources/claude.test.ts +40 -0
  10. package/src/data/sources/claude.ts +118 -0
  11. package/src/data/sources/codex.ts +73 -0
  12. package/src/data/sources/cursor.test.ts +16 -0
  13. package/src/data/sources/cursor.ts +83 -0
  14. package/src/data/sources/gemini.test.ts +27 -0
  15. package/src/data/sources/gemini.ts +112 -0
  16. package/src/data/sources/grok.ts +60 -0
  17. package/src/data/sources/opencode.ts +53 -0
  18. package/src/data/types.ts +23 -0
  19. package/src/domain/aggregate.test.ts +62 -0
  20. package/src/domain/aggregate.ts +143 -0
  21. package/src/domain/dates.ts +24 -0
  22. package/src/domain/types.ts +48 -0
  23. package/src/index.tsx +15 -0
  24. package/src/tui/App.tsx +170 -0
  25. package/src/tui/ThemeContext.tsx +12 -0
  26. package/src/tui/chart.test.ts +48 -0
  27. package/src/tui/chart.ts +130 -0
  28. package/src/tui/components/Breakdown.tsx +85 -0
  29. package/src/tui/components/Chart.test.tsx +67 -0
  30. package/src/tui/components/Chart.tsx +159 -0
  31. package/src/tui/components/Footer.tsx +11 -0
  32. package/src/tui/components/Header.tsx +52 -0
  33. package/src/tui/components/PointerButton.tsx +40 -0
  34. package/src/tui/components/ScanBoot.test.tsx +30 -0
  35. package/src/tui/components/ScanBoot.tsx +30 -0
  36. package/src/tui/components/Segmented.test.tsx +32 -0
  37. package/src/tui/components/Segmented.tsx +59 -0
  38. package/src/tui/components/Summary.tsx +67 -0
  39. package/src/tui/components/Totals.tsx +27 -0
  40. package/src/tui/format.ts +44 -0
  41. package/src/tui/theme.ts +99 -0
  42. package/src/tui/usePointer.ts +11 -0
  43. package/tsconfig.json +15 -0
@@ -0,0 +1,52 @@
1
+ import type { Metric, RangeDays } from "../../domain/types"
2
+ import { shortDate } from "../../domain/dates"
3
+ import { Segmented } from "./Segmented"
4
+ import { useTheme } from "../ThemeContext"
5
+ import { PointerButton } from "./PointerButton"
6
+
7
+ export function Header({
8
+ metric,
9
+ range,
10
+ days,
11
+ compact,
12
+ onMetricChange,
13
+ onRangeChange,
14
+ onRefresh,
15
+ }: {
16
+ metric: Metric
17
+ range: RangeDays
18
+ days: string[]
19
+ compact: boolean
20
+ onMetricChange: (metric: Metric) => void
21
+ onRangeChange: (range: RangeDays) => void
22
+ onRefresh: () => void
23
+ }) {
24
+ const theme = useTheme()
25
+ const dateLabel = days.length === 0 ? "" : `${shortDate(days[0]!)} to ${shortDate(days.at(-1)!)}`
26
+ return (
27
+ <box flexDirection={compact ? "column" : "row"} justifyContent="space-between" width="100%" gap={1}>
28
+ <text fg={theme.text}>
29
+ <strong>yacu</strong>
30
+ <span fg={theme.muted}>{` / ${dateLabel}`}</span>
31
+ </text>
32
+ <box flexDirection="row" gap={2}>
33
+ <Segmented
34
+ selected={metric}
35
+ onChange={onMetricChange}
36
+ options={[{ value: "cost", label: "Cost" }, { value: "tokens", label: "Tokens" }]}
37
+ />
38
+ <Segmented
39
+ selected={range}
40
+ onChange={onRangeChange}
41
+ options={[
42
+ { value: 1, label: "Past 24h" },
43
+ { value: 7, label: "7 days" },
44
+ { value: 30, label: "30 days" },
45
+ { value: 90, label: "90 days" },
46
+ ]}
47
+ />
48
+ <PointerButton label="r ↻" onPress={onRefresh} />
49
+ </box>
50
+ </box>
51
+ )
52
+ }
@@ -0,0 +1,40 @@
1
+ import { useState } from "react"
2
+ import type { KeyEvent, MouseEvent } from "@opentui/core"
3
+ import { useTheme } from "../ThemeContext"
4
+ import { usePointer } from "../usePointer"
5
+
6
+ export function PointerButton({ label, onPress }: { label: string; onPress: () => void }) {
7
+ const theme = useTheme()
8
+ const [hovered, setHovered] = useState(false)
9
+ const { pointerOver, pointerOut } = usePointer()
10
+
11
+ const press = (event: MouseEvent) => {
12
+ if (event.button !== 0) return
13
+ event.stopPropagation()
14
+ onPress()
15
+ }
16
+
17
+ const keyPress = (key: KeyEvent) => {
18
+ if (key.name === "return" || key.name === "space") onPress()
19
+ }
20
+
21
+ return (
22
+ <box
23
+ focusable
24
+ height={1}
25
+ backgroundColor={hovered ? theme.hover : theme.panel}
26
+ onMouseDown={press}
27
+ onMouseOver={() => {
28
+ setHovered(true)
29
+ pointerOver()
30
+ }}
31
+ onMouseOut={() => {
32
+ setHovered(false)
33
+ pointerOut()
34
+ }}
35
+ onKeyDown={keyPress}
36
+ >
37
+ <text selectable={false} fg={hovered ? theme.onHover : theme.muted}>{` ${label} `}</text>
38
+ </box>
39
+ )
40
+ }
@@ -0,0 +1,30 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { testRender } from "@opentui/react/test-utils"
3
+ import type { TestRendererSetup } from "@opentui/core/testing"
4
+ import { act } from "react"
5
+ import { ScanBoot } from "./ScanBoot"
6
+
7
+ let setup: TestRendererSetup | null = null
8
+
9
+ afterEach(() => {
10
+ act(() => { setup?.renderer.destroy() })
11
+ setup = null
12
+ })
13
+
14
+ describe("ScanBoot", () => {
15
+ test("renders only a centered compact scanner", async () => {
16
+ setup = await testRender(
17
+ <ScanBoot completed={3} total={6} width={80} />,
18
+ { width: 80, height: 20 },
19
+ )
20
+ await setup.renderOnce()
21
+
22
+ const frame = setup.captureCharFrame()
23
+ expect(frame).toContain("scanning usage...")
24
+ expect(frame).toContain("[########## ]")
25
+ expect(frame).not.toContain("Scanning Codex")
26
+ expect(frame).not.toContain("files")
27
+ expect(frame).not.toContain("╭")
28
+ expect(frame).not.toContain("│")
29
+ })
30
+ })
@@ -0,0 +1,30 @@
1
+ import { useTheme } from "../ThemeContext"
2
+
3
+ export function ScanBoot({
4
+ completed,
5
+ total,
6
+ width,
7
+ }: {
8
+ completed: number
9
+ total: number
10
+ width: number
11
+ }) {
12
+ const theme = useTheme()
13
+ const progress = total === 0 ? 1 : Math.max(0, Math.min(1, completed / total))
14
+ const barWidth = Math.max(8, Math.min(20, width - 4))
15
+ const filled = Math.round(progress * barWidth)
16
+
17
+ return (
18
+ <box
19
+ width="100%"
20
+ flexGrow={1}
21
+ justifyContent="center"
22
+ alignItems="center"
23
+ >
24
+ <box flexDirection="column" alignItems="center" gap={1}>
25
+ <text fg={theme.text}>scanning usage...</text>
26
+ <text fg={theme.text}>{`[${"#".repeat(filled)}${" ".repeat(barWidth - filled)}]`}</text>
27
+ </box>
28
+ </box>
29
+ )
30
+ }
@@ -0,0 +1,32 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { testRender } from "@opentui/react/test-utils"
3
+ import type { TestRendererSetup } from "@opentui/core/testing"
4
+ import { act } from "react"
5
+ import { Segmented } from "./Segmented"
6
+
7
+ let setup: TestRendererSetup | null = null
8
+
9
+ afterEach(() => {
10
+ act(() => { setup?.renderer.destroy() })
11
+ setup = null
12
+ })
13
+
14
+ describe("Segmented", () => {
15
+ test("changes value through a mouse click", async () => {
16
+ let selected = "cost"
17
+ setup = await testRender(
18
+ <Segmented
19
+ selected={selected}
20
+ onChange={(value) => { selected = value }}
21
+ options={[{ value: "cost", label: "Cost" }, { value: "tokens", label: "Tokens" }]}
22
+ />,
23
+ { width: 24, height: 2, autoFocus: false },
24
+ )
25
+ await setup.renderOnce()
26
+
27
+ await act(async () => { await setup!.mockMouse.click(9, 0) })
28
+
29
+ expect(selected).toBe("tokens")
30
+ expect(setup.renderer.getSelection()).toBeNull()
31
+ })
32
+ })
@@ -0,0 +1,59 @@
1
+ import { useState } from "react"
2
+ import type { KeyEvent, MouseEvent } from "@opentui/core"
3
+ import { useTheme } from "../ThemeContext"
4
+ import { usePointer } from "../usePointer"
5
+
6
+ type Option<T extends string | number> = { value: T; label: string }
7
+
8
+ export function Segmented<T extends string | number>({
9
+ options,
10
+ selected,
11
+ onChange,
12
+ }: {
13
+ options: Array<Option<T>>
14
+ selected: T
15
+ onChange: (value: T) => void
16
+ }) {
17
+ const theme = useTheme()
18
+ const [hovered, setHovered] = useState<T | null>(null)
19
+ const { pointerOver, pointerOut } = usePointer()
20
+
21
+ const mousePress = (value: T) => (event: MouseEvent) => {
22
+ if (event.button !== 0) return
23
+ event.stopPropagation()
24
+ onChange(value)
25
+ }
26
+
27
+ const keyPress = (value: T) => (key: KeyEvent) => {
28
+ if (key.name === "return" || key.name === "space") onChange(value)
29
+ }
30
+
31
+ return (
32
+ <box flexDirection="row" backgroundColor={theme.panel}>
33
+ {options.map((option) => {
34
+ const active = option.value === selected
35
+ const hot = option.value === hovered
36
+ return (
37
+ <box
38
+ key={String(option.value)}
39
+ focusable
40
+ height={1}
41
+ backgroundColor={active ? theme.selected : hot ? theme.hover : theme.panel}
42
+ onMouseDown={mousePress(option.value)}
43
+ onMouseOver={() => {
44
+ setHovered(option.value)
45
+ pointerOver()
46
+ }}
47
+ onMouseOut={() => {
48
+ setHovered(null)
49
+ pointerOut()
50
+ }}
51
+ onKeyDown={keyPress(option.value)}
52
+ >
53
+ <text selectable={false} fg={active ? theme.onSelected : hot ? theme.onHover : theme.muted}>{` ${option.label} `}</text>
54
+ </box>
55
+ )
56
+ })}
57
+ </box>
58
+ )
59
+ }
@@ -0,0 +1,67 @@
1
+ import { useState } from "react"
2
+ import type { MouseEvent } from "@opentui/core"
3
+ import type { Dashboard } from "../../domain/aggregate"
4
+ import type { Metric, SourceId } from "../../domain/types"
5
+ import { SOURCE_META } from "../../domain/types"
6
+ import { formatCompact, formatMetric, formatMoney, percent } from "../format"
7
+ import { useTheme } from "../ThemeContext"
8
+ import { usePointer } from "../usePointer"
9
+
10
+ export function Summary({
11
+ dashboard,
12
+ metric,
13
+ visibleSources,
14
+ onToggleSource,
15
+ }: {
16
+ dashboard: Dashboard
17
+ metric: Metric
18
+ visibleSources: Set<SourceId>
19
+ onToggleSource: (source: SourceId) => void
20
+ }) {
21
+ const theme = useTheme()
22
+ const [hovered, setHovered] = useState<SourceId | null>(null)
23
+ const { pointerOver, pointerOut } = usePointer()
24
+ const total = metric === "cost" ? formatMoney(dashboard.totals.costUsd) : formatCompact(dashboard.totals.processedTokens)
25
+ return (
26
+ <box flexDirection="column" width="100%" gap={1}>
27
+ <text fg={theme.text}><strong>{total}</strong></text>
28
+ <text fg={theme.muted}>{`${dashboard.sessions.toLocaleString("en-US")} sessions · API estimate`}</text>
29
+ <box height={1} />
30
+ {dashboard.providers.map((provider) => {
31
+ const meta = SOURCE_META[provider.source]
32
+ const value = metric === "cost" ? provider.costUsd : provider.processedTokens
33
+ const visible = visibleSources.has(provider.source)
34
+ const hot = hovered === provider.source
35
+ const toggle = (event: MouseEvent) => {
36
+ if (event.button !== 0) return
37
+ event.stopPropagation()
38
+ onToggleSource(provider.source)
39
+ }
40
+ return (
41
+ <box
42
+ key={provider.source}
43
+ flexDirection="column"
44
+ marginBottom={1}
45
+ backgroundColor={hot ? theme.hover : theme.bg}
46
+ opacity={visible ? 1 : 0.45}
47
+ onMouseDown={toggle}
48
+ onMouseOver={() => {
49
+ setHovered(provider.source)
50
+ pointerOver()
51
+ }}
52
+ onMouseOut={() => {
53
+ setHovered(null)
54
+ pointerOut()
55
+ }}
56
+ >
57
+ <box flexDirection="row" justifyContent="space-between">
58
+ <text selectable={false} fg={hot ? theme.onHover : theme.sources[provider.source]}>{`${meta.mark} `}<span fg={hot ? theme.onHover : theme.text}>{meta.label}</span><span fg={hot ? theme.onHover : theme.muted}>{` ${provider.sessions} sessions`}</span></text>
59
+ <text selectable={false} fg={hot ? theme.onHover : theme.text}>{formatMetric(value, metric)}</text>
60
+ </box>
61
+ <text selectable={false} fg={hot ? theme.onHover : theme.muted}>{`${percent(provider.share)} of ${metric} · ${formatCompact(provider.processedTokens)} tokens`}</text>
62
+ </box>
63
+ )
64
+ })}
65
+ </box>
66
+ )
67
+ }
@@ -0,0 +1,27 @@
1
+ import type { Dashboard } from "../../domain/aggregate"
2
+ import { formatCompact, formatMoney } from "../format"
3
+ import { useTheme } from "../ThemeContext"
4
+
5
+ export function Totals({ dashboard, compact }: { dashboard: Dashboard; compact: boolean }) {
6
+ const theme = useTheme()
7
+ const values = [
8
+ ["Processed tokens", formatCompact(dashboard.totals.processedTokens)],
9
+ ["Cached input", formatCompact(dashboard.totals.cacheReadTokens)],
10
+ ["Uncached input", formatCompact(dashboard.totals.inputTokens + dashboard.totals.cacheCreationTokens)],
11
+ ["Output", formatCompact(dashboard.totals.outputTokens)],
12
+ ["Cache savings", formatMoney(dashboard.totals.cacheSavingsUsd)],
13
+ ]
14
+ return (
15
+ <box flexDirection="column" width="100%" gap={1}>
16
+ <text fg={theme.text}><strong>Totals</strong></text>
17
+ <box flexDirection={compact ? "column" : "row"} justifyContent="space-between" gap={compact ? 0 : 2}>
18
+ {values.map(([label, value]) => (
19
+ <box key={label} flexDirection={compact ? "row" : "column"} justifyContent="space-between" flexGrow={1}>
20
+ <text fg={theme.muted}>{label}</text>
21
+ <text fg={theme.text}>{value}</text>
22
+ </box>
23
+ ))}
24
+ </box>
25
+ </box>
26
+ )
27
+ }
@@ -0,0 +1,44 @@
1
+ export function formatMoney(value: number): string {
2
+ return new Intl.NumberFormat("en-US", {
3
+ style: "currency",
4
+ currency: "USD",
5
+ minimumFractionDigits: 2,
6
+ maximumFractionDigits: 2,
7
+ }).format(value)
8
+ }
9
+
10
+ export function formatCompact(value: number): string {
11
+ if (value < 1_000) return Math.round(value).toLocaleString("en-US")
12
+ const units = [
13
+ [1_000_000_000, "B"],
14
+ [1_000_000, "M"],
15
+ [1_000, "K"],
16
+ ] as const
17
+ const [divisor, suffix] = units.find(([divisor]) => value >= divisor) ?? units[2]
18
+ const scaled = value / divisor
19
+ return `${scaled >= 100 ? scaled.toFixed(0) : scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)}${suffix}`
20
+ }
21
+
22
+ export function formatMetric(value: number, metric: "cost" | "tokens"): string {
23
+ return metric === "cost" ? formatMoney(value) : formatCompact(value)
24
+ }
25
+
26
+ export function formatAxis(value: number, metric: "cost" | "tokens"): string {
27
+ if (metric === "tokens") return formatCompact(value)
28
+ if (value >= 1_000) return `$${formatCompact(value)}`
29
+ return `$${Math.round(value).toLocaleString("en-US")}`
30
+ }
31
+
32
+ export function percent(value: number): string {
33
+ return `${(value * 100).toFixed(1)}%`
34
+ }
35
+
36
+ export function padLeft(value: string, width: number): string {
37
+ return value.length >= width ? value.slice(0, width) : value.padStart(width)
38
+ }
39
+
40
+ export function truncate(value: string, width: number): string {
41
+ if (value.length <= width) return value.padEnd(width)
42
+ if (width <= 1) return value.slice(0, width)
43
+ return `${value.slice(0, width - 1)}…`
44
+ }
@@ -0,0 +1,99 @@
1
+ import { useEffect, useState } from "react"
2
+ import { RGBA, type CliRenderer, type TerminalColors, type ThemeMode } from "@opentui/core"
3
+ import type { SourceId } from "../domain/types"
4
+
5
+ export type Theme = {
6
+ mode: ThemeMode
7
+ bg: RGBA
8
+ panel: RGBA
9
+ hover: RGBA
10
+ selected: RGBA
11
+ text: RGBA
12
+ onHover: RGBA
13
+ onSelected: RGBA
14
+ muted: RGBA
15
+ faint: RGBA
16
+ accent: RGBA
17
+ error: RGBA
18
+ progress: RGBA
19
+ onProgress: RGBA
20
+ sources: Record<SourceId, RGBA>
21
+ }
22
+
23
+ const dark = createTheme("dark")
24
+ const light = createTheme("light")
25
+
26
+ export function terminalTheme(mode: ThemeMode): Theme {
27
+ return mode === "light" ? light : dark
28
+ }
29
+
30
+ export function useTerminalTheme(renderer: CliRenderer): Theme {
31
+ const [mode, setMode] = useState<ThemeMode>(renderer.themeMode ?? colorFgBgMode() ?? "dark")
32
+
33
+ useEffect(() => {
34
+ const update = (next: ThemeMode) => setMode(next)
35
+ const updatePalette = (colors: TerminalColors) => {
36
+ const next = backgroundMode(colors.defaultBackground)
37
+ if (next != null) setMode(next)
38
+ }
39
+ renderer.on("theme_mode", update)
40
+ renderer.on("palette", updatePalette)
41
+ void renderer.waitForThemeMode(300).then((next) => {
42
+ if (next != null) setMode(next)
43
+ })
44
+ void renderer.getPalette({ size: 16, timeout: 300 }).then(updatePalette).catch(() => {})
45
+ return () => {
46
+ renderer.off("theme_mode", update)
47
+ renderer.off("palette", updatePalette)
48
+ }
49
+ }, [renderer])
50
+
51
+ return terminalTheme(mode)
52
+ }
53
+
54
+ function colorFgBgMode(): ThemeMode | null {
55
+ const index = Number(process.env.COLORFGBG?.split(";").at(-1))
56
+ if (!Number.isInteger(index)) return null
57
+ return index === 7 || index > 8 ? "light" : "dark"
58
+ }
59
+
60
+ function backgroundMode(background: string | null): ThemeMode | null {
61
+ if (background == null) return null
62
+ const hex = background.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i)
63
+ if (hex == null) return null
64
+ const red = Number.parseInt(hex[1]!, 16)
65
+ const green = Number.parseInt(hex[2]!, 16)
66
+ const blue = Number.parseInt(hex[3]!, 16)
67
+ const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
68
+ return luminance >= 0.55 ? "light" : "dark"
69
+ }
70
+
71
+ function createTheme(mode: ThemeMode): Theme {
72
+ const isLight = mode === "light"
73
+ const textSnapshot = isLight ? "#202020" : "#ededed"
74
+ const backgroundSnapshot = isLight ? "#fafafa" : "#1e1e1e"
75
+ return {
76
+ mode,
77
+ bg: RGBA.defaultBackground(backgroundSnapshot),
78
+ panel: RGBA.defaultBackground(backgroundSnapshot),
79
+ hover: RGBA.fromHex(isLight ? "#e4e4e4" : "#303030"),
80
+ selected: RGBA.fromHex(isLight ? "#d8d8d8" : "#3a3a3a"),
81
+ text: RGBA.defaultForeground(textSnapshot),
82
+ onHover: RGBA.defaultForeground(textSnapshot),
83
+ onSelected: RGBA.defaultForeground(textSnapshot),
84
+ muted: RGBA.fromHex(isLight ? "#666666" : "#858585"),
85
+ faint: RGBA.fromHex(isLight ? "#c8c8c8" : "#424242"),
86
+ accent: RGBA.fromHex(isLight ? "#8a5a00" : "#d7a94a"),
87
+ error: RGBA.fromHex(isLight ? "#a52a2a" : "#dc6b6b"),
88
+ progress: RGBA.fromHex(isLight ? "#1f9d68" : "#35c98b"),
89
+ onProgress: RGBA.fromHex(isLight ? "#ffffff" : "#111111"),
90
+ sources: {
91
+ claude: RGBA.fromHex(isLight ? "#a84324" : "#dc7957"),
92
+ codex: RGBA.defaultForeground(textSnapshot),
93
+ cursor: RGBA.fromHex(isLight ? "#713b91" : "#c58be2"),
94
+ gemini: RGBA.fromHex(isLight ? "#245fa8" : "#77a7e8"),
95
+ grok: RGBA.fromHex(isLight ? "#5e5e5e" : "#a5a5a5"),
96
+ opencode: RGBA.fromHex(isLight ? "#287a73" : "#72a7a0"),
97
+ },
98
+ }
99
+ }
@@ -0,0 +1,11 @@
1
+ import type { MousePointerStyle } from "@opentui/core"
2
+ import { useRenderer } from "@opentui/react"
3
+
4
+ export function usePointer(style: MousePointerStyle = "pointer") {
5
+ const renderer = useRenderer()
6
+
7
+ return {
8
+ pointerOver: () => renderer.setMousePointer(style),
9
+ pointerOut: () => renderer.setMousePointer("default"),
10
+ }
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "@opentui/react",
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true,
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src", "test"]
15
+ }