@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.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. 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
+ }