@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
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react"
|
|
2
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import { buildDashboard } from "../domain/aggregate"
|
|
4
|
+
import { SOURCE_ORDER, type BreakdownMode, type Metric, type RangeDays, type SourceId, type UsageDataset } from "../domain/types"
|
|
5
|
+
import { loadUsageDataset } from "../data/load"
|
|
6
|
+
import { Header } from "./components/Header"
|
|
7
|
+
import { Summary } from "./components/Summary"
|
|
8
|
+
import { Chart } from "./components/Chart"
|
|
9
|
+
import { Totals } from "./components/Totals"
|
|
10
|
+
import { Breakdown } from "./components/Breakdown"
|
|
11
|
+
import { Footer } from "./components/Footer"
|
|
12
|
+
import { ScanBoot } from "./components/ScanBoot"
|
|
13
|
+
import { ThemeProvider } from "./ThemeContext"
|
|
14
|
+
import { useTerminalTheme } from "./theme"
|
|
15
|
+
|
|
16
|
+
const RANGES: RangeDays[] = [1, 7, 30, 90]
|
|
17
|
+
const COMPLETED_SCAN_HOLD_MS = 800
|
|
18
|
+
|
|
19
|
+
export function App() {
|
|
20
|
+
const renderer = useRenderer()
|
|
21
|
+
const theme = useTerminalTheme(renderer)
|
|
22
|
+
const { width, height } = useTerminalDimensions()
|
|
23
|
+
const [dataset, setDataset] = useState<UsageDataset | null>(null)
|
|
24
|
+
const [metric, setMetric] = useState<Metric>("cost")
|
|
25
|
+
const [range, setRange] = useState<RangeDays>(30)
|
|
26
|
+
const [breakdown, setBreakdown] = useState<BreakdownMode>("model")
|
|
27
|
+
const [visibleSources, setVisibleSources] = useState<Set<SourceId>>(() => new Set(SOURCE_ORDER))
|
|
28
|
+
const [selectedDay, setSelectedDay] = useState<string | null>(null)
|
|
29
|
+
const [loading, setLoading] = useState(true)
|
|
30
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
|
+
const [scanCompleted, setScanCompleted] = useState(0)
|
|
32
|
+
|
|
33
|
+
const refresh = async () => {
|
|
34
|
+
const initialScan = dataset == null
|
|
35
|
+
setLoading(true)
|
|
36
|
+
setError(null)
|
|
37
|
+
if (initialScan) setScanCompleted(0)
|
|
38
|
+
try {
|
|
39
|
+
const nextDataset = await loadUsageDataset((progress) => {
|
|
40
|
+
if (initialScan) setScanCompleted(progress.completed)
|
|
41
|
+
})
|
|
42
|
+
if (initialScan) await Bun.sleep(COMPLETED_SCAN_HOLD_MS)
|
|
43
|
+
setDataset(nextDataset)
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
useEffect(() => { void refresh() }, [])
|
|
52
|
+
|
|
53
|
+
const changeRange = (next: RangeDays) => {
|
|
54
|
+
setRange(next)
|
|
55
|
+
setSelectedDay(null)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const selectDay = (day: string | null) => {
|
|
59
|
+
setSelectedDay(day)
|
|
60
|
+
if (day != null) setBreakdown("day")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const toggleSource = (source: SourceId) => {
|
|
64
|
+
setVisibleSources((current) => {
|
|
65
|
+
const next = new Set(current)
|
|
66
|
+
if (next.has(source)) next.delete(source)
|
|
67
|
+
else next.add(source)
|
|
68
|
+
return next
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
useKeyboard((key) => {
|
|
73
|
+
if (key.name === "q" || key.name === "escape") renderer.destroy()
|
|
74
|
+
else if (key.name === "c") setMetric((value) => value === "cost" ? "tokens" : "cost")
|
|
75
|
+
else if (key.name === "b") setBreakdown((value) => value === "model" ? "day" : "model")
|
|
76
|
+
else if (key.name === "r") void refresh()
|
|
77
|
+
else if (["1", "2", "3", "4"].includes(key.name)) changeRange(RANGES[Number(key.name) - 1]!)
|
|
78
|
+
else if (key.name === "left" || key.name === "right") {
|
|
79
|
+
const index = RANGES.indexOf(range)
|
|
80
|
+
const next = key.name === "left" ? Math.max(0, index - 1) : Math.min(RANGES.length - 1, index + 1)
|
|
81
|
+
changeRange(RANGES[next]!)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const dashboard = useMemo(() => dataset == null ? null : buildDashboard(dataset, range, metric), [dataset, range, metric])
|
|
86
|
+
const compact = width < 100
|
|
87
|
+
const wide = width >= 112
|
|
88
|
+
const contentWidth = Math.max(40, width - 4)
|
|
89
|
+
const summaryWidth = wide ? Math.min(45, Math.floor(contentWidth * 0.34)) : contentWidth
|
|
90
|
+
const chartWidth = wide ? contentWidth - summaryWidth - 3 : contentWidth
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<ThemeProvider theme={theme}>
|
|
94
|
+
<box width="100%" height="100%" flexDirection="column" backgroundColor={theme.bg}>
|
|
95
|
+
{dashboard == null ? (
|
|
96
|
+
<box flexGrow={1} width="100%" height="100%" paddingX={2} paddingTop={1}>
|
|
97
|
+
{error == null
|
|
98
|
+
? <ScanBoot completed={scanCompleted} total={SOURCE_ORDER.length} width={contentWidth} />
|
|
99
|
+
: <text fg={theme.error}>{`Error: ${error}`}</text>}
|
|
100
|
+
</box>
|
|
101
|
+
) : (
|
|
102
|
+
<scrollbox
|
|
103
|
+
flexGrow={1}
|
|
104
|
+
width="100%"
|
|
105
|
+
paddingX={2}
|
|
106
|
+
paddingTop={1}
|
|
107
|
+
scrollY
|
|
108
|
+
viewportCulling
|
|
109
|
+
scrollbarOptions={{
|
|
110
|
+
trackOptions: { backgroundColor: theme.bg, foregroundColor: theme.muted },
|
|
111
|
+
arrowOptions: { backgroundColor: theme.bg, foregroundColor: theme.muted },
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<box flexDirection="column" width="100%" gap={2}>
|
|
115
|
+
<Header
|
|
116
|
+
metric={metric}
|
|
117
|
+
range={range}
|
|
118
|
+
days={dashboard.days}
|
|
119
|
+
compact={compact}
|
|
120
|
+
onMetricChange={setMetric}
|
|
121
|
+
onRangeChange={changeRange}
|
|
122
|
+
onRefresh={() => void refresh()}
|
|
123
|
+
/>
|
|
124
|
+
{dashboard.records.length === 0 ? (
|
|
125
|
+
<text fg={theme.muted}>No local usage found</text>
|
|
126
|
+
) : (
|
|
127
|
+
<>
|
|
128
|
+
<box flexDirection={wide ? "row" : "column"} width="100%" gap={3}>
|
|
129
|
+
<box width={wide ? summaryWidth : "100%"}>
|
|
130
|
+
<Summary
|
|
131
|
+
dashboard={dashboard}
|
|
132
|
+
metric={metric}
|
|
133
|
+
visibleSources={visibleSources}
|
|
134
|
+
onToggleSource={toggleSource}
|
|
135
|
+
/>
|
|
136
|
+
</box>
|
|
137
|
+
<box width={wide ? chartWidth : "100%"}>
|
|
138
|
+
<Chart
|
|
139
|
+
dashboard={dashboard}
|
|
140
|
+
metric={metric}
|
|
141
|
+
width={chartWidth}
|
|
142
|
+
height={wide ? 11 : 8}
|
|
143
|
+
visibleSources={visibleSources}
|
|
144
|
+
selectedDay={selectedDay}
|
|
145
|
+
onSelectDay={selectDay}
|
|
146
|
+
/>
|
|
147
|
+
</box>
|
|
148
|
+
</box>
|
|
149
|
+
<Totals dashboard={dashboard} compact={compact} />
|
|
150
|
+
<Breakdown
|
|
151
|
+
dashboard={dashboard}
|
|
152
|
+
mode={breakdown}
|
|
153
|
+
metric={metric}
|
|
154
|
+
width={contentWidth}
|
|
155
|
+
selectedDay={selectedDay}
|
|
156
|
+
onModeChange={setBreakdown}
|
|
157
|
+
onSelectDay={selectDay}
|
|
158
|
+
/>
|
|
159
|
+
</>
|
|
160
|
+
)}
|
|
161
|
+
{dataset?.errors.length ? <text fg={theme.error}>{dataset.errors.join(" · ")}</text> : null}
|
|
162
|
+
<box height={1} />
|
|
163
|
+
</box>
|
|
164
|
+
</scrollbox>
|
|
165
|
+
)}
|
|
166
|
+
{dashboard == null ? null : <Footer loading={loading} />}
|
|
167
|
+
</box>
|
|
168
|
+
</ThemeProvider>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react"
|
|
2
|
+
import { terminalTheme, type Theme } from "./theme"
|
|
3
|
+
|
|
4
|
+
const ThemeContext = createContext<Theme>(terminalTheme("dark"))
|
|
5
|
+
|
|
6
|
+
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
|
|
7
|
+
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useTheme(): Theme {
|
|
11
|
+
return useContext(ThemeContext)
|
|
12
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { SourceId } from "../domain/types"
|
|
3
|
+
import { chartColumnForDay, dayIndexAtColumn, renderBrailleChart } from "./chart"
|
|
4
|
+
|
|
5
|
+
const emptySeries = (): Record<SourceId, number[]> => ({
|
|
6
|
+
claude: [0, 0, 0],
|
|
7
|
+
codex: [0, 0, 0],
|
|
8
|
+
grok: [0, 0, 0],
|
|
9
|
+
opencode: [0, 0, 0],
|
|
10
|
+
cursor: [0, 0, 0],
|
|
11
|
+
gemini: [0, 0, 0],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("renderBrailleChart", () => {
|
|
15
|
+
test("renders the requested cell dimensions", () => {
|
|
16
|
+
const series = emptySeries()
|
|
17
|
+
series.claude = [0, 5, 10]
|
|
18
|
+
|
|
19
|
+
const chart = renderBrailleChart(series, 12, 4)
|
|
20
|
+
|
|
21
|
+
expect(chart.max).toBe(10)
|
|
22
|
+
expect(chart.rows).toHaveLength(4)
|
|
23
|
+
expect(chart.rows.every((row) => row.reduce((length, part) => length + part.text.length, 0) === 12)).toBeTrue()
|
|
24
|
+
expect(chart.rows.flatMap((row) => row).some((part) => part.text.trim().length > 0)).toBeTrue()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("handles empty data", () => {
|
|
28
|
+
const chart = renderBrailleChart(emptySeries(), 8, 3)
|
|
29
|
+
|
|
30
|
+
expect(chart.max).toBe(0)
|
|
31
|
+
expect(chart.rows).toHaveLength(3)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("maps pointer columns to days", () => {
|
|
35
|
+
expect(dayIndexAtColumn(0, 15, 30)).toBe(0)
|
|
36
|
+
expect(dayIndexAtColumn(14, 15, 30)).toBe(29)
|
|
37
|
+
expect(chartColumnForDay(29, 15, 30)).toBe(14)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("highlights the active column", () => {
|
|
41
|
+
const series = emptySeries()
|
|
42
|
+
series.codex = [1, 2, 3]
|
|
43
|
+
|
|
44
|
+
const chart = renderBrailleChart(series, 8, 3, 4, "#123456")
|
|
45
|
+
|
|
46
|
+
expect(chart.rows.every((row) => row.some((part) => part.background === "#123456"))).toBeTrue()
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/tui/chart.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ColorInput } from "@opentui/core"
|
|
2
|
+
import type { SourceId } from "../domain/types"
|
|
3
|
+
import { terminalTheme, type Theme } from "./theme"
|
|
4
|
+
|
|
5
|
+
export type ChartSegment = { text: string; color: ColorInput; background?: ColorInput }
|
|
6
|
+
export type ChartRow = ChartSegment[]
|
|
7
|
+
|
|
8
|
+
type Cell = { bits: number; source: SourceId | null }
|
|
9
|
+
|
|
10
|
+
const DOTS = [
|
|
11
|
+
[1, 8],
|
|
12
|
+
[2, 16],
|
|
13
|
+
[4, 32],
|
|
14
|
+
[64, 128],
|
|
15
|
+
] as const
|
|
16
|
+
|
|
17
|
+
export function renderBrailleChart(
|
|
18
|
+
series: Record<SourceId, number[]>,
|
|
19
|
+
width: number,
|
|
20
|
+
height: number,
|
|
21
|
+
highlightColumn?: number,
|
|
22
|
+
highlightColor?: ColorInput,
|
|
23
|
+
highlightForeground?: ColorInput,
|
|
24
|
+
palette: Theme = terminalTheme("dark"),
|
|
25
|
+
): { rows: ChartRow[]; max: number } {
|
|
26
|
+
const safeWidth = Math.max(4, width)
|
|
27
|
+
const safeHeight = Math.max(3, height)
|
|
28
|
+
const pixelWidth = safeWidth * 2
|
|
29
|
+
const pixelHeight = safeHeight * 4
|
|
30
|
+
const cells: Cell[][] = Array.from({ length: safeHeight }, () =>
|
|
31
|
+
Array.from({ length: safeWidth }, () => ({ bits: 0, source: null })),
|
|
32
|
+
)
|
|
33
|
+
const entries = (Object.entries(series) as Array<[SourceId, number[]]>)
|
|
34
|
+
.filter(([, values]) => values.some((value) => value > 0))
|
|
35
|
+
.sort(([, a], [, b]) => sum(a) - sum(b))
|
|
36
|
+
const max = Math.max(0, ...entries.flatMap(([, values]) => values))
|
|
37
|
+
|
|
38
|
+
if (max > 0) {
|
|
39
|
+
for (const [source, values] of entries) {
|
|
40
|
+
const points = values.map((value, index) => ({
|
|
41
|
+
x: values.length <= 1 ? 0 : Math.round((index / (values.length - 1)) * (pixelWidth - 1)),
|
|
42
|
+
y: Math.round((1 - value / max) * (pixelHeight - 1)),
|
|
43
|
+
}))
|
|
44
|
+
if (points.length === 1) plot(cells, points[0]!.x, points[0]!.y, source)
|
|
45
|
+
for (let index = 1; index < points.length; index++) {
|
|
46
|
+
drawLine(cells, points[index - 1]!, points[index]!, source)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
max,
|
|
53
|
+
rows: cells.map((row) => groupSegments(row, highlightColumn, highlightColor, highlightForeground, palette)),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sum(values: number[]): number {
|
|
58
|
+
return values.reduce((total, value) => total + value, 0)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function drawLine(
|
|
62
|
+
cells: Cell[][],
|
|
63
|
+
from: { x: number; y: number },
|
|
64
|
+
to: { x: number; y: number },
|
|
65
|
+
source: SourceId,
|
|
66
|
+
): void {
|
|
67
|
+
let x = from.x
|
|
68
|
+
let y = from.y
|
|
69
|
+
const dx = Math.abs(to.x - from.x)
|
|
70
|
+
const sx = from.x < to.x ? 1 : -1
|
|
71
|
+
const dy = -Math.abs(to.y - from.y)
|
|
72
|
+
const sy = from.y < to.y ? 1 : -1
|
|
73
|
+
let error = dx + dy
|
|
74
|
+
|
|
75
|
+
while (true) {
|
|
76
|
+
plot(cells, x, y, source)
|
|
77
|
+
if (x === to.x && y === to.y) return
|
|
78
|
+
const doubled = error * 2
|
|
79
|
+
if (doubled >= dy) {
|
|
80
|
+
error += dy
|
|
81
|
+
x += sx
|
|
82
|
+
}
|
|
83
|
+
if (doubled <= dx) {
|
|
84
|
+
error += dx
|
|
85
|
+
y += sy
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function plot(cells: Cell[][], pixelX: number, pixelY: number, source: SourceId): void {
|
|
91
|
+
const row = Math.floor(pixelY / 4)
|
|
92
|
+
const column = Math.floor(pixelX / 2)
|
|
93
|
+
const cell = cells[row]?.[column]
|
|
94
|
+
if (cell == null) return
|
|
95
|
+
cell.bits |= DOTS[pixelY % 4]![pixelX % 2]!
|
|
96
|
+
cell.source = source
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function groupSegments(
|
|
100
|
+
cells: Cell[],
|
|
101
|
+
highlightColumn?: number,
|
|
102
|
+
highlightColor?: ColorInput,
|
|
103
|
+
highlightForeground?: ColorInput,
|
|
104
|
+
palette: Theme = terminalTheme("dark"),
|
|
105
|
+
): ChartSegment[] {
|
|
106
|
+
const segments: ChartSegment[] = []
|
|
107
|
+
for (const [column, cell] of cells.entries()) {
|
|
108
|
+
const text = cell.bits === 0 ? " " : String.fromCodePoint(0x2800 + cell.bits)
|
|
109
|
+
const color = column === highlightColumn && highlightForeground != null
|
|
110
|
+
? highlightForeground
|
|
111
|
+
: cell.source == null ? palette.faint : palette.sources[cell.source]
|
|
112
|
+
const background = column === highlightColumn ? highlightColor : undefined
|
|
113
|
+
const previous = segments.at(-1)
|
|
114
|
+
if (previous?.color === color && previous.background === background) previous.text += text
|
|
115
|
+
else segments.push({ text, color, background })
|
|
116
|
+
}
|
|
117
|
+
return segments
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function dayIndexAtColumn(column: number, width: number, dayCount: number): number {
|
|
121
|
+
if (dayCount <= 1 || width <= 1) return 0
|
|
122
|
+
const bounded = Math.max(0, Math.min(width - 1, column))
|
|
123
|
+
return Math.round((bounded / (width - 1)) * (dayCount - 1))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function chartColumnForDay(index: number, width: number, dayCount: number): number {
|
|
127
|
+
if (dayCount <= 1 || width <= 1) return 0
|
|
128
|
+
const bounded = Math.max(0, Math.min(dayCount - 1, index))
|
|
129
|
+
return Math.round((bounded / (dayCount - 1)) * (width - 1))
|
|
130
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import type { MouseEvent } from "@opentui/core"
|
|
3
|
+
import type { Dashboard } from "../../domain/aggregate"
|
|
4
|
+
import type { BreakdownMode, Metric } from "../../domain/types"
|
|
5
|
+
import { SOURCE_META } from "../../domain/types"
|
|
6
|
+
import { Segmented } from "./Segmented"
|
|
7
|
+
import { formatMetric, percent, truncate } from "../format"
|
|
8
|
+
import { useTheme } from "../ThemeContext"
|
|
9
|
+
import { usePointer } from "../usePointer"
|
|
10
|
+
|
|
11
|
+
export function Breakdown({
|
|
12
|
+
dashboard,
|
|
13
|
+
mode,
|
|
14
|
+
metric,
|
|
15
|
+
width,
|
|
16
|
+
selectedDay,
|
|
17
|
+
onModeChange,
|
|
18
|
+
onSelectDay,
|
|
19
|
+
}: {
|
|
20
|
+
dashboard: Dashboard
|
|
21
|
+
mode: BreakdownMode
|
|
22
|
+
metric: Metric
|
|
23
|
+
width: number
|
|
24
|
+
selectedDay: string | null
|
|
25
|
+
onModeChange: (mode: BreakdownMode) => void
|
|
26
|
+
onSelectDay: (day: string | null) => void
|
|
27
|
+
}) {
|
|
28
|
+
const theme = useTheme()
|
|
29
|
+
const [hovered, setHovered] = useState<string | null>(null)
|
|
30
|
+
const { pointerOver, pointerOut } = usePointer()
|
|
31
|
+
const rows = mode === "model" ? dashboard.models : dashboard.daily
|
|
32
|
+
const nameWidth = Math.max(18, width - 42)
|
|
33
|
+
return (
|
|
34
|
+
<box flexDirection="column" width="100%" gap={1}>
|
|
35
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
36
|
+
<text fg={theme.text}><strong>Breakdown</strong></text>
|
|
37
|
+
<Segmented
|
|
38
|
+
selected={mode}
|
|
39
|
+
onChange={onModeChange}
|
|
40
|
+
options={[{ value: "model", label: "Model" }, { value: "day", label: "Day" }]}
|
|
41
|
+
/>
|
|
42
|
+
</box>
|
|
43
|
+
<box flexDirection="row">
|
|
44
|
+
<text fg={theme.muted} width={nameWidth}>{mode === "model" ? "Model" : "Day"}</text>
|
|
45
|
+
<text fg={theme.muted} width={15}>{metric === "cost" ? "Cost" : "Tokens"}</text>
|
|
46
|
+
<text fg={theme.muted} width={12}>Share</text>
|
|
47
|
+
<text fg={theme.muted}>Tokens</text>
|
|
48
|
+
</box>
|
|
49
|
+
{rows.slice(0, 18).map((row) => {
|
|
50
|
+
const value = metric === "cost" ? row.costUsd : row.processedTokens
|
|
51
|
+
const color = row.source == null ? theme.text : theme.sources[row.source]
|
|
52
|
+
const mark = row.source == null ? " " : SOURCE_META[row.source].mark
|
|
53
|
+
const interactive = mode === "day"
|
|
54
|
+
const selected = interactive && selectedDay === row.key
|
|
55
|
+
const hot = interactive && hovered === row.key
|
|
56
|
+
const select = interactive ? (event: MouseEvent) => {
|
|
57
|
+
if (event.button !== 0) return
|
|
58
|
+
event.stopPropagation()
|
|
59
|
+
onSelectDay(selected ? null : row.key)
|
|
60
|
+
} : undefined
|
|
61
|
+
return (
|
|
62
|
+
<box
|
|
63
|
+
key={row.key}
|
|
64
|
+
flexDirection="row"
|
|
65
|
+
backgroundColor={selected ? theme.selected : hot ? theme.hover : theme.bg}
|
|
66
|
+
onMouseDown={select}
|
|
67
|
+
onMouseOver={interactive ? () => {
|
|
68
|
+
setHovered(row.key)
|
|
69
|
+
pointerOver()
|
|
70
|
+
} : undefined}
|
|
71
|
+
onMouseOut={interactive ? () => {
|
|
72
|
+
setHovered(null)
|
|
73
|
+
pointerOut()
|
|
74
|
+
} : undefined}
|
|
75
|
+
>
|
|
76
|
+
<text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : color} width={nameWidth}>{`${mark} ${truncate(row.label, nameWidth - 2)}`}</text>
|
|
77
|
+
<text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : theme.text} width={15}>{formatMetric(value, metric)}</text>
|
|
78
|
+
<text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : theme.muted} width={12}>{percent(row.share)}</text>
|
|
79
|
+
<text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : theme.muted}>{formatMetric(row.processedTokens, "tokens")}</text>
|
|
80
|
+
</box>
|
|
81
|
+
)
|
|
82
|
+
})}
|
|
83
|
+
</box>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { buildDashboard } from "../../domain/aggregate"
|
|
6
|
+
import { localDate } from "../../domain/dates"
|
|
7
|
+
import { SOURCE_ORDER, type UsageDataset } from "../../domain/types"
|
|
8
|
+
import { terminalTheme } from "../theme"
|
|
9
|
+
import { Chart } from "./Chart"
|
|
10
|
+
|
|
11
|
+
let setup: TestRendererSetup | null = null
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
act(() => { setup?.renderer.destroy() })
|
|
15
|
+
setup = null
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("Chart", () => {
|
|
19
|
+
test("shows point cost on hover and selects the day on click", async () => {
|
|
20
|
+
const today = localDate(new Date())
|
|
21
|
+
const dataset: UsageDataset = {
|
|
22
|
+
scannedAt: new Date(),
|
|
23
|
+
errors: [],
|
|
24
|
+
sessions: [{ id: "codex:1", source: "codex", date: today }],
|
|
25
|
+
records: [{
|
|
26
|
+
date: today,
|
|
27
|
+
source: "codex",
|
|
28
|
+
model: "gpt-5-codex",
|
|
29
|
+
sessionId: "codex:1",
|
|
30
|
+
inputTokens: 100,
|
|
31
|
+
outputTokens: 10,
|
|
32
|
+
cacheCreationTokens: 0,
|
|
33
|
+
cacheReadTokens: 0,
|
|
34
|
+
costUsd: 2,
|
|
35
|
+
cacheSavingsUsd: 0,
|
|
36
|
+
}],
|
|
37
|
+
}
|
|
38
|
+
const dashboard = buildDashboard(dataset, 7, "cost")
|
|
39
|
+
let selected: string | null = null
|
|
40
|
+
setup = await testRender(
|
|
41
|
+
<Chart
|
|
42
|
+
dashboard={dashboard}
|
|
43
|
+
metric="cost"
|
|
44
|
+
width={40}
|
|
45
|
+
height={4}
|
|
46
|
+
visibleSources={new Set(SOURCE_ORDER)}
|
|
47
|
+
selectedDay={selected}
|
|
48
|
+
onSelectDay={(day) => { selected = day }}
|
|
49
|
+
/>,
|
|
50
|
+
{ width: 40, height: 8, autoFocus: false },
|
|
51
|
+
)
|
|
52
|
+
await setup.renderOnce()
|
|
53
|
+
|
|
54
|
+
await act(async () => { await setup!.mockMouse.moveTo(37, 2) })
|
|
55
|
+
await setup.flush()
|
|
56
|
+
|
|
57
|
+
expect(setup.captureCharFrame()).toContain("$2.00")
|
|
58
|
+
const highlightedSpans = setup.captureSpans().lines.flatMap((line) => line.spans)
|
|
59
|
+
.filter((span) => span.bg.equals(terminalTheme("dark").selected))
|
|
60
|
+
expect(highlightedSpans.length).toBeGreaterThanOrEqual(4)
|
|
61
|
+
|
|
62
|
+
await act(async () => { await setup!.mockMouse.click(37, 2) })
|
|
63
|
+
|
|
64
|
+
expect(String(selected)).toBe(today)
|
|
65
|
+
expect(setup.renderer.getSelection()).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useMemo, useState } from "react"
|
|
2
|
+
import type { MouseEvent } from "@opentui/core"
|
|
3
|
+
import type { Dashboard } from "../../domain/aggregate"
|
|
4
|
+
import { shortDate } from "../../domain/dates"
|
|
5
|
+
import { SOURCE_META, SOURCE_ORDER, type Metric, type SourceId } from "../../domain/types"
|
|
6
|
+
import { chartColumnForDay, dayIndexAtColumn, renderBrailleChart } from "../chart"
|
|
7
|
+
import { formatAxis, formatMetric } from "../format"
|
|
8
|
+
import { useTheme } from "../ThemeContext"
|
|
9
|
+
import { usePointer } from "../usePointer"
|
|
10
|
+
|
|
11
|
+
export function Chart({
|
|
12
|
+
dashboard,
|
|
13
|
+
metric,
|
|
14
|
+
width,
|
|
15
|
+
visibleSources,
|
|
16
|
+
selectedDay,
|
|
17
|
+
onSelectDay,
|
|
18
|
+
height = 11,
|
|
19
|
+
}: {
|
|
20
|
+
dashboard: Dashboard
|
|
21
|
+
metric: Metric
|
|
22
|
+
width: number
|
|
23
|
+
visibleSources: Set<SourceId>
|
|
24
|
+
selectedDay: string | null
|
|
25
|
+
onSelectDay: (day: string | null) => void
|
|
26
|
+
height?: number
|
|
27
|
+
}) {
|
|
28
|
+
const theme = useTheme()
|
|
29
|
+
const labelWidth = 9
|
|
30
|
+
const graphWidth = Math.max(8, width - labelWidth - 1)
|
|
31
|
+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
|
32
|
+
const { pointerOver, pointerOut } = usePointer("crosshair")
|
|
33
|
+
const selectedIndex = selectedDay == null ? -1 : dashboard.days.indexOf(selectedDay)
|
|
34
|
+
const activeIndex = hoveredIndex ?? (selectedIndex >= 0 ? selectedIndex : null)
|
|
35
|
+
const highlightColumn = activeIndex == null
|
|
36
|
+
? undefined
|
|
37
|
+
: chartColumnForDay(activeIndex, graphWidth, dashboard.days.length)
|
|
38
|
+
|
|
39
|
+
const visibleSeries = useMemo(() => Object.fromEntries(
|
|
40
|
+
SOURCE_ORDER.map((source) => [
|
|
41
|
+
source,
|
|
42
|
+
visibleSources.has(source)
|
|
43
|
+
? dashboard.series[source]
|
|
44
|
+
: dashboard.series[source].map(() => 0),
|
|
45
|
+
]),
|
|
46
|
+
) as Record<SourceId, number[]>, [dashboard.series, visibleSources])
|
|
47
|
+
const chart = renderBrailleChart(
|
|
48
|
+
visibleSeries,
|
|
49
|
+
graphWidth,
|
|
50
|
+
height,
|
|
51
|
+
highlightColumn,
|
|
52
|
+
theme.selected,
|
|
53
|
+
theme.onSelected,
|
|
54
|
+
theme,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const indexFromEvent = (event: MouseEvent): number => {
|
|
58
|
+
const start = event.currentTarget?.screenX ?? event.x
|
|
59
|
+
return dayIndexAtColumn(event.x - start, graphWidth, dashboard.days.length)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const move = (event: MouseEvent) => setHoveredIndex(indexFromEvent(event))
|
|
63
|
+
const select = (event: MouseEvent) => {
|
|
64
|
+
if (event.button !== 0) return
|
|
65
|
+
event.stopPropagation()
|
|
66
|
+
const index = indexFromEvent(event)
|
|
67
|
+
const day = dashboard.days[index] ?? null
|
|
68
|
+
onSelectDay(day === selectedDay ? null : day)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<box flexDirection="column" width="100%">
|
|
73
|
+
<text fg={theme.text}>{`Daily ${metric}`}</text>
|
|
74
|
+
<box height={1}>
|
|
75
|
+
{activeIndex == null ? null : (
|
|
76
|
+
<ChartPoint
|
|
77
|
+
dashboard={dashboard}
|
|
78
|
+
metric={metric}
|
|
79
|
+
index={activeIndex}
|
|
80
|
+
visibleSources={visibleSources}
|
|
81
|
+
pinned={hoveredIndex == null && selectedIndex === activeIndex}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
</box>
|
|
85
|
+
<box flexDirection="row" height={height}>
|
|
86
|
+
<box flexDirection="column" width={labelWidth}>
|
|
87
|
+
{chart.rows.map((_, index) => {
|
|
88
|
+
const ratio = 1 - index / Math.max(1, chart.rows.length - 1)
|
|
89
|
+
const show = index === 0 || index === Math.floor(chart.rows.length / 2) || index === chart.rows.length - 1
|
|
90
|
+
const label = show ? formatAxis(chart.max * ratio, metric) : ""
|
|
91
|
+
return <text key={index} fg={theme.muted} height={1}>{label.padStart(labelWidth - 1)}</text>
|
|
92
|
+
})}
|
|
93
|
+
</box>
|
|
94
|
+
<box
|
|
95
|
+
focusable
|
|
96
|
+
flexDirection="column"
|
|
97
|
+
width={graphWidth}
|
|
98
|
+
height={height}
|
|
99
|
+
onMouseMove={move}
|
|
100
|
+
onMouseDown={select}
|
|
101
|
+
onMouseOver={pointerOver}
|
|
102
|
+
onMouseOut={() => {
|
|
103
|
+
setHoveredIndex(null)
|
|
104
|
+
pointerOut()
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{chart.rows.map((segments, index) => (
|
|
108
|
+
<text key={index} height={1} selectable={false}>
|
|
109
|
+
{segments.map((segment, segmentIndex) => (
|
|
110
|
+
<span key={segmentIndex} fg={segment.color} bg={segment.background}>
|
|
111
|
+
{segment.text}
|
|
112
|
+
</span>
|
|
113
|
+
))}
|
|
114
|
+
</text>
|
|
115
|
+
))}
|
|
116
|
+
</box>
|
|
117
|
+
</box>
|
|
118
|
+
<box flexDirection="row" marginLeft={labelWidth} justifyContent="space-between" width={graphWidth}>
|
|
119
|
+
<text fg={theme.muted}>{shortDate(dashboard.days[0] ?? "")}</text>
|
|
120
|
+
<text fg={theme.muted}>{shortDate(dashboard.days[Math.floor(dashboard.days.length / 2)] ?? "")}</text>
|
|
121
|
+
<text fg={theme.muted}>{shortDate(dashboard.days.at(-1) ?? "")}</text>
|
|
122
|
+
</box>
|
|
123
|
+
</box>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ChartPoint({
|
|
128
|
+
dashboard,
|
|
129
|
+
metric,
|
|
130
|
+
index,
|
|
131
|
+
visibleSources,
|
|
132
|
+
pinned,
|
|
133
|
+
}: {
|
|
134
|
+
dashboard: Dashboard
|
|
135
|
+
metric: Metric
|
|
136
|
+
index: number
|
|
137
|
+
visibleSources: Set<SourceId>
|
|
138
|
+
pinned: boolean
|
|
139
|
+
}) {
|
|
140
|
+
const theme = useTheme()
|
|
141
|
+
const values = SOURCE_ORDER
|
|
142
|
+
.filter((source) => visibleSources.has(source))
|
|
143
|
+
.map((source) => ({ source, value: dashboard.series[source][index] ?? 0 }))
|
|
144
|
+
.filter(({ value }) => value > 0)
|
|
145
|
+
const total = values.reduce((sum, { value }) => sum + value, 0)
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<text height={1}>
|
|
149
|
+
<span fg={theme.text}>{shortDate(dashboard.days[index] ?? "")}</span>
|
|
150
|
+
<span fg={theme.muted}>{` ${formatMetric(total, metric)}`}</span>
|
|
151
|
+
{values.map(({ source, value }) => (
|
|
152
|
+
<span key={source} fg={theme.sources[source]}>
|
|
153
|
+
{` ${SOURCE_META[source].mark} ${formatMetric(value, metric)}`}
|
|
154
|
+
</span>
|
|
155
|
+
))}
|
|
156
|
+
{pinned ? <span fg={theme.muted}>{" pinned"}</span> : null}
|
|
157
|
+
</text>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useTheme } from "../ThemeContext"
|
|
2
|
+
|
|
3
|
+
export function Footer({ loading }: { loading: boolean }) {
|
|
4
|
+
const theme = useTheme()
|
|
5
|
+
return (
|
|
6
|
+
<box width="100%" flexDirection="row" justifyContent="space-between" backgroundColor={theme.panel} paddingX={1}>
|
|
7
|
+
<text fg={theme.muted}>c cost/tokens 1-4 range b breakdown r refresh q quit</text>
|
|
8
|
+
<text fg={loading ? theme.accent : theme.muted}>{loading ? "loading" : "local"}</text>
|
|
9
|
+
</box>
|
|
10
|
+
)
|
|
11
|
+
}
|