@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.
Files changed (158) 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 +32 -32
  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 +29 -23
  19. package/src/plugins/notifications/visual-flash.ts +21 -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 +66 -57
  34. package/src/plugins/registry.ts +47 -43
  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/repos/agentSessions.ts +33 -23
  73. package/src/storage/repos/providerSnapshots.ts +5 -5
  74. package/src/storage/repos/usageEvents.ts +40 -28
  75. package/src/storage/types.ts +5 -5
  76. package/src/tui/App.tsx +180 -137
  77. package/src/tui/components/CommandPalette.tsx +46 -34
  78. package/src/tui/components/DebugConsole.tsx +39 -33
  79. package/src/tui/components/DebugPanel.tsx +295 -177
  80. package/src/tui/components/FooterHints.tsx +2 -2
  81. package/src/tui/components/GhostProviderCard.tsx +37 -35
  82. package/src/tui/components/Header.tsx +68 -35
  83. package/src/tui/components/HelpOverlay.tsx +99 -29
  84. package/src/tui/components/InlineGauge.tsx +10 -7
  85. package/src/tui/components/InlineSparkline.tsx +17 -11
  86. package/src/tui/components/KpiStrip.tsx +128 -98
  87. package/src/tui/components/LimitGauge.tsx +96 -59
  88. package/src/tui/components/ModalBackdrop.tsx +4 -4
  89. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  90. package/src/tui/components/ProviderCard.tsx +232 -226
  91. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  92. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  93. package/src/tui/components/ProvidersList.tsx +215 -126
  94. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  95. package/src/tui/components/SessionsTable.tsx +313 -166
  96. package/src/tui/components/SettingsModal.tsx +231 -214
  97. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  98. package/src/tui/components/Skeleton.tsx +24 -17
  99. package/src/tui/components/SmartSidebar.tsx +360 -258
  100. package/src/tui/components/Sparkline.tsx +64 -52
  101. package/src/tui/components/Spinner.tsx +3 -3
  102. package/src/tui/components/StatusBar.tsx +27 -27
  103. package/src/tui/components/ThemePicker.tsx +67 -66
  104. package/src/tui/components/Toast.tsx +9 -6
  105. package/src/tui/components/UsageGauge.tsx +10 -13
  106. package/src/tui/components/index.ts +5 -5
  107. package/src/tui/contexts/AgentSessionContext.tsx +193 -144
  108. package/src/tui/contexts/ConfigContext.tsx +15 -13
  109. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  110. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  111. package/src/tui/contexts/DrawerContext.tsx +11 -9
  112. package/src/tui/contexts/InputContext.tsx +2 -2
  113. package/src/tui/contexts/LogContext.tsx +62 -47
  114. package/src/tui/contexts/PluginContext.tsx +232 -183
  115. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  116. package/src/tui/contexts/StorageContext.tsx +151 -117
  117. package/src/tui/contexts/ThemeContext.tsx +21 -16
  118. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  119. package/src/tui/contexts/ToastContext.tsx +10 -7
  120. package/src/tui/contexts/index.ts +9 -5
  121. package/src/tui/createApp.tsx +8 -12
  122. package/src/tui/debug/captureFrame.ts +25 -28
  123. package/src/tui/debug/snapshot.tsx +179 -162
  124. package/src/tui/driver/assertions.ts +30 -22
  125. package/src/tui/driver/cli.ts +231 -221
  126. package/src/tui/driver/coverage.ts +27 -44
  127. package/src/tui/driver/demo-snapshot.ts +33 -33
  128. package/src/tui/driver/diff.ts +26 -32
  129. package/src/tui/driver/driver.ts +43 -48
  130. package/src/tui/driver/index.ts +36 -40
  131. package/src/tui/driver/recorder.ts +37 -48
  132. package/src/tui/driver/test-workflow.ts +51 -51
  133. package/src/tui/driver/test.ts +25 -25
  134. package/src/tui/hooks/index.ts +24 -20
  135. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  136. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  137. package/src/tui/hooks/useDashboardState.ts +26 -24
  138. package/src/tui/hooks/useEmaActivity.ts +82 -74
  139. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  140. package/src/tui/hooks/useExitAnimation.ts +3 -3
  141. package/src/tui/hooks/usePulse.ts +4 -4
  142. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  143. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  144. package/src/tui/hooks/useValueFlash.ts +10 -14
  145. package/src/tui/index.tsx +29 -6
  146. package/src/tui/shutdown.ts +19 -0
  147. package/src/tui/utils/providerColor.ts +1 -1
  148. package/src/tui/views/Dashboard.tsx +127 -102
  149. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  150. package/src/tui/views/ProjectsView.tsx +466 -237
  151. package/src/tui/views/RealTimeDashboard.tsx +150 -111
  152. package/src/tui/views/SettingsView.tsx +222 -191
  153. package/src/tui/views/index.ts +2 -2
  154. package/src/types/opentui-test-utils.d.ts +7 -7
  155. package/src/utils/clipboard.ts +29 -29
  156. package/src/utils/currency.test.ts +53 -53
  157. package/src/utils/currency.ts +35 -32
  158. package/src/version.ts +1 -1
