@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,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
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -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
|
+
}
|