@tokentop/ttop 0.2.2 → 0.4.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.
Files changed (160) hide show
  1. package/README.md +2 -5
  2. package/package.json +8 -5
  3. package/src/agents/aggregator.ts +39 -19
  4. package/src/agents/costing.ts +11 -9
  5. package/src/agents/index.ts +7 -9
  6. package/src/agents/types.ts +2 -2
  7. package/src/cli.ts +28 -23
  8. package/src/config/schema.test.ts +42 -40
  9. package/src/config/schema.ts +26 -26
  10. package/src/demo/simulator.ts +161 -122
  11. package/src/plugins/agents/index.ts +2 -2
  12. package/src/plugins/auth-sources.ts +42 -38
  13. package/src/plugins/lifecycle.ts +42 -38
  14. package/src/plugins/loader.test.ts +49 -49
  15. package/src/plugins/loader.ts +67 -63
  16. package/src/plugins/notification-bus.ts +21 -16
  17. package/src/plugins/notifications/index.ts +2 -2
  18. package/src/plugins/notifications/terminal-bell.ts +36 -23
  19. package/src/plugins/notifications/visual-flash.ts +31 -19
  20. package/src/plugins/npm-installer.ts +23 -25
  21. package/src/plugins/plugin-context-factory.ts +10 -10
  22. package/src/plugins/plugin-host.ts +12 -5
  23. package/src/plugins/providers/anthropic.ts +64 -57
  24. package/src/plugins/providers/antigravity.ts +81 -69
  25. package/src/plugins/providers/codex.ts +42 -42
  26. package/src/plugins/providers/gemini.ts +106 -87
  27. package/src/plugins/providers/github-copilot.ts +49 -45
  28. package/src/plugins/providers/index.ts +10 -12
  29. package/src/plugins/providers/minimax.ts +50 -44
  30. package/src/plugins/providers/openai-api.ts +82 -62
  31. package/src/plugins/providers/opencode-zen.ts +46 -39
  32. package/src/plugins/providers/perplexity.ts +55 -46
  33. package/src/plugins/providers/zai.ts +68 -57
  34. package/src/plugins/registry.ts +80 -71
  35. package/src/plugins/sandbox-guard.test.ts +45 -48
  36. package/src/plugins/sandbox-guard.ts +20 -25
  37. package/src/plugins/sandbox.ts +26 -48
  38. package/src/plugins/themes/catppuccin-latte.ts +29 -29
  39. package/src/plugins/themes/catppuccin-mocha.ts +29 -29
  40. package/src/plugins/themes/claude-code.ts +29 -29
  41. package/src/plugins/themes/dracula.ts +29 -29
  42. package/src/plugins/themes/github-light.ts +29 -29
  43. package/src/plugins/themes/gruvbox-dark.ts +29 -29
  44. package/src/plugins/themes/gruvbox-light.ts +29 -29
  45. package/src/plugins/themes/index.ts +15 -15
  46. package/src/plugins/themes/kanagawa.ts +29 -29
  47. package/src/plugins/themes/nord.ts +29 -29
  48. package/src/plugins/themes/one-dark.ts +29 -29
  49. package/src/plugins/themes/opencode.ts +31 -31
  50. package/src/plugins/themes/rose-pine-dawn.ts +29 -29
  51. package/src/plugins/themes/rose-pine.ts +29 -29
  52. package/src/plugins/themes/solarized-light.ts +29 -29
  53. package/src/plugins/themes/tokyo-night.ts +29 -29
  54. package/src/plugins/types/agent.ts +5 -5
  55. package/src/plugins/types/base.ts +35 -24
  56. package/src/plugins/types/index.ts +9 -9
  57. package/src/plugins/types/notification.ts +12 -12
  58. package/src/plugins/types/provider.ts +12 -8
  59. package/src/plugins/types/theme.ts +31 -23
  60. package/src/plugins/update-checker.ts +6 -6
  61. package/src/pricing/estimator.test.ts +39 -44
  62. package/src/pricing/estimator.ts +7 -13
  63. package/src/pricing/fallback.test.ts +30 -34
  64. package/src/pricing/fallback.ts +54 -27
  65. package/src/pricing/index.ts +18 -12
  66. package/src/pricing/models-dev.ts +6 -6
  67. package/src/storage/database.ts +56 -46
  68. package/src/storage/db.ts +12 -12
  69. package/src/storage/index.ts +44 -45
  70. package/src/storage/migrations/index.ts +2 -2
  71. package/src/storage/paths.ts +13 -13
  72. package/src/storage/persistence-service.ts +206 -0
  73. package/src/storage/repos/agentSessions.ts +33 -23
  74. package/src/storage/repos/providerSnapshots.ts +5 -5
  75. package/src/storage/repos/usageEvents.ts +40 -28
  76. package/src/storage/retention.ts +146 -0
  77. package/src/storage/types.ts +5 -5
  78. package/src/tui/App.tsx +180 -137
  79. package/src/tui/components/CommandPalette.tsx +46 -34
  80. package/src/tui/components/DebugConsole.tsx +39 -33
  81. package/src/tui/components/DebugPanel.tsx +314 -183
  82. package/src/tui/components/FooterHints.tsx +2 -2
  83. package/src/tui/components/GhostProviderCard.tsx +37 -35
  84. package/src/tui/components/Header.tsx +68 -35
  85. package/src/tui/components/HelpOverlay.tsx +99 -29
  86. package/src/tui/components/InlineGauge.tsx +10 -7
  87. package/src/tui/components/InlineSparkline.tsx +17 -11
  88. package/src/tui/components/KpiStrip.tsx +128 -98
  89. package/src/tui/components/LimitGauge.tsx +96 -59
  90. package/src/tui/components/ModalBackdrop.tsx +4 -4
  91. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  92. package/src/tui/components/ProviderCard.tsx +232 -226
  93. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  94. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  95. package/src/tui/components/ProvidersList.tsx +215 -126
  96. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  97. package/src/tui/components/SessionsTable.tsx +320 -169
  98. package/src/tui/components/SettingsModal.tsx +593 -269
  99. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  100. package/src/tui/components/Skeleton.tsx +24 -17
  101. package/src/tui/components/SmartSidebar.tsx +360 -258
  102. package/src/tui/components/Sparkline.tsx +64 -52
  103. package/src/tui/components/Spinner.tsx +3 -3
  104. package/src/tui/components/StatusBar.tsx +27 -27
  105. package/src/tui/components/ThemePicker.tsx +67 -66
  106. package/src/tui/components/Toast.tsx +9 -6
  107. package/src/tui/components/UsageGauge.tsx +10 -13
  108. package/src/tui/components/index.ts +5 -5
  109. package/src/tui/contexts/AgentSessionContext.tsx +164 -151
  110. package/src/tui/contexts/ConfigContext.tsx +15 -13
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  112. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  113. package/src/tui/contexts/DrawerContext.tsx +11 -9
  114. package/src/tui/contexts/InputContext.tsx +2 -2
  115. package/src/tui/contexts/LogContext.tsx +62 -47
  116. package/src/tui/contexts/PluginContext.tsx +238 -183
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  118. package/src/tui/contexts/StorageContext.tsx +95 -149
  119. package/src/tui/contexts/ThemeContext.tsx +21 -16
  120. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  121. package/src/tui/contexts/ToastContext.tsx +10 -7
  122. package/src/tui/contexts/index.ts +9 -5
  123. package/src/tui/createApp.tsx +8 -12
  124. package/src/tui/debug/captureFrame.ts +25 -28
  125. package/src/tui/debug/snapshot.tsx +179 -162
  126. package/src/tui/driver/assertions.ts +30 -22
  127. package/src/tui/driver/cli.ts +231 -221
  128. package/src/tui/driver/coverage.ts +27 -44
  129. package/src/tui/driver/demo-snapshot.ts +33 -33
  130. package/src/tui/driver/diff.ts +26 -32
  131. package/src/tui/driver/driver.ts +43 -48
  132. package/src/tui/driver/index.ts +36 -40
  133. package/src/tui/driver/recorder.ts +37 -48
  134. package/src/tui/driver/test-workflow.ts +51 -51
  135. package/src/tui/driver/test.ts +25 -25
  136. package/src/tui/hooks/index.ts +24 -20
  137. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  138. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  139. package/src/tui/hooks/useDashboardState.ts +26 -24
  140. package/src/tui/hooks/useEmaActivity.ts +82 -74
  141. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  142. package/src/tui/hooks/useExitAnimation.ts +7 -3
  143. package/src/tui/hooks/usePulse.ts +4 -4
  144. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  145. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  146. package/src/tui/hooks/useValueFlash.ts +10 -14
  147. package/src/tui/index.tsx +29 -6
  148. package/src/tui/shutdown.ts +19 -0
  149. package/src/tui/utils/providerColor.ts +1 -1
  150. package/src/tui/views/Dashboard.tsx +164 -102
  151. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  152. package/src/tui/views/ProjectsView.tsx +495 -243
  153. package/src/tui/views/RealTimeDashboard.tsx +176 -116
  154. package/src/tui/views/SettingsView.tsx +225 -189
  155. package/src/tui/views/index.ts +2 -2
  156. package/src/types/opentui-test-utils.d.ts +7 -7
  157. package/src/utils/clipboard.ts +29 -29
  158. package/src/utils/currency.test.ts +53 -53
  159. package/src/utils/currency.ts +35 -32
  160. 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
  ![Tokentop Dashboard](assets/Screenshot.png)