@@ -1,23 +1,23 @@
1
- import { useState, useCallback, useEffect, useMemo } from 'react';
2
- import { useKeyboard, useTerminalDimensions } from '@opentui/react';
3
- import { useColors, useTheme } from '../contexts/ThemeContext.tsx';
4
- import { usePlugins } from '../contexts/PluginContext.tsx';
5
- import { useToastContext } from '../contexts/ToastContext.tsx';
6
- import { useConfig } from '../contexts/ConfigContext.tsx';
7
- import { useDemoMode } from '../contexts/DemoModeContext.tsx';
8
- import { useInputFocus } from '../contexts/InputContext.tsx';
9
- import { ModalBackdrop, Z_INDEX } from './ModalBackdrop.tsx';
10
- import { type AppConfig, type SparklineStyle, type SparklineOrientation } from '@/config/schema.ts';
11
- import { parseCurrencyInput, formatBudgetDisplay } from '@/utils/currency.ts';
12
- import { ThemePicker } from './ThemePicker.tsx';
13
-
14
- type SettingCategory = 'refresh' | 'display' | 'budgets' | 'alerts' | 'notifications';
1
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
4
+ import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
5
+ import { useConfig } from "../contexts/ConfigContext.tsx";
6
+ import { useDemoMode } from "../contexts/DemoModeContext.tsx";
7
+ import { useInputFocus } from "../contexts/InputContext.tsx";
8
+ import { usePlugins } from "../contexts/PluginContext.tsx";
9
+ import { useColors, useTheme } from "../contexts/ThemeContext.tsx";
10
+ import { useToastContext } from "../contexts/ToastContext.tsx";
11
+ import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
12
+ import { ThemePicker } from "./ThemePicker.tsx";
13
+
14
+ type SettingCategory = "refresh" | "display" | "budgets" | "alerts" | "notifications";
15
15
 
16
16
  interface SettingItem {
17
17
  key: string;
18
18
  label: string;
19
19
  category: SettingCategory;
20
- type: 'toggle' | 'select' | 'number';
20
+ type: "toggle" | "select" | "number";
21
21
  options?: string[];
22
22
  getValue: (config: AppConfig) => string | number | boolean | null;
23
23
  setValue: (config: AppConfig, value: string | number | boolean | null) => AppConfig;
@@ -25,182 +25,191 @@ interface SettingItem {
25
25
 
26
26
  const BASE_SETTINGS: SettingItem[] = [
27
27
  {
28
- key: 'intervalMs',
29
- label: 'Refresh Interval',
30
- category: 'refresh',
31
- type: 'select',
32
- options: ['30s', '1m', '2m', '5m'],
28
+ key: "intervalMs",
29
+ label: "Refresh Interval",
30
+ category: "refresh",
31
+ type: "select",
32
+ options: ["30s", "1m", "2m", "5m"],
33
33
  getValue: (c) => {
34
34
  const ms = c.refresh.intervalMs;
35
- if (ms <= 30000) return '30s';
36
- if (ms <= 60000) return '1m';
37
- if (ms <= 120000) return '2m';
38
- return '5m';
35
+ if (ms <= 30000) return "30s";
36
+ if (ms <= 60000) return "1m";
37
+ if (ms <= 120000) return "2m";
38
+ return "5m";
39
39
  },
40
40
  setValue: (c, v) => {
41
- const map: Record<string, number> = { '30s': 30000, '1m': 60000, '2m': 120000, '5m': 300000 };
41
+ const map: Record<string, number> = { "30s": 30000, "1m": 60000, "2m": 120000, "5m": 300000 };
42
42
  return { ...c, refresh: { ...c.refresh, intervalMs: map[v as string] ?? 60000 } };
43
43
  },
44
44
  },
45
45
  {
46
- key: 'pauseAutoRefresh',
47
- label: 'Pause Auto-Refresh',
48
- category: 'refresh',
49
- type: 'toggle',
46
+ key: "pauseAutoRefresh",
47
+ label: "Pause Auto-Refresh",
48
+ category: "refresh",
49
+ type: "toggle",
50
50
  getValue: (c) => c.refresh.pauseAutoRefresh,
51
51
  setValue: (c, v) => ({ ...c, refresh: { ...c.refresh, pauseAutoRefresh: v as boolean } }),
52
52
  },
53
53
  {
54
- key: 'defaultTimeWindow',
55
- label: 'Default Time Window',
56
- category: 'display',
57
- type: 'select',
58
- options: ['5m', '15m', '1h', '24h', '7d', '30d', 'all'],
54
+ key: "defaultTimeWindow",
55
+ label: "Default Time Window",
56
+ category: "display",
57
+ type: "select",
58
+ options: ["5m", "15m", "1h", "24h", "7d", "30d", "all"],
59
59
  getValue: (c) => c.display.defaultTimeWindow,
60
- setValue: (c, v) => ({ ...c, display: { ...c.display, defaultTimeWindow: v as AppConfig['display']['defaultTimeWindow'] } }),
60
+ setValue: (c, v) => ({
61
+ ...c,
62
+ display: { ...c.display, defaultTimeWindow: v as AppConfig["display"]["defaultTimeWindow"] },
63
+ }),
61
64
  },
62
65
  {
63
- key: 'sidebarCollapsed',
64
- label: 'Sidebar Collapsed',
65
- category: 'display',
66
- type: 'toggle',
66
+ key: "sidebarCollapsed",
67
+ label: "Sidebar Collapsed",
68
+ category: "display",
69
+ type: "toggle",
67
70
  getValue: (c) => c.display.sidebarCollapsed,
68
71
  setValue: (c, v) => ({ ...c, display: { ...c.display, sidebarCollapsed: v as boolean } }),
69
72
  },
70
73
  {
71
- key: 'timeFormat',
72
- label: 'Time Format',
73
- category: 'display',
74
- type: 'select',
75
- options: ['12h', '24h'],
74
+ key: "timeFormat",
75
+ label: "Time Format",
76
+ category: "display",
77
+ type: "select",
78
+ options: ["12h", "24h"],
76
79
  getValue: (c) => c.display.timeFormat,
77
- setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as '12h' | '24h' } }),
80
+ setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as "12h" | "24h" } }),
78
81
  },
