@tokentop/ttop 0.2.2 → 0.3.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/README.md +2 -5
- package/package.json +8 -5
- package/src/agents/aggregator.ts +39 -19
- package/src/agents/costing.ts +11 -9
- package/src/agents/index.ts +7 -9
- package/src/agents/types.ts +2 -2
- package/src/cli.ts +28 -23
- package/src/config/schema.test.ts +42 -40
- package/src/config/schema.ts +26 -26
- package/src/demo/simulator.ts +161 -122
- package/src/plugins/agents/index.ts +2 -2
- package/src/plugins/auth-sources.ts +42 -38
- package/src/plugins/lifecycle.ts +32 -32
- package/src/plugins/loader.test.ts +49 -49
- package/src/plugins/loader.ts +67 -63
- package/src/plugins/notification-bus.ts +21 -16
- package/src/plugins/notifications/index.ts +2 -2
- package/src/plugins/notifications/terminal-bell.ts +29 -23
- package/src/plugins/notifications/visual-flash.ts +21 -19
- package/src/plugins/npm-installer.ts +23 -25
- package/src/plugins/plugin-context-factory.ts +10 -10
- package/src/plugins/plugin-host.ts +12 -5
- package/src/plugins/providers/anthropic.ts +64 -57
- package/src/plugins/providers/antigravity.ts +81 -69
- package/src/plugins/providers/codex.ts +42 -42
- package/src/plugins/providers/gemini.ts +106 -87
- package/src/plugins/providers/github-copilot.ts +49 -45
- package/src/plugins/providers/index.ts +10 -12
- package/src/plugins/providers/minimax.ts +50 -44
- package/src/plugins/providers/openai-api.ts +82 -62
- package/src/plugins/providers/opencode-zen.ts +46 -39
- package/src/plugins/providers/perplexity.ts +55 -46
- package/src/plugins/providers/zai.ts +66 -57
- package/src/plugins/registry.ts +47 -43
- package/src/plugins/sandbox-guard.test.ts +45 -48
- package/src/plugins/sandbox-guard.ts +20 -25
- package/src/plugins/sandbox.ts +26 -48
- package/src/plugins/themes/catppuccin-latte.ts +29 -29
- package/src/plugins/themes/catppuccin-mocha.ts +29 -29
- package/src/plugins/themes/claude-code.ts +29 -29
- package/src/plugins/themes/dracula.ts +29 -29
- package/src/plugins/themes/github-light.ts +29 -29
- package/src/plugins/themes/gruvbox-dark.ts +29 -29
- package/src/plugins/themes/gruvbox-light.ts +29 -29
- package/src/plugins/themes/index.ts +15 -15
- package/src/plugins/themes/kanagawa.ts +29 -29
- package/src/plugins/themes/nord.ts +29 -29
- package/src/plugins/themes/one-dark.ts +29 -29
- package/src/plugins/themes/opencode.ts +31 -31
- package/src/plugins/themes/rose-pine-dawn.ts +29 -29
- package/src/plugins/themes/rose-pine.ts +29 -29
- package/src/plugins/themes/solarized-light.ts +29 -29
- package/src/plugins/themes/tokyo-night.ts +29 -29
- package/src/plugins/types/agent.ts +5 -5
- package/src/plugins/types/base.ts +35 -24
- package/src/plugins/types/index.ts +9 -9
- package/src/plugins/types/notification.ts +12 -12
- package/src/plugins/types/provider.ts +12 -8
- package/src/plugins/types/theme.ts +31 -23
- package/src/plugins/update-checker.ts +6 -6
- package/src/pricing/estimator.test.ts +39 -44
- package/src/pricing/estimator.ts +7 -13
- package/src/pricing/fallback.test.ts +30 -34
- package/src/pricing/fallback.ts +54 -27
- package/src/pricing/index.ts +18 -12
- package/src/pricing/models-dev.ts +6 -6
- package/src/storage/database.ts +56 -46
- package/src/storage/db.ts +12 -12
- package/src/storage/index.ts +44 -45
- package/src/storage/migrations/index.ts +2 -2
- package/src/storage/paths.ts +13 -13
- package/src/storage/repos/agentSessions.ts +33 -23
- package/src/storage/repos/providerSnapshots.ts +5 -5
- package/src/storage/repos/usageEvents.ts +40 -28
- package/src/storage/types.ts +5 -5
- package/src/tui/App.tsx +180 -137
- package/src/tui/components/CommandPalette.tsx +46 -34
- package/src/tui/components/DebugConsole.tsx +39 -33
- package/src/tui/components/DebugPanel.tsx +295 -177
- package/src/tui/components/FooterHints.tsx +2 -2
- package/src/tui/components/GhostProviderCard.tsx +37 -35
- package/src/tui/components/Header.tsx +68 -35
- package/src/tui/components/HelpOverlay.tsx +99 -29
- package/src/tui/components/InlineGauge.tsx +10 -7
- package/src/tui/components/InlineSparkline.tsx +17 -11
- package/src/tui/components/KpiStrip.tsx +128 -98
- package/src/tui/components/LimitGauge.tsx +96 -59
- package/src/tui/components/ModalBackdrop.tsx +4 -4
- package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
- package/src/tui/components/ProviderCard.tsx +232 -226
- package/src/tui/components/ProviderDetailPanel.tsx +133 -58
- package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
- package/src/tui/components/ProvidersList.tsx +215 -126
- package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
- package/src/tui/components/SessionsTable.tsx +313 -166
- package/src/tui/components/SettingsModal.tsx +231 -214
- package/src/tui/components/SidebarBreakdown.tsx +30 -16
- package/src/tui/components/Skeleton.tsx +24 -17
- package/src/tui/components/SmartSidebar.tsx +360 -258
- package/src/tui/components/Sparkline.tsx +64 -52
- package/src/tui/components/Spinner.tsx +3 -3
- package/src/tui/components/StatusBar.tsx +27 -27
- package/src/tui/components/ThemePicker.tsx +67 -66
- package/src/tui/components/Toast.tsx +9 -6
- package/src/tui/components/UsageGauge.tsx +10 -13
- package/src/tui/components/index.ts +5 -5
- package/src/tui/contexts/AgentSessionContext.tsx +193 -144
- package/src/tui/contexts/ConfigContext.tsx +15 -13
- package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
- package/src/tui/contexts/DemoModeContext.tsx +22 -15
- package/src/tui/contexts/DrawerContext.tsx +11 -9
- package/src/tui/contexts/InputContext.tsx +2 -2
- package/src/tui/contexts/LogContext.tsx +62 -47
- package/src/tui/contexts/PluginContext.tsx +232 -183
- package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
- package/src/tui/contexts/StorageContext.tsx +151 -117
- package/src/tui/contexts/ThemeContext.tsx +21 -16
- package/src/tui/contexts/TimeWindowContext.tsx +72 -61
- package/src/tui/contexts/ToastContext.tsx +10 -7
- package/src/tui/contexts/index.ts +9 -5
- package/src/tui/createApp.tsx +8 -12
- package/src/tui/debug/captureFrame.ts +25 -28
- package/src/tui/debug/snapshot.tsx +179 -162
- package/src/tui/driver/assertions.ts +30 -22
- package/src/tui/driver/cli.ts +231 -221
- package/src/tui/driver/coverage.ts +27 -44
- package/src/tui/driver/demo-snapshot.ts +33 -33
- package/src/tui/driver/diff.ts +26 -32
- package/src/tui/driver/driver.ts +43 -48
- package/src/tui/driver/index.ts +36 -40
- package/src/tui/driver/recorder.ts +37 -48
- package/src/tui/driver/test-workflow.ts +51 -51
- package/src/tui/driver/test.ts +25 -25
- package/src/tui/hooks/index.ts +24 -20
- package/src/tui/hooks/useAnimatedValue.ts +13 -18
- package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
- package/src/tui/hooks/useDashboardState.ts +26 -24
- package/src/tui/hooks/useEmaActivity.ts +82 -74
- package/src/tui/hooks/useEntranceAnimation.ts +11 -11
- package/src/tui/hooks/useExitAnimation.ts +3 -3
- package/src/tui/hooks/usePulse.ts +4 -4
- package/src/tui/hooks/useSafeRenderer.ts +5 -5
- package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
- package/src/tui/hooks/useValueFlash.ts +10 -14
- package/src/tui/index.tsx +29 -6
- package/src/tui/shutdown.ts +19 -0
- package/src/tui/utils/providerColor.ts +1 -1
- package/src/tui/views/Dashboard.tsx +127 -102
- package/src/tui/views/HistoricalTrendsView.tsx +579 -250
- package/src/tui/views/ProjectsView.tsx +466 -237
- package/src/tui/views/RealTimeDashboard.tsx +150 -111
- package/src/tui/views/SettingsView.tsx +222 -191
- package/src/tui/views/index.ts +2 -2
- package/src/types/opentui-test-utils.d.ts +7 -7
- package/src/utils/clipboard.ts +29 -29
- package/src/utils/currency.test.ts +53 -53
- package/src/utils/currency.ts +35 -32
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -11,8 +11,7 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
11
11
|
|
|
12
12
|

