@tokentop/ttop 0.2.2
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 +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type AgentName = string;
|
|
2
|
+
|
|
3
|
+
export type AgentId = string;
|
|
4
|
+
|
|
5
|
+
export interface TokenCounts {
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead?: number;
|
|
9
|
+
cacheWrite?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Sum all token categories (input + cacheRead + cacheWrite + output). */
|
|
13
|
+
export function totalTokenCount(t: TokenCounts): number {
|
|
14
|
+
return t.input + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0) + t.output;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StreamCostBreakdown {
|
|
18
|
+
total: number;
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead?: number;
|
|
22
|
+
cacheWrite?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AgentSessionStream {
|
|
26
|
+
providerId: string;
|
|
27
|
+
modelId: string;
|
|
28
|
+
tokens: TokenCounts;
|
|
29
|
+
requestCount: number;
|
|
30
|
+
costUsd?: number;
|
|
31
|
+
costBreakdown?: StreamCostBreakdown;
|
|
32
|
+
pricingSource?: 'models.dev' | 'fallback' | 'unknown';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StreamWindowedTokens {
|
|
36
|
+
dayTokens: number;
|
|
37
|
+
weekTokens: number;
|
|
38
|
+
monthTokens: number;
|
|
39
|
+
totalTokens: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AgentSessionAggregate {
|
|
43
|
+
sessionId: string;
|
|
44
|
+
sessionName?: string;
|
|
45
|
+
agentId: AgentId;
|
|
46
|
+
agentName: AgentName;
|
|
47
|
+
projectPath?: string;
|
|
48
|
+
startedAt: number;
|
|
49
|
+
lastActivityAt: number;
|
|
50
|
+
endedAt?: number;
|
|
51
|
+
status: 'active' | 'idle';
|
|
52
|
+
totals: TokenCounts;
|
|
53
|
+
totalCostUsd?: number;
|
|
54
|
+
requestCount: number;
|
|
55
|
+
streams: AgentSessionStream[];
|
|
56
|
+
|
|
57
|
+
costInDay: number;
|
|
58
|
+
costInWeek: number;
|
|
59
|
+
costInMonth: number;
|
|
60
|
+
|
|
61
|
+
/** @internal Keyed by `providerId::modelId`. Set by aggregator, consumed by costing. */
|
|
62
|
+
_streamWindowedTokens?: Map<string, StreamWindowedTokens>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AgentInfo {
|
|
66
|
+
agentId: AgentId;
|
|
67
|
+
name: AgentName;
|
|
68
|
+
installed: boolean;
|
|
69
|
+
sessionParsingSupported: boolean;
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { startTui, type TuiOptions } from './tui/index.tsx';
|
|
4
|
+
import type { DemoPreset } from './demo/simulator.ts';
|
|
5
|
+
import { VERSION } from './version.ts';
|
|
6
|
+
|
|
7
|
+
const DEMO_PRESETS: DemoPreset[] = ['light', 'normal', 'heavy'];
|
|
8
|
+
|
|
9
|
+
function printHelp() {
|
|
10
|
+
console.log(`
|
|
11
|
+
tokentop - htop for AI API usage
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
ttop [options]
|
|
15
|
+
ttop demo [options]
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
demo Run with demo data (no real providers)
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
-r, --refresh <ms> Refresh interval in milliseconds (default: 60000)
|
|
22
|
+
-p, --plugin <path> Load a local plugin (file or directory, repeatable)
|
|
23
|
+
-d, --debug Enable debug mode (verbose logging)
|
|
24
|
+
-h, --help Show this help message
|
|
25
|
+
-v, --version Show version
|
|
26
|
+
|
|
27
|
+
Demo Options:
|
|
28
|
+
--seed <number> Seed for deterministic demo data
|
|
29
|
+
--preset <preset> Demo intensity: light, normal, heavy (default: normal)
|
|
30
|
+
|
|
31
|
+
Keyboard shortcuts (in TUI):
|
|
32
|
+
q Quit
|
|
33
|
+
r Refresh all providers
|
|
34
|
+
d Toggle debug console
|
|
35
|
+
|
|
36
|
+
Debug console shortcuts:
|
|
37
|
+
ESC Close debug console
|
|
38
|
+
c Clear logs
|
|
39
|
+
x Export logs to file (~/.local/share/tokentop/logs/)
|
|
40
|
+
y Copy all logs to clipboard
|
|
41
|
+
[select text] Copies to clipboard on mouse release
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
ttop Start with default settings
|
|
45
|
+
ttop -r 30000 Refresh every 30 seconds
|
|
46
|
+
ttop -p ./my-plugin Load a local plugin from a directory
|
|
47
|
+
ttop -d Start with debug logging enabled
|
|
48
|
+
ttop demo Start in demo mode
|
|
49
|
+
ttop demo --seed 42 Demo mode with seed 42 (reproducible)
|
|
50
|
+
ttop demo --preset heavy Demo mode with high activity
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printVersion() {
|
|
55
|
+
console.log(`tokentop v${VERSION}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
const args = process.argv.slice(2);
|
|
60
|
+
|
|
61
|
+
// Check for subcommands first
|
|
62
|
+
const subcommand = args[0] && !args[0].startsWith('-') ? args[0] : null;
|
|
63
|
+
const commandArgs = subcommand ? args.slice(1) : args;
|
|
64
|
+
|
|
65
|
+
let refreshInterval = 60000;
|
|
66
|
+
let debug = false;
|
|
67
|
+
let demo = false;
|
|
68
|
+
let demoSeed: number | undefined;
|
|
69
|
+
let demoPreset: DemoPreset | undefined;
|
|
70
|
+
const cliPlugins: string[] = [];
|
|
71
|
+
|
|
72
|
+
// Handle demo subcommand
|
|
73
|
+
if (subcommand === 'demo') {
|
|
74
|
+
demo = true;
|
|
75
|
+
} else if (subcommand !== null) {
|
|
76
|
+
console.error(`Error: Unknown command "${subcommand}"`);
|
|
77
|
+
console.error('Run "ttop --help" for usage information');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < commandArgs.length; i++) {
|
|
82
|
+
const arg = commandArgs[i];
|
|
83
|
+
|
|
84
|
+
if (arg === '-h' || arg === '--help') {
|
|
85
|
+
printHelp();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (arg === '-v' || arg === '--version') {
|
|
90
|
+
printVersion();
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (arg === '-d' || arg === '--debug') {
|
|
95
|
+
debug = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (arg === '-p' || arg === '--plugin') {
|
|
99
|
+
const pluginPath = commandArgs[++i];
|
|
100
|
+
if (!pluginPath) {
|
|
101
|
+
console.error('Error: --plugin requires a path');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const resolved = path.resolve(pluginPath);
|
|
105
|
+
cliPlugins.push(resolved);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Demo subcommand options
|
|
109
|
+
if (arg === '--seed') {
|
|
110
|
+
if (!demo) {
|
|
111
|
+
console.error('Error: --seed can only be used with the demo command');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
const seedStr = commandArgs[++i];
|
|
115
|
+
const seed = seedStr ? parseInt(seedStr, 10) : NaN;
|
|
116
|
+
if (isNaN(seed)) {
|
|
117
|
+
console.error('Error: --seed requires a number');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
demoSeed = seed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (arg === '--preset') {
|
|
124
|
+
if (!demo) {
|
|
125
|
+
console.error('Error: --preset can only be used with the demo command');
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
const preset = commandArgs[++i] as DemoPreset;
|
|
129
|
+
if (!preset || !DEMO_PRESETS.includes(preset)) {
|
|
130
|
+
console.error(`Error: --preset must be one of: ${DEMO_PRESETS.join(', ')}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
demoPreset = preset;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (arg === '-r' || arg === '--refresh') {
|
|
137
|
+
const intervalStr = commandArgs[++i];
|
|
138
|
+
const interval = intervalStr ? parseInt(intervalStr, 10) : NaN;
|
|
139
|
+
if (isNaN(interval) || interval < 1000) {
|
|
140
|
+
console.error('Error: --refresh must be a number >= 1000');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
refreshInterval = interval;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const launchOptions: TuiOptions = {
|
|
148
|
+
refreshInterval,
|
|
149
|
+
debug,
|
|
150
|
+
demo,
|
|
151
|
+
};
|
|
152
|
+
if (cliPlugins.length > 0) {
|
|
153
|
+
launchOptions.cliPlugins = cliPlugins;
|
|
154
|
+
}
|
|
155
|
+
if (demoSeed !== undefined) {
|
|
156
|
+
launchOptions.demoSeed = demoSeed;
|
|
157
|
+
}
|
|
158
|
+
if (demoPreset !== undefined) {
|
|
159
|
+
launchOptions.demoPreset = demoPreset;
|
|
160
|
+
}
|
|
161
|
+
await startTui(launchOptions);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch((err) => {
|
|
165
|
+
console.error('Fatal error:', err);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CONFIG, loadConfig } from './schema.ts';
|
|
5
|
+
|
|
6
|
+
const readFileSpies: Array<ReturnType<typeof spyOn<typeof fs, 'readFile'>>> = [];
|
|
7
|
+
|
|
8
|
+
function mockReadFileWithJson(config: unknown) {
|
|
9
|
+
const spy = spyOn(fs, 'readFile');
|
|
10
|
+
spy.mockResolvedValue(JSON.stringify(config));
|
|
11
|
+
readFileSpies.push(spy);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mockReadFileWithRaw(content: string) {
|
|
15
|
+
const spy = spyOn(fs, 'readFile');
|
|
16
|
+
spy.mockResolvedValue(content);
|
|
17
|
+
readFileSpies.push(spy);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mockReadFileError(error: Error) {
|
|
21
|
+
const spy = spyOn(fs, 'readFile');
|
|
22
|
+
spy.mockRejectedValue(error);
|
|
23
|
+
readFileSpies.push(spy);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
for (const spy of readFileSpies) {
|
|
28
|
+
spy.mockRestore();
|
|
29
|
+
}
|
|
30
|
+
readFileSpies.length = 0;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('DEFAULT_CONFIG', () => {
|
|
34
|
+
test('has expected top-level structure', () => {
|
|
35
|
+
expect(Object.keys(DEFAULT_CONFIG).sort()).toEqual(
|
|
36
|
+
[
|
|
37
|
+
'alerts',
|
|
38
|
+
'budgets',
|
|
39
|
+
'configVersion',
|
|
40
|
+
'display',
|
|
41
|
+
'notifications',
|
|
42
|
+
'pluginConfig',
|
|
43
|
+
'plugins',
|
|
44
|
+
'providers',
|
|
45
|
+
'refresh',
|
|
46
|
+
].sort()
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('uses expected default values', () => {
|
|
51
|
+
expect(DEFAULT_CONFIG.refresh.intervalMs).toBe(60000);
|
|
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
|
+
|
|
56
|
+
expect(DEFAULT_CONFIG.budgets.daily).toBeNull();
|
|
57
|
+
expect(DEFAULT_CONFIG.budgets.weekly).toBeNull();
|
|
58
|
+
expect(DEFAULT_CONFIG.budgets.monthly).toBeNull();
|
|
59
|
+
|
|
60
|
+
expect(DEFAULT_CONFIG.alerts.warningPercent).toBe(80);
|
|
61
|
+
expect(DEFAULT_CONFIG.alerts.criticalPercent).toBe(95);
|
|
62
|
+
|
|
63
|
+
expect(DEFAULT_CONFIG.plugins.local).toEqual([]);
|
|
64
|
+
expect(DEFAULT_CONFIG.plugins.npm).toEqual([]);
|
|
65
|
+
expect(DEFAULT_CONFIG.plugins.disabled).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('loadConfig', () => {
|
|
70
|
+
test("returns defaults when config file doesn't exist", async () => {
|
|
71
|
+
mockReadFileError(new Error('ENOENT'));
|
|
72
|
+
|
|
73
|
+
const loaded = await loadConfig();
|
|
74
|
+
|
|
75
|
+
expect(loaded).toEqual(DEFAULT_CONFIG);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('merges partial overrides with defaults', async () => {
|
|
79
|
+
mockReadFileWithJson({
|
|
80
|
+
refresh: { intervalMs: 15000 },
|
|
81
|
+
budgets: { daily: 25 },
|
|
82
|
+
alerts: { warningPercent: 70 },
|
|
83
|
+
plugins: { local: ['~/plugins/local-theme.ts'] },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const loaded = await loadConfig();
|
|
87
|
+
|
|
88
|
+
expect(loaded.refresh.intervalMs).toBe(15000);
|
|
89
|
+
expect(loaded.refresh.pauseAutoRefresh).toBe(DEFAULT_CONFIG.refresh.pauseAutoRefresh);
|
|
90
|
+
expect(loaded.budgets.daily).toBe(25);
|
|
91
|
+
expect(loaded.budgets.weekly).toBeNull();
|
|
92
|
+
expect(loaded.alerts.warningPercent).toBe(70);
|
|
93
|
+
expect(loaded.alerts.criticalPercent).toBe(DEFAULT_CONFIG.alerts.criticalPercent);
|
|
94
|
+
expect(loaded.plugins.local).toEqual(['~/plugins/local-theme.ts']);
|
|
95
|
+
expect(loaded.plugins.npm).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('applies nested display overrides without dropping sibling defaults', async () => {
|
|
99
|
+
mockReadFileWithJson({
|
|
100
|
+
display: { theme: 'dracula' },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const loaded = await loadConfig();
|
|
104
|
+
|
|
105
|
+
expect(loaded.display.theme).toBe('dracula');
|
|
106
|
+
expect(loaded.display.defaultTimeWindow).toBe(DEFAULT_CONFIG.display.defaultTimeWindow);
|
|
107
|
+
expect(loaded.display.colorScheme).toBe(DEFAULT_CONFIG.display.colorScheme);
|
|
108
|
+
expect(loaded.display.timeFormat).toBe(DEFAULT_CONFIG.display.timeFormat);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('deep merges display.sparkline.style while preserving other sparkline defaults', async () => {
|
|
112
|
+
mockReadFileWithJson({
|
|
113
|
+
display: {
|
|
114
|
+
sparkline: {
|
|
115
|
+
style: 'block',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const loaded = await loadConfig();
|
|
121
|
+
|
|
122
|
+
expect(loaded.display.sparkline.style).toBe('block');
|
|
123
|
+
expect(loaded.display.sparkline.orientation).toBe(DEFAULT_CONFIG.display.sparkline.orientation);
|
|
124
|
+
expect(loaded.display.sparkline.showBaseline).toBe(DEFAULT_CONFIG.display.sparkline.showBaseline);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('falls back to defaults for invalid JSON', async () => {
|
|
128
|
+
mockReadFileWithRaw(`{
|
|
129
|
+
// comments are not valid JSON
|
|
130
|
+
"display": { "theme": "dracula" }
|
|
131
|
+
}`);
|
|
132
|
+
|
|
133
|
+
const loaded = await loadConfig();
|
|
134
|
+
expect(loaded.display.theme).toBe(DEFAULT_CONFIG.display.theme);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('parses valid JSON with budget overrides', async () => {
|
|
138
|
+
mockReadFileWithRaw(`{
|
|
139
|
+
"budgets": { "daily": 100 }
|
|
140
|
+
}`);
|
|
141
|
+
|
|
142
|
+
const loaded = await loadConfig();
|
|
143
|
+
expect(loaded.budgets.daily).toBe(100);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('parses valid JSON with multiple overrides', async () => {
|
|
147
|
+
mockReadFileWithRaw(`{
|
|
148
|
+
"budgets": { "daily": 50, "weekly": 200 },
|
|
149
|
+
"alerts": { "warningPercent": 90 }
|
|
150
|
+
}`);
|
|
151
|
+
|
|
152
|
+
const loaded = await loadConfig();
|
|
153
|
+
expect(loaded.budgets.daily).toBe(50);
|
|
154
|
+
expect(loaded.budgets.weekly).toBe(200);
|
|
155
|
+
expect(loaded.alerts.warningPercent).toBe(90);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('does not strip // inside string values', async () => {
|
|
159
|
+
mockReadFileWithRaw(`{
|
|
160
|
+
"plugins": { "local": ["~/my-plugin // v2"] }
|
|
161
|
+
}`);
|
|
162
|
+
|
|
163
|
+
const loaded = await loadConfig();
|
|
164
|
+
expect(loaded.plugins.local).toEqual(['~/my-plugin // v2']);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { PATHS } from '@/storage/paths.ts';
|
|
4
|
+
|
|
5
|
+
export type TimeWindow = '5m' | '15m' | '1h' | '24h' | '7d' | '30d' | 'all';
|
|
6
|
+
|
|
7
|
+
export interface BudgetConfig {
|
|
8
|
+
daily: number | null;
|
|
9
|
+
weekly: number | null;
|
|
10
|
+
monthly: number | null;
|
|
11
|
+
currency: 'USD' | 'EUR' | 'GBP';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AlertThresholds {
|
|
15
|
+
warningPercent: number;
|
|
16
|
+
criticalPercent: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SparklineStyle = 'braille' | 'block';
|
|
20
|
+
export type SparklineOrientation = 'up' | 'down';
|
|
21
|
+
|
|
22
|
+
export interface SparklineConfig {
|
|
23
|
+
style: SparklineStyle;
|
|
24
|
+
orientation: SparklineOrientation;
|
|
25
|
+
showBaseline: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PluginsConfig {
|
|
29
|
+
local: string[];
|
|
30
|
+
npm: string[];
|
|
31
|
+
disabled: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AppConfig {
|
|
35
|
+
configVersion: number;
|
|
36
|
+
refresh: {
|
|
37
|
+
intervalMs: number;
|
|
38
|
+
pauseAutoRefresh: boolean;
|
|
39
|
+
staleThresholdMs: number;
|
|
40
|
+
};
|
|
41
|
+
display: {
|
|
42
|
+
defaultTimeWindow: TimeWindow;
|
|
43
|
+
sidebarCollapsed: boolean;
|
|
44
|
+
timeFormat: '12h' | '24h';
|
|
45
|
+
numberFormat: 'full' | 'compact';
|
|
46
|
+
sparkline: SparklineConfig;
|
|
47
|
+
theme: string;
|
|
48
|
+
colorScheme: 'auto' | 'light' | 'dark';
|
|
49
|
+
};
|
|
50
|
+
notifications: {
|
|
51
|
+
toastsEnabled: boolean;
|
|
52
|
+
soundEnabled: boolean;
|
|
53
|
+
};
|
|
54
|
+
budgets: BudgetConfig;
|
|
55
|
+
alerts: AlertThresholds;
|
|
56
|
+
providers: {
|
|
57
|
+
hideUnconfigured: boolean;
|
|
58
|
+
};
|
|
59
|
+
plugins: PluginsConfig;
|
|
60
|
+
pluginConfig: Record<string, Record<string, unknown>>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_CONFIG: AppConfig = {
|
|
64
|
+
configVersion: 1,
|
|
65
|
+
refresh: {
|
|
66
|
+
intervalMs: 60000,
|
|
67
|
+
pauseAutoRefresh: false,
|
|
68
|
+
staleThresholdMs: 300000,
|
|
69
|
+
},
|
|
70
|
+
display: {
|
|
71
|
+
defaultTimeWindow: '1h',
|
|
72
|
+
sidebarCollapsed: false,
|
|
73
|
+
timeFormat: '24h',
|
|
74
|
+
numberFormat: 'compact',
|
|
75
|
+
sparkline: {
|
|
76
|
+
style: 'braille',
|
|
77
|
+
orientation: 'up',
|
|
78
|
+
showBaseline: true,
|
|
79
|
+
},
|
|
80
|
+
theme: 'tokyo-night',
|
|
81
|
+
colorScheme: 'auto',
|
|
82
|
+
},
|
|
83
|
+
notifications: {
|
|
84
|
+
toastsEnabled: true,
|
|
85
|
+
soundEnabled: false,
|
|
86
|
+
},
|
|
87
|
+
budgets: {
|
|
88
|
+
daily: null,
|
|
89
|
+
weekly: null,
|
|
90
|
+
monthly: null,
|
|
91
|
+
currency: 'USD',
|
|
92
|
+
},
|
|
93
|
+
alerts: {
|
|
94
|
+
warningPercent: 80,
|
|
95
|
+
criticalPercent: 95,
|
|
96
|
+
},
|
|
97
|
+
providers: {
|
|
98
|
+
hideUnconfigured: false,
|
|
99
|
+
},
|
|
100
|
+
plugins: {
|
|
101
|
+
local: [],
|
|
102
|
+
npm: [],
|
|
103
|
+
disabled: [],
|
|
104
|
+
},
|
|
105
|
+
pluginConfig: {},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function deepMerge(target: AppConfig, source: Partial<AppConfig>): AppConfig {
|
|
109
|
+
return {
|
|
110
|
+
configVersion: source.configVersion ?? target.configVersion,
|
|
111
|
+
refresh: {
|
|
112
|
+
...target.refresh,
|
|
113
|
+
...(source.refresh ?? {}),
|
|
114
|
+
},
|
|
115
|
+
display: {
|
|
116
|
+
...target.display,
|
|
117
|
+
...(source.display ?? {}),
|
|
118
|
+
sparkline: {
|
|
119
|
+
...target.display.sparkline,
|
|
120
|
+
...(source.display?.sparkline ?? {}),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
notifications: {
|
|
124
|
+
...target.notifications,
|
|
125
|
+
...(source.notifications ?? {}),
|
|
126
|
+
},
|
|
127
|
+
budgets: {
|
|
128
|
+
...target.budgets,
|
|
129
|
+
...(source.budgets ?? {}),
|
|
130
|
+
},
|
|
131
|
+
alerts: {
|
|
132
|
+
...target.alerts,
|
|
133
|
+
...(source.alerts ?? {}),
|
|
134
|
+
},
|
|
135
|
+
providers: {
|
|
136
|
+
...target.providers,
|
|
137
|
+
...(source.providers ?? {}),
|
|
138
|
+
},
|
|
139
|
+
plugins: {
|
|
140
|
+
...target.plugins,
|
|
141
|
+
...(source.plugins ?? {}),
|
|
142
|
+
},
|
|
143
|
+
pluginConfig: {
|
|
144
|
+
...target.pluginConfig,
|
|
145
|
+
...(source.pluginConfig ?? {}),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function loadConfig(): Promise<AppConfig> {
|
|
151
|
+
try {
|
|
152
|
+
const content = await fs.readFile(PATHS.config.file, 'utf-8');
|
|
153
|
+
const loaded = JSON.parse(content) as Partial<AppConfig>;
|
|
154
|
+
return deepMerge(DEFAULT_CONFIG, loaded);
|
|
155
|
+
} catch {
|
|
156
|
+
return DEFAULT_CONFIG;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function saveConfig(config: AppConfig): Promise<void> {
|
|
161
|
+
await fs.mkdir(path.dirname(PATHS.config.file), { recursive: true });
|
|
162
|
+
const tempFile = PATHS.config.file + '.tmp';
|
|
163
|
+
await fs.writeFile(tempFile, JSON.stringify(config, null, 2), 'utf-8');
|
|
164
|
+
await fs.rename(tempFile, PATHS.config.file);
|
|
165
|
+
}
|