79
82
  {
80
- key: 'sparklineStyle',
81
- label: 'Sparkline Style',
82
- category: 'display',
83
- type: 'select',
84
- options: ['braille', 'block'],
83
+ key: "sparklineStyle",
84
+ label: "Sparkline Style",
85
+ category: "display",
86
+ type: "select",
87
+ options: ["braille", "block"],
85
88
  getValue: (c) => c.display.sparkline.style,
86
- setValue: (c, v) => ({
87
- ...c,
88
- display: {
89
- ...c.display,
90
- sparkline: { ...c.display.sparkline, style: v as SparklineStyle }
91
- }
89
+ setValue: (c, v) => ({
90
+ ...c,
91
+ display: {
92
+ ...c.display,
93
+ sparkline: { ...c.display.sparkline, style: v as SparklineStyle },
94
+ },
92
95
  }),
93
96
  },
94
97
  {
95
- key: 'sparklineOrientation',
96
- label: 'Sparkline Direction',
97
- category: 'display',
98
- type: 'select',
99
- options: ['up', 'down'],
98
+ key: "sparklineOrientation",
99
+ label: "Sparkline Direction",
100
+ category: "display",
101
+ type: "select",
102
+ options: ["up", "down"],
100
103
  getValue: (c) => c.display.sparkline.orientation,
101
- setValue: (c, v) => ({
102
- ...c,
103
- display: {
104
- ...c.display,
105
- sparkline: { ...c.display.sparkline, orientation: v as SparklineOrientation }
106
- }
104
+ setValue: (c, v) => ({
105
+ ...c,
106
+ display: {
107
+ ...c.display,
108
+ sparkline: { ...c.display.sparkline, orientation: v as SparklineOrientation },
109
+ },
107
110
  }),
108
111
  },
109
112
  {
110
- key: 'sparklineBaseline',
111
- label: 'Sparkline Baseline',
112
- category: 'display',
113
- type: 'toggle',
113
+ key: "sparklineBaseline",
114
+ label: "Sparkline Baseline",
115
+ category: "display",
116
+ type: "toggle",
114
117
  getValue: (c) => c.display.sparkline.showBaseline,
115
- setValue: (c, v) => ({
116
- ...c,
117
- display: {
118
- ...c.display,
119
- sparkline: { ...c.display.sparkline, showBaseline: v as boolean }
120
- }
118
+ setValue: (c, v) => ({
119
+ ...c,
120
+ display: {
121
+ ...c.display,
122
+ sparkline: { ...c.display.sparkline, showBaseline: v as boolean },
123
+ },
121
124
  }),
122
125
  },
123
126
  {
124
- key: 'theme',
125
- label: 'Theme',
126
- category: 'display',
127
- type: 'select',
127
+ key: "theme",
128
+ label: "Theme",
129
+ category: "display",
130
+ type: "select",
128
131
  options: [], // Handled by ThemePicker
129
132
  getValue: (c) => c.display.theme,
130
133
  setValue: (c) => c, // Handled by ThemePicker
131
134
  },
132
135
  {
133
- key: 'dailyBudget',
134
- label: 'Daily Budget ($)',
135
- category: 'budgets',
136
- type: 'number',
136
+ key: "dailyBudget",
137
+ label: "Daily Budget ($)",
138
+ category: "budgets",
139
+ type: "number",
137
140
  getValue: (c) => c.budgets.daily,
138
141
  setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, daily: v as number | null } }),
139
142
  },
140
143
  {
141
- key: 'weeklyBudget',
142
- label: 'Weekly Budget ($)',
143
- category: 'budgets',
144
- type: 'number',
144
+ key: "weeklyBudget",
145
+ label: "Weekly Budget ($)",
146
+ category: "budgets",
147
+ type: "number",
145
148
  getValue: (c) => c.budgets.weekly,
146
149
  setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, weekly: v as number | null } }),
147
150
  },