13
13
 
14
- <!-- TODO: uncomment CI badge when GitHub Actions workflow is set up -->
15
- <!-- [![CI](https://img.shields.io/github/actions/workflow/status/tokentopapp/tokentop/ci.yml?style=flat-square&label=CI)](https://github.com/tokentopapp/tokentop/actions) -->
14
+ [![CI](https://img.shields.io/github/actions/workflow/status/tokentopapp/tokentop/ci.yml?style=flat-square&label=CI)](https://github.com/tokentopapp/tokentop/actions)
16
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
17
16
  [![GitHub release](https://img.shields.io/github/v/release/tokentopapp/tokentop?style=flat-square)](https://github.com/tokentopapp/tokentop/releases)
18
17
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white)](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
- > _Coming soon: VHS terminal recording_
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.2.2",
3
+ "version": "0.4.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": "eslint src/",
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.79",
35
- "@opentui/react": "0.1.79",
36
+ "@opentui/core": "0.1.81",
37
+ "@opentui/react": "0.1.81",
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": "^3.24.0"
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
  },
@@ -1,13 +1,13 @@
1
- import type { SessionUsageData } from '@/plugins/types/agent.ts';
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
- } from './types.ts';
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(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate()).getTime();
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(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate() - dayOfWeek).getTime();
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 { agentId, agentName, rows, now = Date.now(), activeThresholdMs = ACTIVE_THRESHOLD_MS } = options;
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<string, {
67
- sessionName?: string;
68
- projectPath?: string;
69
- timestamps: number[];
70
- sessionUpdatedAt?: number;
71
- streamMap: Map<string, StreamAccumulator>;
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 (row.sessionUpdatedAt && (!session.sessionUpdatedAt || row.sessionUpdatedAt > session.sessionUpdatedAt)) {
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 = (now - lastSeenAt) <= activeThresholdMs ? 'active' : 'idle';
156
+ const status = now - lastSeenAt <= activeThresholdMs ? "active" : "idle";
137
157
 
138
158
  const streams: AgentSessionStream[] = [];
139
159
  const streamWindowedTokens = new Map<string, StreamWindowedTokens>();
@@ -1,16 +1,16 @@
1
- import { getPricing, estimateCost } from '@/pricing/index.ts';
2
- import type { AgentSessionAggregate, AgentSessionStream, StreamCostBreakdown } from './types.ts';
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: 'unknown' };
8
+ return { ...stream, pricingSource: "unknown" };
9
9
  }
10
10
 
11
11
  const breakdown = estimateCost(stream.tokens, pricing);
12
- const source = pricing.source === 'models.dev' ? 'models.dev' : 'fallback';
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(sessions: AgentSessionAggregate[]): Promise<AgentSessionAggregate[]> {
72
+ export async function priceSessions(
73
+ sessions: AgentSessionAggregate[],
74
+ ): Promise<AgentSessionAggregate[]> {
73
75
  return Promise.all(sessions.map(priceSession));
74
76
  }
@@ -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
- } from './types.ts';
9
-
10
- export { aggregateSessionUsage } from './aggregator.ts';
11
-
12
- export { priceStream, priceSession, priceSessions } from './costing.ts';
6
+ AgentName,
7
+ AgentSessionAggregate,
8
+ AgentSessionStream,
9
+ TokenCounts,
10
+ } from "./types.ts";
@@ -29,7 +29,7 @@ export interface AgentSessionStream {
29
29
  requestCount: number;
30
30
  costUsd?: number;
31
31
  costBreakdown?: StreamCostBreakdown;
32
- pricingSource?: 'models.dev' | 'fallback' | 'unknown';
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: 'active' | 'idle';
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 'path';
3
- import { startTui, type TuiOptions } from './tui/index.tsx';
4
- import type { DemoPreset } from './demo/simulator.ts';
5
- import { VERSION } from './version.ts';
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[] = ['light', 'normal', 'heavy'];
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('-') ? args[0] : null;
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 === 'demo') {
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 === '-h' || arg === '--help') {
85
+ if (arg === "-h" || arg === "--help") {
85
86
  printHelp();
86
87
  process.exit(0);
87
88
  }
88
89
 
89
- if (arg === '-v' || arg === '--version') {
90
+ if (arg === "-v" || arg === "--version") {
90
91
  printVersion();
91
92
  process.exit(0);
92
93
  }
93
94
 
94
- if (arg === '-d' || arg === '--debug') {
95
+ if (arg === "-d" || arg === "--debug") {
95
96
  debug = true;
96
97
  }
97
98
 
98
- if (arg === '-p' || arg === '--plugin') {
99
+ if (arg === "-p" || arg === "--plugin") {
99
100
  const pluginPath = commandArgs[++i];
100
101
  if (!pluginPath) {
101
- console.error('Error: --plugin requires a path');
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 === '--seed') {
110
+ if (arg === "--seed") {
110
111
  if (!demo) {
111
- console.error('Error: --seed can only be used with the demo command');
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('Error: --seed requires a number');
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 === '--preset') {
124
+ if (arg === "--preset") {
124
125
  if (!demo) {
125
- console.error('Error: --preset can only be used with the demo command');
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 === '-r' || arg === '--refresh') {
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('Error: --refresh must be a number >= 1000');
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('Fatal error:', err);
170
+ console.error("Fatal error:", err);
166
171
  process.exit(1);
167
172
  });
@@ -1,24 +1,24 @@
1
- import * as fs from 'fs/promises';
2
- import { afterEach, describe, expect, spyOn, test } from 'bun:test';
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 './schema.ts';
4
+ import { DEFAULT_CONFIG, loadConfig } from "./schema.ts";
5
5
 
6
- const readFileSpies: Array<ReturnType<typeof spyOn<typeof fs, 'readFile'>>> = [];
6
+ const readFileSpies: Array<ReturnType<typeof spyOn<typeof fs, "readFile">>> = [];
7
7
 
8
8
  function mockReadFileWithJson(config: unknown) {
9
- const spy = spyOn(fs, 'readFile');
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, 'readFile');
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, 'readFile');
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('DEFAULT_CONFIG', () => {
34
- test('has expected top-level structure', () => {
33
+ describe("DEFAULT_CONFIG", () => {
34
+ test("has expected top-level structure", () => {
35
35
  expect(Object.keys(DEFAULT_CONFIG).sort()).toEqual(
36
36
  [
37
- 'alerts',
38
- 'budgets',
39
- 'configVersion',
40
- 'display',
41
- 'notifications',
42
- 'pluginConfig',
43
- 'plugins',
44
- 'providers',
45
- 'refresh',
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('uses expected default values', () => {
50
+ test("uses expected default values", () => {
51
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');
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('loadConfig', () => {
69
+ describe("loadConfig", () => {
70
70
  test("returns defaults when config file doesn't exist", async () => {
71
- mockReadFileError(new Error('ENOENT'));
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('merges partial overrides with defaults', async () => {
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: ['~/plugins/local-theme.ts'] },
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(['~/plugins/local-theme.ts']);
94
+ expect(loaded.plugins.local).toEqual(["~/plugins/local-theme.ts"]);
95
95
  expect(loaded.plugins.npm).toEqual([]);
96
96
  });
97
97
 
98
- test('applies nested display overrides without dropping sibling defaults', async () => {
98
+ test("applies nested display overrides without dropping sibling defaults", async () => {
99
99
  mockReadFileWithJson({
100
- display: { theme: 'dracula' },
100
+ display: { theme: "dracula" },
101
101
  });
102
102
 
103
103
  const loaded = await loadConfig();
104
104
 
105
- expect(loaded.display.theme).toBe('dracula');
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('deep merges display.sparkline.style while preserving other sparkline defaults', async () => {
111
+ test("deep merges display.sparkline.style while preserving other sparkline defaults", async () => {
112
112
  mockReadFileWithJson({
113
113
  display: {
114
114
  sparkline: {
115
- style: 'block',
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('block');
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(DEFAULT_CONFIG.display.sparkline.showBaseline);
124
+ expect(loaded.display.sparkline.showBaseline).toBe(
125
+ DEFAULT_CONFIG.display.sparkline.showBaseline,
126
+ );
125
127
  });
126
128
 
127
- test('falls back to defaults for invalid JSON', async () => {
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('parses valid JSON with budget overrides', async () => {
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('parses valid JSON with multiple overrides', async () => {
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('does not strip // inside string values', async () => {
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(['~/my-plugin // v2']);
166
+ expect(loaded.plugins.local).toEqual(["~/my-plugin // v2"]);
165
167
  });
166
168
  });