|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
<!-- [](https://github.com/tokentopapp/tokentop/actions) -->
|
|
14
|
+
[](https://github.com/tokentopapp/tokentop/actions)
|
|
16
15
|
[](LICENSE)
|
|
17
16
|
[](https://github.com/tokentopapp/tokentop/releases)
|
|
18
17
|
[](https://www.typescriptlang.org/)
|
|
@@ -37,11 +36,9 @@ That's tokentop.
|
|
|
37
36
|
- Set **budget alerts** so you don't wake up to a surprise bill
|
|
38
37
|
- Runs **100% locally** — your usage data never leaves your machine
|
|
39
38
|
|
|
40
|
-
<!-- TODO: Replace with VHS recording
|
|
41
39
|
## Demo
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
-->
|
|
41
|
+
https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
|
|
45
42
|
|
|
46
43
|
## Features
|
|
47
44
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokentop/ttop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Real-time AI token usage monitor - htop for your API costs",
|
|
5
5
|
"author": "Nigel Bazzeghin <nbazzeghin@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"start": "bun src/cli.ts",
|
|
24
24
|
"build": "bun build ./src/cli.ts --outdir=dist --target=bun --minify",
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
|
-
"lint": "
|
|
26
|
+
"lint": "biome check src/",
|
|
27
|
+
"format": "biome format src/",
|
|
28
|
+
"lint:fix": "biome check --write src/",
|
|
27
29
|
"test": "bun test",
|
|
28
30
|
"test:watch": "bun test --watch",
|
|
29
31
|
"driver": "bun src/tui/driver/cli.ts",
|
|
@@ -31,16 +33,17 @@
|
|
|
31
33
|
"snapshot": "bun src/tui/debug/snapshot.tsx"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@opentui/core": "0.1.
|
|
35
|
-
"@opentui/react": "0.1.
|
|
36
|
+
"@opentui/core": "0.1.80",
|
|
37
|
+
"@opentui/react": "0.1.80",
|
|
36
38
|
"@tokentop/agent-claude-code": "^1.1.0",
|
|
37
39
|
"@tokentop/agent-opencode": "^1.0.0",
|
|
38
40
|
"@tokentop/plugin-sdk": "^1.1.0",
|
|
39
41
|
"react": "^19.0.0",
|
|
40
42
|
"react-devtools-core": "^7.0.1",
|
|
41
|
-
"zod": "^
|
|
43
|
+
"zod": "^4.0.0"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
|
46
|
+
"@biomejs/biome": "^2.4.2",
|
|
44
47
|
"@types/bun": "latest",
|
|
45
48
|
"@types/react": "^19.0.0"
|
|
46
49
|
},
|
package/src/agents/aggregator.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { SessionUsageData } from
|
|
1
|
+
import type { SessionUsageData } from "@/plugins/types/agent.ts";
|
|
2
2
|
import {
|
|
3
|
-
totalTokenCount,
|
|
4
3
|
type AgentId,
|
|
5
4
|
type AgentName,
|
|
6
5
|
type AgentSessionAggregate,
|
|
7
6
|
type AgentSessionStream,
|
|
8
7
|
type StreamWindowedTokens,
|
|
9
8
|
type TokenCounts,
|
|
10
|
-
|
|
9
|
+
totalTokenCount,
|
|
10
|
+
} from "./types.ts";
|
|
11
11
|
|
|
12
12
|
const ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
13
13
|
|
|
@@ -33,21 +33,29 @@ function sumTokens(a: TokenCounts, b: TokenCounts): TokenCounts {
|
|
|
33
33
|
input: a.input + b.input,
|
|
34
34
|
output: a.output + b.output,
|
|
35
35
|
};
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
const cacheRead = (a.cacheRead ?? 0) + (b.cacheRead ?? 0);
|
|
38
38
|
if (cacheRead > 0) result.cacheRead = cacheRead;
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
const cacheWrite = (a.cacheWrite ?? 0) + (b.cacheWrite ?? 0);
|
|
41
41
|
if (cacheWrite > 0) result.cacheWrite = cacheWrite;
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
return result;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function computeWindowBoundaries(now: number) {
|
|
47
47
|
const nowDate = new Date(now);
|
|
48
|
-
const startOfDay = new Date(
|
|
48
|
+
const startOfDay = new Date(
|
|
49
|
+
nowDate.getFullYear(),
|
|
50
|
+
nowDate.getMonth(),
|
|
51
|
+
nowDate.getDate(),
|
|
52
|
+
).getTime();
|
|
49
53
|
const dayOfWeek = nowDate.getDay();
|
|
50
|
-
const startOfWeek = new Date(
|
|
54
|
+
const startOfWeek = new Date(
|
|
55
|
+
nowDate.getFullYear(),
|
|
56
|
+
nowDate.getMonth(),
|
|
57
|
+
nowDate.getDate() - dayOfWeek,
|
|
58
|
+
).getTime();
|
|
51
59
|
const startOfMonth = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1).getTime();
|
|
52
60
|
return { startOfDay, startOfWeek, startOfMonth };
|
|
53
61
|
}
|
|
@@ -60,16 +68,25 @@ interface StreamAccumulator {
|
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAggregate[] {
|
|
63
|
-
const {
|
|
71
|
+
const {
|
|
72
|
+
agentId,
|
|
73
|
+
agentName,
|
|
74
|
+
rows,
|
|
75
|
+
now = Date.now(),
|
|
76
|
+
activeThresholdMs = ACTIVE_THRESHOLD_MS,
|
|
77
|
+
} = options;
|
|
64
78
|
const { startOfDay, startOfWeek, startOfMonth } = computeWindowBoundaries(now);
|
|
65
79
|
|
|
66
|
-
const sessionMap = new Map<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
const sessionMap = new Map<
|
|
81
|
+
string,
|
|
82
|
+
{
|
|
83
|
+
sessionName?: string;
|
|
84
|
+
projectPath?: string;
|
|
85
|
+
timestamps: number[];
|
|
86
|
+
sessionUpdatedAt?: number;
|
|
87
|
+
streamMap: Map<string, StreamAccumulator>;
|
|
88
|
+
}
|
|
89
|
+
>();
|
|
73
90
|
|
|
74
91
|
for (const row of rows) {
|
|
75
92
|
const existing = sessionMap.get(row.sessionId);
|
|
@@ -89,7 +106,7 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
89
106
|
if (row.sessionUpdatedAt) newSession.sessionUpdatedAt = row.sessionUpdatedAt;
|
|
90
107
|
sessionMap.set(row.sessionId, newSession);
|
|
91
108
|
}
|
|
92
|
-
|
|
109
|
+
|
|
93
110
|
const session = sessionMap.get(row.sessionId)!;
|
|
94
111
|
|
|
95
112
|
session.timestamps.push(row.timestamp);
|
|
@@ -99,7 +116,10 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
99
116
|
if (row.projectPath && !session.projectPath) {
|
|
100
117
|
session.projectPath = row.projectPath;
|
|
101
118
|
}
|
|
102
|
-
if (
|
|
119
|
+
if (
|
|
120
|
+
row.sessionUpdatedAt &&
|
|
121
|
+
(!session.sessionUpdatedAt || row.sessionUpdatedAt > session.sessionUpdatedAt)
|
|
122
|
+
) {
|
|
103
123
|
session.sessionUpdatedAt = row.sessionUpdatedAt;
|
|
104
124
|
}
|
|
105
125
|
|
|
@@ -133,7 +153,7 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
133
153
|
const startedAt = Math.min(...session.timestamps);
|
|
134
154
|
const lastActivityAt = Math.max(...session.timestamps);
|
|
135
155
|
const lastSeenAt = session.sessionUpdatedAt ?? lastActivityAt;
|
|
136
|
-
const status =
|
|
156
|
+
const status = now - lastSeenAt <= activeThresholdMs ? "active" : "idle";
|
|
137
157
|
|
|
138
158
|
const streams: AgentSessionStream[] = [];
|
|
139
159
|
const streamWindowedTokens = new Map<string, StreamWindowedTokens>();
|
package/src/agents/costing.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { AgentSessionAggregate, AgentSessionStream, StreamCostBreakdown } from
|
|
1
|
+
import { estimateCost, getPricing } from "@/pricing/index.ts";
|
|
2
|
+
import type { AgentSessionAggregate, AgentSessionStream, StreamCostBreakdown } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
export async function priceStream(stream: AgentSessionStream): Promise<AgentSessionStream> {
|
|
5
5
|
const pricing = await getPricing(stream.providerId, stream.modelId);
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
if (!pricing) {
|
|
8
|
-
return { ...stream, pricingSource:
|
|
8
|
+
return { ...stream, pricingSource: "unknown" };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const breakdown = estimateCost(stream.tokens, pricing);
|
|
12
|
-
const source = pricing.source ===
|
|
13
|
-
|
|
12
|
+
const source = pricing.source === "models.dev" ? "models.dev" : "fallback";
|
|
13
|
+
|
|
14
14
|
const costBreakdown: StreamCostBreakdown = {
|
|
15
15
|
total: breakdown.total,
|
|
16
16
|
input: breakdown.input ?? 0,
|
|
@@ -29,12 +29,12 @@ export async function priceStream(stream: AgentSessionStream): Promise<AgentSess
|
|
|
29
29
|
|
|
30
30
|
export async function priceSession(session: AgentSessionAggregate): Promise<AgentSessionAggregate> {
|
|
31
31
|
const pricedStreams = await Promise.all(session.streams.map(priceStream));
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
const totalCostUsd = pricedStreams.reduce((sum, s) => {
|
|
34
34
|
return sum + (s.costUsd ?? 0);
|
|
35
35
|
}, 0);
|
|
36
36
|
|
|
37
|
-
const hasAnyCost = pricedStreams.some(s => s.costUsd !== undefined);
|
|
37
|
+
const hasAnyCost = pricedStreams.some((s) => s.costUsd !== undefined);
|
|
38
38
|
|
|
39
39
|
let costInDay = 0;
|
|
40
40
|
let costInWeek = 0;
|
|
@@ -69,6 +69,8 @@ export async function priceSession(session: AgentSessionAggregate): Promise<Agen
|
|
|
69
69
|
return result;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
export async function priceSessions(
|
|
72
|
+
export async function priceSessions(
|
|
73
|
+
sessions: AgentSessionAggregate[],
|
|
74
|
+
): Promise<AgentSessionAggregate[]> {
|
|
73
75
|
return Promise.all(sessions.map(priceSession));
|
|
74
76
|
}
|
package/src/agents/index.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
+
export { aggregateSessionUsage } from "./aggregator.ts";
|
|
2
|
+
export { priceSession, priceSessions, priceStream } from "./costing.ts";
|
|
1
3
|
export type {
|
|
2
|
-
AgentName,
|
|
3
4
|
AgentId,
|
|
4
|
-
TokenCounts,
|
|
5
|
-
AgentSessionStream,
|
|
6
|
-
AgentSessionAggregate,
|
|
7
5
|
AgentInfo,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
AgentName,
|
|
7
|
+
AgentSessionAggregate,
|
|
8
|
+
AgentSessionStream,
|
|
9
|
+
TokenCounts,
|
|
10
|
+
} from "./types.ts";
|
package/src/agents/types.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface AgentSessionStream {
|
|
|
29
29
|
requestCount: number;
|
|
30
30
|
costUsd?: number;
|
|
31
31
|
costBreakdown?: StreamCostBreakdown;
|
|
32
|
-
pricingSource?:
|
|
32
|
+
pricingSource?: "models.dev" | "fallback" | "unknown";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export interface StreamWindowedTokens {
|
|
@@ -48,7 +48,7 @@ export interface AgentSessionAggregate {
|
|
|
48
48
|
startedAt: number;
|
|
49
49
|
lastActivityAt: number;
|
|
50
50
|
endedAt?: number;
|
|
51
|
-
status:
|
|
51
|
+
status: "active" | "idle";
|
|
52
52
|
totals: TokenCounts;
|
|
53
53
|
totalCostUsd?: number;
|
|
54
54
|
requestCount: number;
|
package/src/cli.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import * as path from
|
|
3
|
-
import {
|
|
4
|
-
import type
|
|
5
|
-
import {
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { DemoPreset } from "./demo/simulator.ts";
|
|
4
|
+
import { startTui, type TuiOptions } from "./tui/index.tsx";
|
|
5
|
+
import { triggerShutdown } from "./tui/shutdown.ts";
|
|
6
|
+
import { VERSION } from "./version.ts";
|
|
6
7
|
|
|
7
|
-
const DEMO_PRESETS: DemoPreset[] = [
|
|
8
|
+
const DEMO_PRESETS: DemoPreset[] = ["light", "normal", "heavy"];
|
|
8
9
|
|
|
9
10
|
function printHelp() {
|
|
10
11
|
console.log(`
|
|
@@ -57,11 +58,11 @@ function printVersion() {
|
|
|
57
58
|
|
|
58
59
|
async function main() {
|
|
59
60
|
const args = process.argv.slice(2);
|
|
60
|
-
|
|
61
|
+
|
|
61
62
|
// Check for subcommands first
|
|
62
|
-
const subcommand = args[0] && !args[0].startsWith(
|
|
63
|
+
const subcommand = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
63
64
|
const commandArgs = subcommand ? args.slice(1) : args;
|
|
64
|
-
|
|
65
|
+
|
|
65
66
|
let refreshInterval = 60000;
|
|
66
67
|
let debug = false;
|
|
67
68
|
let demo = false;
|
|
@@ -70,7 +71,7 @@ async function main() {
|
|
|
70
71
|
const cliPlugins: string[] = [];
|
|
71
72
|
|
|
72
73
|
// Handle demo subcommand
|
|
73
|
-
if (subcommand ===
|
|
74
|
+
if (subcommand === "demo") {
|
|
74
75
|
demo = true;
|
|
75
76
|
} else if (subcommand !== null) {
|
|
76
77
|
console.error(`Error: Unknown command "${subcommand}"`);
|
|
@@ -81,24 +82,24 @@ async function main() {
|
|
|
81
82
|
for (let i = 0; i < commandArgs.length; i++) {
|
|
82
83
|
const arg = commandArgs[i];
|
|
83
84
|
|
|
84
|
-
if (arg ===
|
|
85
|
+
if (arg === "-h" || arg === "--help") {
|
|
85
86
|
printHelp();
|
|
86
87
|
process.exit(0);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
if (arg ===
|
|
90
|
+
if (arg === "-v" || arg === "--version") {
|
|
90
91
|
printVersion();
|
|
91
92
|
process.exit(0);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
if (arg ===
|
|
95
|
+
if (arg === "-d" || arg === "--debug") {
|
|
95
96
|
debug = true;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
if (arg ===
|
|
99
|
+
if (arg === "-p" || arg === "--plugin") {
|
|
99
100
|
const pluginPath = commandArgs[++i];
|
|
100
101
|
if (!pluginPath) {
|
|
101
|
-
console.error(
|
|
102
|
+
console.error("Error: --plugin requires a path");
|
|
102
103
|
process.exit(1);
|
|
103
104
|
}
|
|
104
105
|
const resolved = path.resolve(pluginPath);
|
|
@@ -106,38 +107,38 @@ async function main() {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
// Demo subcommand options
|
|
109
|
-
if (arg ===
|
|
110
|
+
if (arg === "--seed") {
|
|
110
111
|
if (!demo) {
|
|
111
|
-
console.error(
|
|
112
|
+
console.error("Error: --seed can only be used with the demo command");
|
|
112
113
|
process.exit(1);
|
|
113
114
|
}
|
|
114
115
|
const seedStr = commandArgs[++i];
|
|
115
116
|
const seed = seedStr ? parseInt(seedStr, 10) : NaN;
|
|
116
117
|
if (isNaN(seed)) {
|
|
117
|
-
console.error(
|
|
118
|
+
console.error("Error: --seed requires a number");
|
|
118
119
|
process.exit(1);
|
|
119
120
|
}
|
|
120
121
|
demoSeed = seed;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
if (arg ===
|
|
124
|
+
if (arg === "--preset") {
|
|
124
125
|
if (!demo) {
|
|
125
|
-
console.error(
|
|
126
|
+
console.error("Error: --preset can only be used with the demo command");
|
|
126
127
|
process.exit(1);
|
|
127
128
|
}
|
|
128
129
|
const preset = commandArgs[++i] as DemoPreset;
|
|
129
130
|
if (!preset || !DEMO_PRESETS.includes(preset)) {
|
|
130
|
-
console.error(`Error: --preset must be one of: ${DEMO_PRESETS.join(
|
|
131
|
+
console.error(`Error: --preset must be one of: ${DEMO_PRESETS.join(", ")}`);
|
|
131
132
|
process.exit(1);
|
|
132
133
|
}
|
|
133
134
|
demoPreset = preset;
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
if (arg ===
|
|
137
|
+
if (arg === "-r" || arg === "--refresh") {
|
|
137
138
|
const intervalStr = commandArgs[++i];
|
|
138
139
|
const interval = intervalStr ? parseInt(intervalStr, 10) : NaN;
|
|
139
140
|
if (isNaN(interval) || interval < 1000) {
|
|
140
|
-
console.error(
|
|
141
|
+
console.error("Error: --refresh must be a number >= 1000");
|
|
141
142
|
process.exit(1);
|
|
142
143
|
}
|
|
143
144
|
refreshInterval = interval;
|
|
@@ -159,9 +160,13 @@ async function main() {
|
|
|
159
160
|
launchOptions.demoPreset = demoPreset;
|
|
160
161
|
}
|
|
161
162
|
await startTui(launchOptions);
|
|
163
|
+
|
|
164
|
+
const handleSignal = () => void triggerShutdown();
|
|
165
|
+
process.on("SIGINT", handleSignal);
|
|
166
|
+
process.on("SIGTERM", handleSignal);
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
main().catch((err) => {
|
|
165
|
-
console.error(
|
|
170
|
+
console.error("Fatal error:", err);
|
|
166
171
|
process.exit(1);
|
|
167
172
|
});
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
3
|
|
|
4
|
-
import { DEFAULT_CONFIG, loadConfig } from
|
|
4
|
+
import { DEFAULT_CONFIG, loadConfig } from "./schema.ts";
|
|
5
5
|
|
|
6
|
-
const readFileSpies: Array<ReturnType<typeof spyOn<typeof fs,
|
|
6
|
+
const readFileSpies: Array<ReturnType<typeof spyOn<typeof fs, "readFile">>> = [];
|
|
7
7
|
|
|
8
8
|
function mockReadFileWithJson(config: unknown) {
|
|
9
|
-
const spy = spyOn(fs,
|
|
9
|
+
const spy = spyOn(fs, "readFile");
|
|
10
10
|
spy.mockResolvedValue(JSON.stringify(config));
|
|
11
11
|
readFileSpies.push(spy);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function mockReadFileWithRaw(content: string) {
|
|
15
|
-
const spy = spyOn(fs,
|
|
15
|
+
const spy = spyOn(fs, "readFile");
|
|
16
16
|
spy.mockResolvedValue(content);
|
|
17
17
|
readFileSpies.push(spy);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function mockReadFileError(error: Error) {
|
|
21
|
-
const spy = spyOn(fs,
|
|
21
|
+
const spy = spyOn(fs, "readFile");
|
|
22
22
|
spy.mockRejectedValue(error);
|
|
23
23
|
readFileSpies.push(spy);
|
|
24
24
|
}
|
|
@@ -30,28 +30,28 @@ afterEach(() => {
|
|
|
30
30
|
readFileSpies.length = 0;
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
describe(
|
|
34
|
-
test(
|
|
33
|
+
describe("DEFAULT_CONFIG", () => {
|
|
34
|
+
test("has expected top-level structure", () => {
|
|
35
35
|
expect(Object.keys(DEFAULT_CONFIG).sort()).toEqual(
|
|
36
36
|
[
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
].sort()
|
|
37
|
+
"alerts",
|
|
38
|
+
"budgets",
|
|
39
|
+
"configVersion",
|
|
40
|
+
"display",
|
|
41
|
+
"notifications",
|
|
42
|
+
"pluginConfig",
|
|
43
|
+
"plugins",
|
|
44
|
+
"providers",
|
|
45
|
+
"refresh",
|
|
46
|
+
].sort(),
|
|
47
47
|
);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test(
|
|
50
|
+
test("uses expected default values", () => {
|
|
51
51
|
expect(DEFAULT_CONFIG.refresh.intervalMs).toBe(60000);
|
|
52
|
-
expect(DEFAULT_CONFIG.display.defaultTimeWindow).toBe(
|
|
53
|
-
expect(DEFAULT_CONFIG.display.theme).toBe(
|
|
54
|
-
expect(DEFAULT_CONFIG.display.colorScheme).toBe(
|
|
52
|
+
expect(DEFAULT_CONFIG.display.defaultTimeWindow).toBe("1h");
|
|
53
|
+
expect(DEFAULT_CONFIG.display.theme).toBe("tokyo-night");
|
|
54
|
+
expect(DEFAULT_CONFIG.display.colorScheme).toBe("auto");
|
|
55
55
|
|
|
56
56
|
expect(DEFAULT_CONFIG.budgets.daily).toBeNull();
|
|
57
57
|
expect(DEFAULT_CONFIG.budgets.weekly).toBeNull();
|
|
@@ -66,21 +66,21 @@ describe('DEFAULT_CONFIG', () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
describe(
|
|
69
|
+
describe("loadConfig", () => {
|
|
70
70
|
test("returns defaults when config file doesn't exist", async () => {
|
|
71
|
-
mockReadFileError(new Error(
|
|
71
|
+
mockReadFileError(new Error("ENOENT"));
|
|
72
72
|
|
|
73
73
|
const loaded = await loadConfig();
|
|
74
74
|
|
|
75
75
|
expect(loaded).toEqual(DEFAULT_CONFIG);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
test(
|
|
78
|
+
test("merges partial overrides with defaults", async () => {
|
|
79
79
|
mockReadFileWithJson({
|
|
80
80
|
refresh: { intervalMs: 15000 },
|
|
81
81
|
budgets: { daily: 25 },
|
|
82
82
|
alerts: { warningPercent: 70 },
|
|
83
|
-
plugins: { local: [
|
|
83
|
+
plugins: { local: ["~/plugins/local-theme.ts"] },
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
const loaded = await loadConfig();
|
|
@@ -91,40 +91,42 @@ describe('loadConfig', () => {
|
|
|
91
91
|
expect(loaded.budgets.weekly).toBeNull();
|
|
92
92
|
expect(loaded.alerts.warningPercent).toBe(70);
|
|
93
93
|
expect(loaded.alerts.criticalPercent).toBe(DEFAULT_CONFIG.alerts.criticalPercent);
|
|
94
|
-
expect(loaded.plugins.local).toEqual([
|
|
94
|
+
expect(loaded.plugins.local).toEqual(["~/plugins/local-theme.ts"]);
|
|
95
95
|
expect(loaded.plugins.npm).toEqual([]);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
test(
|
|
98
|
+
test("applies nested display overrides without dropping sibling defaults", async () => {
|
|
99
99
|
mockReadFileWithJson({
|
|
100
|
-
display: { theme:
|
|
100
|
+
display: { theme: "dracula" },
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
const loaded = await loadConfig();
|
|
104
104
|
|
|
105
|
-
expect(loaded.display.theme).toBe(
|
|
105
|
+
expect(loaded.display.theme).toBe("dracula");
|
|
106
106
|
expect(loaded.display.defaultTimeWindow).toBe(DEFAULT_CONFIG.display.defaultTimeWindow);
|
|
107
107
|
expect(loaded.display.colorScheme).toBe(DEFAULT_CONFIG.display.colorScheme);
|
|
108
108
|
expect(loaded.display.timeFormat).toBe(DEFAULT_CONFIG.display.timeFormat);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
test(
|
|
111
|
+
test("deep merges display.sparkline.style while preserving other sparkline defaults", async () => {
|
|
112
112
|
mockReadFileWithJson({
|
|
113
113
|
display: {
|
|
114
114
|
sparkline: {
|
|
115
|
-
style:
|
|
115
|
+
style: "block",
|
|
116
116
|
},
|
|
117
117
|
},
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
const loaded = await loadConfig();
|
|
121
121
|
|
|
122
|
-
expect(loaded.display.sparkline.style).toBe(
|
|
122
|
+
expect(loaded.display.sparkline.style).toBe("block");
|
|
123
123
|
expect(loaded.display.sparkline.orientation).toBe(DEFAULT_CONFIG.display.sparkline.orientation);
|
|
124
|
-
expect(loaded.display.sparkline.showBaseline).toBe(
|
|
124
|
+
expect(loaded.display.sparkline.showBaseline).toBe(
|
|
125
|
+
DEFAULT_CONFIG.display.sparkline.showBaseline,
|
|
126
|
+
);
|
|
125
127
|
});
|
|
126
128
|
|
|
127
|
-
test(
|
|
129
|
+
test("falls back to defaults for invalid JSON", async () => {
|
|
128
130
|
mockReadFileWithRaw(`{
|
|
129
131
|
// comments are not valid JSON
|
|
130
132
|
"display": { "theme": "dracula" }
|
|
@@ -134,7 +136,7 @@ describe('loadConfig', () => {
|
|
|
134
136
|
expect(loaded.display.theme).toBe(DEFAULT_CONFIG.display.theme);
|
|
135
137
|
});
|
|
136
138
|
|
|
137
|
-
test(
|
|
139
|
+
test("parses valid JSON with budget overrides", async () => {
|
|
138
140
|
mockReadFileWithRaw(`{
|
|
139
141
|
"budgets": { "daily": 100 }
|
|
140
142
|
}`);
|
|
@@ -143,7 +145,7 @@ describe('loadConfig', () => {
|
|
|
143
145
|
expect(loaded.budgets.daily).toBe(100);
|
|
144
146
|
});
|
|
145
147
|
|
|
146
|
-
test(
|
|
148
|
+
test("parses valid JSON with multiple overrides", async () => {
|
|
147
149
|
mockReadFileWithRaw(`{
|
|
148
150
|
"budgets": { "daily": 50, "weekly": 200 },
|
|
149
151
|
"alerts": { "warningPercent": 90 }
|
|
@@ -155,12 +157,12 @@ describe('loadConfig', () => {
|
|
|
155
157
|
expect(loaded.alerts.warningPercent).toBe(90);
|
|
156
158
|
});
|
|
157
159
|
|
|
158
|
-
test(
|
|
160
|
+
test("does not strip // inside string values", async () => {
|
|
159
161
|
mockReadFileWithRaw(`{
|
|
160
162
|
"plugins": { "local": ["~/my-plugin // v2"] }
|
|
161
163
|
}`);
|
|
162
164
|
|
|
163
165
|
const loaded = await loadConfig();
|
|
164
|
-
expect(loaded.plugins.local).toEqual([
|
|
166
|
+
expect(loaded.plugins.local).toEqual(["~/my-plugin // v2"]);
|
|
165
167
|
});
|
|
166
168
|
});
|