148
151
  {
149
- key: 'monthlyBudget',
150
- label: 'Monthly Budget ($)',
151
- category: 'budgets',
152
- type: 'number',
152
+ key: "monthlyBudget",
153
+ label: "Monthly Budget ($)",
154
+ category: "budgets",
155
+ type: "number",
153
156
  getValue: (c) => c.budgets.monthly,
154
157
  setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, monthly: v as number | null } }),
155
158
  },
156
159
  {
157
- key: 'warningPercent',
158
- label: 'Warning Threshold (%)',
159
- category: 'alerts',
160
- type: 'select',
161
- options: ['55%', '60%', '65%', '70%', '75%', '80%', '85%', '90%', '95%'],
160
+ key: "warningPercent",
161
+ label: "Warning Threshold (%)",
162
+ category: "alerts",
163
+ type: "select",
164
+ options: ["55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%"],
162
165
  getValue: (c) => `${c.alerts.warningPercent}%`,
163
166
  setValue: (c, v) => {
164
- const percent = parseInt((v as string).replace('%', ''), 10);
167
+ const percent = parseInt((v as string).replace("%", ""), 10);
165
168
  return { ...c, alerts: { ...c.alerts, warningPercent: percent } };
166
169
  },
167
170
  },
168
171
  {
169
- key: 'criticalPercent',
170
- label: 'Critical Threshold (%)',
171
- category: 'alerts',
172
- type: 'select',
173
- options: ['75%', '80%', '85%', '90%', '95%', '98%', '100%'],
172
+ key: "criticalPercent",
173
+ label: "Critical Threshold (%)",
174
+ category: "alerts",
175
+ type: "select",
176
+ options: ["75%", "80%", "85%", "90%", "95%", "98%", "100%"],
174
177
  getValue: (c) => `${c.alerts.criticalPercent}%`,
175
178
  setValue: (c, v) => {
176
- const percent = parseInt((v as string).replace('%', ''), 10);
179
+ const percent = parseInt((v as string).replace("%", ""), 10);
177
180
  return { ...c, alerts: { ...c.alerts, criticalPercent: percent } };
178
181
  },
179
182
  },
180
183
  {
181
- key: 'toastsEnabled',
182
- label: 'Show Toast Notifications',
183
- category: 'notifications',
184
- type: 'toggle',
184
+ key: "toastsEnabled",
185
+ label: "Show Toast Notifications",
186
+ category: "notifications",
187
+ type: "toggle",
185
188
  getValue: (c) => c.notifications.toastsEnabled,
186
- setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, toastsEnabled: v as boolean } }),
189
+ setValue: (c, v) => ({
190
+ ...c,
191
+ notifications: { ...c.notifications, toastsEnabled: v as boolean },
192
+ }),
187
193
  },
188
194
  {
189
- key: 'soundEnabled',
190
- label: 'Sound Alerts',
191
- category: 'notifications',
192
- type: 'toggle',
195
+ key: "soundEnabled",
196
+ label: "Sound Alerts",
197
+ category: "notifications",
198
+ type: "toggle",
193
199
  getValue: (c) => c.notifications.soundEnabled,
194
- setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, soundEnabled: v as boolean } }),
200
+ setValue: (c, v) => ({
201
+ ...c,
202
+ notifications: { ...c.notifications, soundEnabled: v as boolean },
203
+ }),
195
204
  },
196
205
  ];
197
206
 
198
207
  const CATEGORIES: { id: SettingCategory; label: string }[] = [
199
- { id: 'refresh', label: 'Refresh' },
200
- { id: 'display', label: 'Display' },
201
- { id: 'budgets', label: 'Budgets' },
202
- { id: 'alerts', label: 'Alerts' },
203
- { id: 'notifications', label: 'Notifications' },
208
+ { id: "refresh", label: "Refresh" },
209
+ { id: "display", label: "Display" },
210
+ { id: "budgets", label: "Budgets" },
211
+ { id: "alerts", label: "Alerts" },
212
+ { id: "notifications", label: "Notifications" },
204
213
  ];
205
214
 
206
215
  interface SettingsModalProps {
@@ -217,17 +226,17 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
217
226
  const { setInputFocused } = useInputFocus();
218
227
  const { width: termWidth, height: termHeight } = useTerminalDimensions();
219
228
 
220
- const [selectedCategory, setSelectedCategory] = useState<SettingCategory>('refresh');
229
+ const [selectedCategory, setSelectedCategory] = useState<SettingCategory>("refresh");
221
230
  const [selectedIndex, setSelectedIndex] = useState(0);
222
- const [focusedPane, setFocusedPane] = useState<'categories' | 'settings'>('categories');
231
+ const [focusedPane, setFocusedPane] = useState<"categories" | "settings">("categories");
223
232
  const [showThemePicker, setShowThemePicker] = useState(false);
224
-
233
+
225
234
  const [editingSettingKey, setEditingSettingKey] = useState<string | null>(null);
226
- const [inputValue, setInputValue] = useState('');
235
+ const [inputValue, setInputValue] = useState("");
227
236
 
228
237
  const width = Math.min(termWidth - 4, 100);
229
238
  const height = Math.min(termHeight - 4, 28);
230
-
239
+
231
240
  // Calculate available height for settings list
232
241
  // Modal height - header(1) - footer(1) - outer padding(2) - inner borders(4) - title row with margin(2)
233
242
  const settingsAreaHeight = height - 1 - 1 - 2 - 4 - 2 - (demoMode ? 1 : 0);
@@ -236,8 +245,8 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
236
245
 
237
246
  const settings = BASE_SETTINGS;
238
247
 
239
- const categorySettings = settings.filter(s => s.category === selectedCategory);
240
-
248
+ const categorySettings = settings.filter((s) => s.category === selectedCategory);
249
+
241
250
  // Calculate scroll offset to keep selected item visible
242
251
  const scrollOffset = useMemo(() => {
243
252
  if (categorySettings.length <= visibleSettingsCount) return 0;
@@ -245,7 +254,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
245
254
  if (selectedIndex < visibleSettingsCount - 1) return 0;
246
255
  return Math.min(selectedIndex - visibleSettingsCount + 2, maxOffset);
247
256
  }, [selectedIndex, categorySettings.length, visibleSettingsCount]);
248
-
257
+
249
258
  const visibleSettings = categorySettings.slice(scrollOffset, scrollOffset + visibleSettingsCount);
250
259
  const hasMoreAbove = scrollOffset > 0;
251
260
  const hasMoreBelow = scrollOffset + visibleSettingsCount < categorySettings.length;
@@ -258,45 +267,45 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
258
267
  const handleSave = useCallback(async () => {
259
268
  try {
260
269
  await saveNow();
261
- showToast('Settings saved');
270
+ showToast("Settings saved");
262
271
  } catch {
263
- showToast('Failed to save settings', 'error');
272
+ showToast("Failed to save settings", "error");
264
273
  }
265
274
  }, [saveNow, showToast]);
266
275
 
267
276
  const handleReset = useCallback(() => {
268
277
  resetToDefaults();
269
- showToast('Reset to defaults');
278
+ showToast("Reset to defaults");
270
279
  }, [resetToDefaults, showToast]);
271
280
 
272
281
  const startEditingNumber = useCallback(() => {
273
282
  const setting = categorySettings[selectedIndex];
274
- if (!setting || setting.type !== 'number') return;
275
-
283
+ if (!setting || setting.type !== "number") return;
284
+
276
285
  const currentValue = setting.getValue(config);
277
- setInputValue(currentValue === null ? '' : String(currentValue));
286
+ setInputValue(currentValue === null ? "" : String(currentValue));
278
287
  setEditingSettingKey(setting.key);
279
288
  }, [categorySettings, selectedIndex, config]);
280
289
 
281
290
  const commitNumberEdit = useCallback(() => {
282
291
  if (editingSettingKey === null) return;
283
-
284
- const setting = categorySettings.find(s => s.key === editingSettingKey);
285
- if (!setting || setting.type !== 'number') {
292
+
293
+ const setting = categorySettings.find((s) => s.key === editingSettingKey);
294
+ if (!setting || setting.type !== "number") {
286
295
  setEditingSettingKey(null);
287
- setInputValue('');
296
+ setInputValue("");
288
297
  return;
289
298
  }
290
-
291
- const parsed = inputValue.trim() === '' ? null : parseCurrencyInput(inputValue);
299
+
300
+ const parsed = inputValue.trim() === "" ? null : parseCurrencyInput(inputValue);
292
301
  updateConfig(setting.setValue(config, parsed));
293
302
  setEditingSettingKey(null);
294
- setInputValue('');
303
+ setInputValue("");
295
304
  }, [editingSettingKey, categorySettings, inputValue, config, updateConfig]);
296
305
 
297
306
  const cancelNumberEdit = useCallback(() => {
298
307
  setEditingSettingKey(null);
299
- setInputValue('');
308
+ setInputValue("");
300
309
  }, []);
301
310
 
302
311
  const toggleCurrentSetting = useCallback(() => {
@@ -306,9 +315,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
306
315
  const currentValue = setting.getValue(config);
307
316
  let newConfig: AppConfig;
308
317
 
309
- if (setting.type === 'toggle') {
318
+ if (setting.type === "toggle") {
310
319
  newConfig = setting.setValue(config, !currentValue);
311
- } else if (setting.type === 'select' && setting.options && setting.options.length > 0) {
320
+ } else if (setting.type === "select" && setting.options && setting.options.length > 0) {
312
321
  const currentIdx = setting.options.indexOf(currentValue as string);
313
322
  const nextIdx = (currentIdx + 1) % setting.options.length;
314
323
  const newValue = setting.options[nextIdx]!;
@@ -324,90 +333,90 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
324
333
  if (showThemePicker) return;
325
334
 
326
335
  if (editingSettingKey !== null) {
327
- if (key.ctrl && (key.name === 'p' || key.name === 's')) {
336
+ if (key.ctrl && (key.name === "p" || key.name === "s")) {
328
337
  return;
329
338
  }
330
- if (key.name === 'escape') {
339
+ if (key.name === "escape") {
331
340
  cancelNumberEdit();
332
341
  return;
333
342
  }
334
- if (key.name === 'return') {
343
+ if (key.name === "return") {
335
344
  commitNumberEdit();
336
345
  return;
337
346
  }
338
- if (key.name === 'backspace') {
339
- setInputValue(prev => prev.slice(0, -1));
347
+ if (key.name === "backspace") {
348
+ setInputValue((prev) => prev.slice(0, -1));
340
349
  return;
341
350
  }
342
351
  if (key.sequence && /^[0-9.]$/.test(key.sequence)) {
343
- setInputValue(prev => prev + key.sequence);
352
+ setInputValue((prev) => prev + key.sequence);
344
353
  return;
345
354
  }
346
355
  return;
347
356
  }
348
357
 
349
- if (key.name === 'escape') {
358
+ if (key.name === "escape") {
350
359
  onClose();
351
360
  return;
352
361
  }
353
362
 
354
- if (key.ctrl && key.name === 's') {
363
+ if (key.ctrl && key.name === "s") {
355
364
  handleSave();
356
365
  return;
357
366
  }
358
367
 
359
- if (key.shift && key.name === 'r') {
368
+ if (key.shift && key.name === "r") {
360
369
  handleReset();
361
370
  return;
362
371
  }
363
372
 
364
- if (key.name === 'tab') {
365
- setFocusedPane(p => p === 'categories' ? 'settings' : 'categories');
373
+ if (key.name === "tab") {
374
+ setFocusedPane((p) => (p === "categories" ? "settings" : "categories"));
366
375
  return;
367
376
  }
368
377
 
369
- if (focusedPane === 'categories') {
370
- const catIdx = CATEGORIES.findIndex(c => c.id === selectedCategory);
371
- if (key.name === 'down' || key.name === 'j') {
378
+ if (focusedPane === "categories") {
379
+ const catIdx = CATEGORIES.findIndex((c) => c.id === selectedCategory);
380
+ if (key.name === "down" || key.name === "j") {
372
381
  const nextIdx = Math.min(catIdx + 1, CATEGORIES.length - 1);
373
382
  setSelectedCategory(CATEGORIES[nextIdx]!.id);
374
383
  setSelectedIndex(0);
375
- } else if (key.name === 'up' || key.name === 'k') {
384
+ } else if (key.name === "up" || key.name === "k") {
376
385
  const prevIdx = Math.max(catIdx - 1, 0);
377
386
  setSelectedCategory(CATEGORIES[prevIdx]!.id);
378
387
  setSelectedIndex(0);
379
- } else if (key.name === 'return' || key.name === 'right' || key.name === 'l') {
380
- setFocusedPane('settings');
388
+ } else if (key.name === "return" || key.name === "right" || key.name === "l") {
389
+ setFocusedPane("settings");
381
390
  }
382
391
  return;
383
392
  }
384
393
 
385
- if (focusedPane === 'settings') {
386
- if (key.name === 'down' || key.name === 'j') {
387
- setSelectedIndex(i => Math.min(i + 1, categorySettings.length - 1));
388
- } else if (key.name === 'up' || key.name === 'k') {
389
- setSelectedIndex(i => Math.max(i - 1, 0));
390
- } else if (key.name === 'return' || key.name === 'space') {
394
+ if (focusedPane === "settings") {
395
+ if (key.name === "down" || key.name === "j") {
396
+ setSelectedIndex((i) => Math.min(i + 1, categorySettings.length - 1));
397
+ } else if (key.name === "up" || key.name === "k") {
398
+ setSelectedIndex((i) => Math.max(i - 1, 0));
399
+ } else if (key.name === "return" || key.name === "space") {
391
400
  const setting = categorySettings[selectedIndex];
392
- if (setting?.key === 'theme') {
401
+ if (setting?.key === "theme") {
393
402
  setShowThemePicker(true);
394
- } else if (setting?.type === 'number') {
403
+ } else if (setting?.type === "number") {
395
404
  startEditingNumber();
396
405
  } else {
397
406
  toggleCurrentSetting();
398
407
  }
399
- } else if (key.name === 'left' || key.name === 'h') {
408
+ } else if (key.name === "left" || key.name === "h") {
400
409
  const setting = categorySettings[selectedIndex];
401
- if (setting?.type === 'select' && setting.options && setting.options.length > 0) {
410
+ if (setting?.type === "select" && setting.options && setting.options.length > 0) {
402
411
  const currentValue = setting.getValue(config) as string;
403
412
  const currentIdx = setting.options.indexOf(currentValue);
404
413
  const prevIdx = (currentIdx - 1 + setting.options.length) % setting.options.length;
405
414
  const newValue = setting.options[prevIdx]!;
406
415
  updateConfig(setting.setValue(config, newValue));
407
416
  }
408
- } else if (key.name === 'right' || key.name === 'l') {
417
+ } else if (key.name === "right" || key.name === "l") {
409
418
  const setting = categorySettings[selectedIndex];
410
- if (setting?.type === 'select' && setting.options && setting.options.length > 0) {
419
+ if (setting?.type === "select" && setting.options && setting.options.length > 0) {
411
420
  const currentValue = setting.getValue(config) as string;
412
421
  const currentIdx = setting.options.indexOf(currentValue);
413
422
  const nextIdx = (currentIdx + 1) % setting.options.length;
@@ -430,23 +439,25 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
430
439
  backgroundColor={colors.background}
431
440
  overflow="hidden"
432
441
  >
433
- <box
434
- flexDirection="row"
435
- justifyContent="space-between"
436
- paddingX={1}
437
- backgroundColor={colors.foreground}
438
- height={1}
439
- flexShrink={0}
440
- >
441
- <text fg={colors.primary}><strong>SETTINGS</strong></text>
442
- <text fg={colors.textSubtle}>Esc:close ^S:save Shift+R:reset</text>
443
- </box>
442
+ <box
443
+ flexDirection="row"
444
+ justifyContent="space-between"
445
+ paddingX={1}
446
+ backgroundColor={colors.foreground}
447
+ height={1}
448
+ flexShrink={0}
449
+ >
450
+ <text fg={colors.primary}>
451
+ <strong>SETTINGS</strong>
452
+ </text>
453
+ <text fg={colors.textSubtle}>Esc:close ^S:save Shift+R:reset</text>
454
+ </box>
444
455
 
445
456
  {demoMode && (
446
457
  <box flexDirection="row" height={1} paddingLeft={1} gap={2}>
447
458
  <text fg={colors.warning}>DEMO MODE</text>
448
459
  <text fg={colors.textMuted}>
449
- Seed: {seed ?? 'default'} | Preset: {preset ?? 'normal'}
460
+ Seed: {seed ?? "default"} | Preset: {preset ?? "normal"}
450
461
  </text>
451
462
  </box>
452
463
  )}
@@ -456,20 +467,23 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
456
467
  flexDirection="column"
457
468
  width={20}
458
469
  border
459
- borderStyle={focusedPane === 'categories' ? 'double' : 'single'}
460
- borderColor={focusedPane === 'categories' ? colors.primary : colors.border}
470
+ borderStyle={focusedPane === "categories" ? "double" : "single"}
471
+ borderColor={focusedPane === "categories" ? colors.primary : colors.border}
461
472
  padding={1}
462
473
  >
463
474
  {CATEGORIES.map((cat) => {
464
475
  const isActive = cat.id === selectedCategory;
465
- const isFocusedActive = isActive && focusedPane === 'categories';
476
+ const isFocusedActive = isActive && focusedPane === "categories";
466
477
  return (
467
478
  <box key={cat.id} height={1}>
468
479
  <text
469
- fg={isFocusedActive ? colors.background : isActive ? colors.primary : colors.text}
480
+ fg={
481
+ isFocusedActive ? colors.background : isActive ? colors.primary : colors.text
482
+ }
470
483
  {...(isFocusedActive ? { bg: colors.primary } : {})}
471
484
  >
472
- {isActive ? '▸ ' : ' '}{cat.label}
485
+ {isActive ? "▸ " : " "}
486
+ {cat.label}
473
487
  </text>
474
488
  </box>
475
489
  );
@@ -480,8 +494,8 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
480
494
  flexDirection="column"
481
495
  flexGrow={1}
482
496
  border
483
- borderStyle={focusedPane === 'settings' ? 'double' : 'single'}
484
- borderColor={focusedPane === 'settings' ? colors.primary : colors.border}
497
+ borderStyle={focusedPane === "settings" ? "double" : "single"}
498
+ borderColor={focusedPane === "settings" ? colors.primary : colors.border}
485
499
  padding={0}
486
500
  overflow="hidden"
487
501
  >
@@ -511,30 +525,32 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
511
525
  <box flexDirection="column" flexGrow={1} padding={1}>
512
526
  <box flexDirection="row" justifyContent="space-between" marginBottom={1} height={1}>
513
527
  <text fg={colors.textMuted}>
514
- {CATEGORIES.find(c => c.id === selectedCategory)?.label.toUpperCase()}
528
+ {CATEGORIES.find((c) => c.id === selectedCategory)?.label.toUpperCase()}
515
529
  </text>
516
530
  {(hasMoreAbove || hasMoreBelow) && (
517
531
  <text fg={colors.textSubtle}>
518
- {hasMoreAbove ? '▲' : ' '}{hasMoreBelow ? '▼' : ' '}
532
+ {hasMoreAbove ? "▲" : " "}
533
+ {hasMoreBelow ? "▼" : " "}
519
534
  </text>
520
535
  )}
521
536
  </box>
522
537
 
523
538
  {visibleSettings.map((setting) => {
524
- const realIdx = categorySettings.findIndex(s => s.key === setting.key);
525
- const isSelected = realIdx === selectedIndex && focusedPane === 'settings';
539
+ const realIdx = categorySettings.findIndex((s) => s.key === setting.key);
540
+ const isSelected = realIdx === selectedIndex && focusedPane === "settings";
526
541
  const value = setting.getValue(config);
527
- const isEditingThis = setting.type === 'number' && editingSettingKey === setting.key;
542
+ const isEditingThis =
543
+ setting.type === "number" && editingSettingKey === setting.key;
528
544
 
529
545
  let displayValue: string;
530
- if (setting.key === 'theme') {
531
- const themeName = themes.find(t => t.id === value)?.name ?? value;
546
+ if (setting.key === "theme") {
547
+ const themeName = themes.find((t) => t.id === value)?.name ?? value;
532
548
  displayValue = `${themeName} ▸`;
533
- } else if (setting.type === 'toggle') {
534
- displayValue = value ? '● ON' : '○ OFF';
535
- } else if (setting.type === 'select') {
549
+ } else if (setting.type === "toggle") {
550
+ displayValue = value ? "● ON" : "○ OFF";
551
+ } else if (setting.type === "select") {
536
552
  displayValue = `◂ ${value} ▸`;
537
- } else if (setting.type === 'number' && !isEditingThis) {
553
+ } else if (setting.type === "number" && !isEditingThis) {
538
554
  displayValue = formatBudgetDisplay(value as number | null);
539
555
  } else {
540
556
  displayValue = String(value);
@@ -549,7 +565,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
549
565
  paddingX={1}
550
566
  {...(isSelected && !isEditingThis ? { backgroundColor: colors.primary } : {})}
551
567
  >
552
- <text
568
+ <text
553
569
  flexGrow={1}
554
570
  fg={isSelected && !isEditingThis ? colors.background : colors.text}
555
571
  >
@@ -557,7 +573,8 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
557
573
  </text>
558
574
  {isEditingThis ? (
559
575
  <text fg={colors.text}>
560
- ${inputValue}<span fg={colors.primary}>█</span>
576
+ ${inputValue}
577
+ <span fg={colors.primary}>█</span>
561
578
  </text>
562
579
  ) : (
563
580
  <text fg={isSelected ? colors.background : colors.textMuted}>
@@ -572,15 +589,15 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
572
589
  </box>
573
590
  </box>
574
591
 
575
- <box flexDirection="row" height={1} paddingX={1} backgroundColor={colors.foreground}>
576
- <text fg={colors.textSubtle}>
577
- {editingSettingKey !== null
578
- ? 'Type value Enter:save Esc:cancel'
579
- : showThemePicker
580
- ? '↑↓:navigate ←→:scheme Tab:switch Enter:apply Esc:cancel'
581
- : 'Tab:switch ↑↓:navigate ←→:adjust Enter:edit'}
582
- </text>
583
- </box>
592
+ <box flexDirection="row" height={1} paddingX={1} backgroundColor={colors.foreground}>
593
+ <text fg={colors.textSubtle}>
594
+ {editingSettingKey !== null
595
+ ? "Type value Enter:save Esc:cancel"
596
+ : showThemePicker
597
+ ? "↑↓:navigate ←→:scheme Tab:switch Enter:apply Esc:cancel"
598
+ : "Tab:switch ↑↓:navigate ←→:adjust Enter:edit"}
599
+ </text>
600
+ </box>
584
601
  </box>
585
602
  </ModalBackdrop>
586
603
  );