@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,587 @@
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';
15
+
16
+ interface SettingItem {
17
+ key: string;
18
+ label: string;
19
+ category: SettingCategory;
20
+ type: 'toggle' | 'select' | 'number';
21
+ options?: string[];
22
+ getValue: (config: AppConfig) => string | number | boolean | null;
23
+ setValue: (config: AppConfig, value: string | number | boolean | null) => AppConfig;
24
+ }
25
+
26
+ const BASE_SETTINGS: SettingItem[] = [
27
+ {
28
+ key: 'intervalMs',
29
+ label: 'Refresh Interval',
30
+ category: 'refresh',
31
+ type: 'select',
32
+ options: ['30s', '1m', '2m', '5m'],
33
+ getValue: (c) => {
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';
39
+ },
40
+ setValue: (c, v) => {
41
+ const map: Record<string, number> = { '30s': 30000, '1m': 60000, '2m': 120000, '5m': 300000 };
42
+ return { ...c, refresh: { ...c.refresh, intervalMs: map[v as string] ?? 60000 } };
43
+ },
44
+ },
45
+ {
46
+ key: 'pauseAutoRefresh',
47
+ label: 'Pause Auto-Refresh',
48
+ category: 'refresh',
49
+ type: 'toggle',
50
+ getValue: (c) => c.refresh.pauseAutoRefresh,
51
+ setValue: (c, v) => ({ ...c, refresh: { ...c.refresh, pauseAutoRefresh: v as boolean } }),
52
+ },
53
+ {
54
+ key: 'defaultTimeWindow',
55
+ label: 'Default Time Window',
56
+ category: 'display',
57
+ type: 'select',
58
+ options: ['5m', '15m', '1h', '24h', '7d', '30d', 'all'],
59
+ getValue: (c) => c.display.defaultTimeWindow,
60
+ setValue: (c, v) => ({ ...c, display: { ...c.display, defaultTimeWindow: v as AppConfig['display']['defaultTimeWindow'] } }),
61
+ },
62
+ {
63
+ key: 'sidebarCollapsed',
64
+ label: 'Sidebar Collapsed',
65
+ category: 'display',
66
+ type: 'toggle',
67
+ getValue: (c) => c.display.sidebarCollapsed,
68
+ setValue: (c, v) => ({ ...c, display: { ...c.display, sidebarCollapsed: v as boolean } }),
69
+ },
70
+ {
71
+ key: 'timeFormat',
72
+ label: 'Time Format',
73
+ category: 'display',
74
+ type: 'select',
75
+ options: ['12h', '24h'],
76
+ getValue: (c) => c.display.timeFormat,
77
+ setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as '12h' | '24h' } }),
78
+ },
79
+ {
80
+ key: 'sparklineStyle',
81
+ label: 'Sparkline Style',
82
+ category: 'display',
83
+ type: 'select',
84
+ options: ['braille', 'block'],
85
+ 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
+ }
92
+ }),
93
+ },
94
+ {
95
+ key: 'sparklineOrientation',
96
+ label: 'Sparkline Direction',
97
+ category: 'display',
98
+ type: 'select',
99
+ options: ['up', 'down'],
100
+ 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
+ }
107
+ }),
108
+ },
109
+ {
110
+ key: 'sparklineBaseline',
111
+ label: 'Sparkline Baseline',
112
+ category: 'display',
113
+ type: 'toggle',
114
+ 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
+ }
121
+ }),
122
+ },
123
+ {
124
+ key: 'theme',
125
+ label: 'Theme',
126
+ category: 'display',
127
+ type: 'select',
128
+ options: [], // Handled by ThemePicker
129
+ getValue: (c) => c.display.theme,
130
+ setValue: (c) => c, // Handled by ThemePicker
131
+ },
132
+ {
133
+ key: 'dailyBudget',
134
+ label: 'Daily Budget ($)',
135
+ category: 'budgets',
136
+ type: 'number',
137
+ getValue: (c) => c.budgets.daily,
138
+ setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, daily: v as number | null } }),
139
+ },
140
+ {
141
+ key: 'weeklyBudget',
142
+ label: 'Weekly Budget ($)',
143
+ category: 'budgets',
144
+ type: 'number',
145
+ getValue: (c) => c.budgets.weekly,
146
+ setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, weekly: v as number | null } }),
147
+ },
148
+ {
149
+ key: 'monthlyBudget',
150
+ label: 'Monthly Budget ($)',
151
+ category: 'budgets',
152
+ type: 'number',
153
+ getValue: (c) => c.budgets.monthly,
154
+ setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, monthly: v as number | null } }),
155
+ },
156
+ {
157
+ key: 'warningPercent',
158
+ label: 'Warning Threshold (%)',
159
+ category: 'alerts',
160
+ type: 'select',
161
+ options: ['55%', '60%', '65%', '70%', '75%', '80%', '85%', '90%', '95%'],
162
+ getValue: (c) => `${c.alerts.warningPercent}%`,
163
+ setValue: (c, v) => {
164
+ const percent = parseInt((v as string).replace('%', ''), 10);
165
+ return { ...c, alerts: { ...c.alerts, warningPercent: percent } };
166
+ },
167
+ },
168
+ {
169
+ key: 'criticalPercent',
170
+ label: 'Critical Threshold (%)',
171
+ category: 'alerts',
172
+ type: 'select',
173
+ options: ['75%', '80%', '85%', '90%', '95%', '98%', '100%'],
174
+ getValue: (c) => `${c.alerts.criticalPercent}%`,
175
+ setValue: (c, v) => {
176
+ const percent = parseInt((v as string).replace('%', ''), 10);
177
+ return { ...c, alerts: { ...c.alerts, criticalPercent: percent } };
178
+ },
179
+ },
180
+ {
181
+ key: 'toastsEnabled',
182
+ label: 'Show Toast Notifications',
183
+ category: 'notifications',
184
+ type: 'toggle',
185
+ getValue: (c) => c.notifications.toastsEnabled,
186
+ setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, toastsEnabled: v as boolean } }),
187
+ },
188
+ {
189
+ key: 'soundEnabled',
190
+ label: 'Sound Alerts',
191
+ category: 'notifications',
192
+ type: 'toggle',
193
+ getValue: (c) => c.notifications.soundEnabled,
194
+ setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, soundEnabled: v as boolean } }),
195
+ },
196
+ ];
197
+
198
+ 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' },
204
+ ];
205
+
206
+ interface SettingsModalProps {
207
+ onClose: () => void;
208
+ }
209
+
210
+ export function SettingsModal({ onClose }: SettingsModalProps) {
211
+ const colors = useColors();
212
+ const { setPreviewTheme } = useTheme();
213
+ const { themes } = usePlugins();
214
+ const { showToast } = useToastContext();
215
+ const { config, updateConfig, resetToDefaults, saveNow } = useConfig();
216
+ const { demoMode, seed, preset } = useDemoMode();
217
+ const { setInputFocused } = useInputFocus();
218
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
219
+
220
+ const [selectedCategory, setSelectedCategory] = useState<SettingCategory>('refresh');
221
+ const [selectedIndex, setSelectedIndex] = useState(0);
222
+ const [focusedPane, setFocusedPane] = useState<'categories' | 'settings'>('categories');
223
+ const [showThemePicker, setShowThemePicker] = useState(false);
224
+
225
+ const [editingSettingKey, setEditingSettingKey] = useState<string | null>(null);
226
+ const [inputValue, setInputValue] = useState('');
227
+
228
+ const width = Math.min(termWidth - 4, 100);
229
+ const height = Math.min(termHeight - 4, 28);
230
+
231
+ // Calculate available height for settings list
232
+ // Modal height - header(1) - footer(1) - outer padding(2) - inner borders(4) - title row with margin(2)
233
+ const settingsAreaHeight = height - 1 - 1 - 2 - 4 - 2 - (demoMode ? 1 : 0);
234
+ // Each setting takes 2 rows (height=1 + marginBottom=1)
235
+ const visibleSettingsCount = Math.max(1, Math.floor(settingsAreaHeight / 2));
236
+
237
+ const settings = BASE_SETTINGS;
238
+
239
+ const categorySettings = settings.filter(s => s.category === selectedCategory);
240
+
241
+ // Calculate scroll offset to keep selected item visible
242
+ const scrollOffset = useMemo(() => {
243
+ if (categorySettings.length <= visibleSettingsCount) return 0;
244
+ const maxOffset = categorySettings.length - visibleSettingsCount;
245
+ if (selectedIndex < visibleSettingsCount - 1) return 0;
246
+ return Math.min(selectedIndex - visibleSettingsCount + 2, maxOffset);
247
+ }, [selectedIndex, categorySettings.length, visibleSettingsCount]);
248
+
249
+ const visibleSettings = categorySettings.slice(scrollOffset, scrollOffset + visibleSettingsCount);
250
+ const hasMoreAbove = scrollOffset > 0;
251
+ const hasMoreBelow = scrollOffset + visibleSettingsCount < categorySettings.length;
252
+
253
+ useEffect(() => {
254
+ setInputFocused(true);
255
+ return () => setInputFocused(false);
256
+ }, [setInputFocused]);
257
+
258
+ const handleSave = useCallback(async () => {
259
+ try {
260
+ await saveNow();
261
+ showToast('Settings saved');
262
+ } catch {
263
+ showToast('Failed to save settings', 'error');
264
+ }
265
+ }, [saveNow, showToast]);
266
+
267
+ const handleReset = useCallback(() => {
268
+ resetToDefaults();
269
+ showToast('Reset to defaults');
270
+ }, [resetToDefaults, showToast]);
271
+
272
+ const startEditingNumber = useCallback(() => {
273
+ const setting = categorySettings[selectedIndex];
274
+ if (!setting || setting.type !== 'number') return;
275
+
276
+ const currentValue = setting.getValue(config);
277
+ setInputValue(currentValue === null ? '' : String(currentValue));
278
+ setEditingSettingKey(setting.key);
279
+ }, [categorySettings, selectedIndex, config]);
280
+
281
+ const commitNumberEdit = useCallback(() => {
282
+ if (editingSettingKey === null) return;
283
+
284
+ const setting = categorySettings.find(s => s.key === editingSettingKey);
285
+ if (!setting || setting.type !== 'number') {
286
+ setEditingSettingKey(null);
287
+ setInputValue('');
288
+ return;
289
+ }
290
+
291
+ const parsed = inputValue.trim() === '' ? null : parseCurrencyInput(inputValue);
292
+ updateConfig(setting.setValue(config, parsed));
293
+ setEditingSettingKey(null);
294
+ setInputValue('');
295
+ }, [editingSettingKey, categorySettings, inputValue, config, updateConfig]);
296
+
297
+ const cancelNumberEdit = useCallback(() => {
298
+ setEditingSettingKey(null);
299
+ setInputValue('');
300
+ }, []);
301
+
302
+ const toggleCurrentSetting = useCallback(() => {
303
+ const setting = categorySettings[selectedIndex];
304
+ if (!setting) return;
305
+
306
+ const currentValue = setting.getValue(config);
307
+ let newConfig: AppConfig;
308
+
309
+ if (setting.type === 'toggle') {
310
+ newConfig = setting.setValue(config, !currentValue);
311
+ } else if (setting.type === 'select' && setting.options && setting.options.length > 0) {
312
+ const currentIdx = setting.options.indexOf(currentValue as string);
313
+ const nextIdx = (currentIdx + 1) % setting.options.length;
314
+ const newValue = setting.options[nextIdx]!;
315
+ newConfig = setting.setValue(config, newValue);
316
+ } else {
317
+ return;
318
+ }
319
+
320
+ updateConfig(newConfig);
321
+ }, [categorySettings, selectedIndex, config, updateConfig]);
322
+
323
+ useKeyboard((key) => {
324
+ if (showThemePicker) return;
325
+
326
+ if (editingSettingKey !== null) {
327
+ if (key.ctrl && (key.name === 'p' || key.name === 's')) {
328
+ return;
329
+ }
330
+ if (key.name === 'escape') {
331
+ cancelNumberEdit();
332
+ return;
333
+ }
334
+ if (key.name === 'return') {
335
+ commitNumberEdit();
336
+ return;
337
+ }
338
+ if (key.name === 'backspace') {
339
+ setInputValue(prev => prev.slice(0, -1));
340
+ return;
341
+ }
342
+ if (key.sequence && /^[0-9.]$/.test(key.sequence)) {
343
+ setInputValue(prev => prev + key.sequence);
344
+ return;
345
+ }
346
+ return;
347
+ }
348
+
349
+ if (key.name === 'escape') {
350
+ onClose();
351
+ return;
352
+ }
353
+
354
+ if (key.ctrl && key.name === 's') {
355
+ handleSave();
356
+ return;
357
+ }
358
+
359
+ if (key.shift && key.name === 'r') {
360
+ handleReset();
361
+ return;
362
+ }
363
+
364
+ if (key.name === 'tab') {
365
+ setFocusedPane(p => p === 'categories' ? 'settings' : 'categories');
366
+ return;
367
+ }
368
+
369
+ if (focusedPane === 'categories') {
370
+ const catIdx = CATEGORIES.findIndex(c => c.id === selectedCategory);
371
+ if (key.name === 'down' || key.name === 'j') {
372
+ const nextIdx = Math.min(catIdx + 1, CATEGORIES.length - 1);
373
+ setSelectedCategory(CATEGORIES[nextIdx]!.id);
374
+ setSelectedIndex(0);
375
+ } else if (key.name === 'up' || key.name === 'k') {
376
+ const prevIdx = Math.max(catIdx - 1, 0);
377
+ setSelectedCategory(CATEGORIES[prevIdx]!.id);
378
+ setSelectedIndex(0);
379
+ } else if (key.name === 'return' || key.name === 'right' || key.name === 'l') {
380
+ setFocusedPane('settings');
381
+ }
382
+ return;
383
+ }
384
+
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') {
391
+ const setting = categorySettings[selectedIndex];
392
+ if (setting?.key === 'theme') {
393
+ setShowThemePicker(true);
394
+ } else if (setting?.type === 'number') {
395
+ startEditingNumber();
396
+ } else {
397
+ toggleCurrentSetting();
398
+ }
399
+ } else if (key.name === 'left' || key.name === 'h') {
400
+ const setting = categorySettings[selectedIndex];
401
+ if (setting?.type === 'select' && setting.options && setting.options.length > 0) {
402
+ const currentValue = setting.getValue(config) as string;
403
+ const currentIdx = setting.options.indexOf(currentValue);
404
+ const prevIdx = (currentIdx - 1 + setting.options.length) % setting.options.length;
405
+ const newValue = setting.options[prevIdx]!;
406
+ updateConfig(setting.setValue(config, newValue));
407
+ }
408
+ } else if (key.name === 'right' || key.name === 'l') {
409
+ const setting = categorySettings[selectedIndex];
410
+ if (setting?.type === 'select' && setting.options && setting.options.length > 0) {
411
+ const currentValue = setting.getValue(config) as string;
412
+ const currentIdx = setting.options.indexOf(currentValue);
413
+ const nextIdx = (currentIdx + 1) % setting.options.length;
414
+ const newValue = setting.options[nextIdx]!;
415
+ updateConfig(setting.setValue(config, newValue));
416
+ }
417
+ }
418
+ }
419
+ });
420
+
421
+ return (
422
+ <ModalBackdrop zIndex={Z_INDEX.MODAL}>
423
+ <box
424
+ width={width}
425
+ height={height}
426
+ border
427
+ borderStyle="double"
428
+ borderColor={colors.primary}
429
+ flexDirection="column"
430
+ backgroundColor={colors.background}
431
+ overflow="hidden"
432
+ >
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>
444
+
445
+ {demoMode && (
446
+ <box flexDirection="row" height={1} paddingLeft={1} gap={2}>
447
+ <text fg={colors.warning}>DEMO MODE</text>
448
+ <text fg={colors.textMuted}>
449
+ Seed: {seed ?? 'default'} | Preset: {preset ?? 'normal'}
450
+ </text>
451
+ </box>
452
+ )}
453
+
454
+ <box flexDirection="row" gap={1} flexGrow={1} padding={1}>
455
+ <box
456
+ flexDirection="column"
457
+ width={20}
458
+ border
459
+ borderStyle={focusedPane === 'categories' ? 'double' : 'single'}
460
+ borderColor={focusedPane === 'categories' ? colors.primary : colors.border}
461
+ padding={1}
462
+ >
463
+ {CATEGORIES.map((cat) => {
464
+ const isActive = cat.id === selectedCategory;
465
+ const isFocusedActive = isActive && focusedPane === 'categories';
466
+ return (
467
+ <box key={cat.id} height={1}>
468
+ <text
469
+ fg={isFocusedActive ? colors.background : isActive ? colors.primary : colors.text}
470
+ {...(isFocusedActive ? { bg: colors.primary } : {})}
471
+ >
472
+ {isActive ? '▸ ' : ' '}{cat.label}
473
+ </text>
474
+ </box>
475
+ );
476
+ })}
477
+ </box>
478
+
479
+ <box
480
+ flexDirection="column"
481
+ flexGrow={1}
482
+ border
483
+ borderStyle={focusedPane === 'settings' ? 'double' : 'single'}
484
+ borderColor={focusedPane === 'settings' ? colors.primary : colors.border}
485
+ padding={0}
486
+ overflow="hidden"
487
+ >
488
+ {showThemePicker ? (
489
+ <ThemePicker
490
+ themes={themes}
491
+ currentThemeId={config.display.theme}
492
+ currentScheme={config.display.colorScheme}
493
+ onSelect={(themeId, scheme) => {
494
+ updateConfig({
495
+ ...config,
496
+ display: {
497
+ ...config.display,
498
+ theme: themeId,
499
+ colorScheme: scheme,
500
+ },
501
+ });
502
+ setShowThemePicker(false);
503
+ }}
504
+ onPreview={(theme) => setPreviewTheme(theme)}
505
+ onCancel={() => {
506
+ setPreviewTheme(null);
507
+ setShowThemePicker(false);
508
+ }}
509
+ />
510
+ ) : (
511
+ <box flexDirection="column" flexGrow={1} padding={1}>
512
+ <box flexDirection="row" justifyContent="space-between" marginBottom={1} height={1}>
513
+ <text fg={colors.textMuted}>
514
+ {CATEGORIES.find(c => c.id === selectedCategory)?.label.toUpperCase()}
515
+ </text>
516
+ {(hasMoreAbove || hasMoreBelow) && (
517
+ <text fg={colors.textSubtle}>
518
+ {hasMoreAbove ? '▲' : ' '}{hasMoreBelow ? '▼' : ' '}
519
+ </text>
520
+ )}
521
+ </box>
522
+
523
+ {visibleSettings.map((setting) => {
524
+ const realIdx = categorySettings.findIndex(s => s.key === setting.key);
525
+ const isSelected = realIdx === selectedIndex && focusedPane === 'settings';
526
+ const value = setting.getValue(config);
527
+ const isEditingThis = setting.type === 'number' && editingSettingKey === setting.key;
528
+
529
+ let displayValue: string;
530
+ if (setting.key === 'theme') {
531
+ const themeName = themes.find(t => t.id === value)?.name ?? value;
532
+ displayValue = `${themeName} ▸`;
533
+ } else if (setting.type === 'toggle') {
534
+ displayValue = value ? '● ON' : '○ OFF';
535
+ } else if (setting.type === 'select') {
536
+ displayValue = `◂ ${value} ▸`;
537
+ } else if (setting.type === 'number' && !isEditingThis) {
538
+ displayValue = formatBudgetDisplay(value as number | null);
539
+ } else {
540
+ displayValue = String(value);
541
+ }
542
+
543
+ return (
544
+ <box
545
+ key={setting.key}
546
+ flexDirection="row"
547
+ height={1}
548
+ marginBottom={1}
549
+ paddingX={1}
550
+ {...(isSelected && !isEditingThis ? { backgroundColor: colors.primary } : {})}
551
+ >
552
+ <text
553
+ flexGrow={1}
554
+ fg={isSelected && !isEditingThis ? colors.background : colors.text}
555
+ >
556
+ {setting.label}
557
+ </text>
558
+ {isEditingThis ? (
559
+ <text fg={colors.text}>
560
+ ${inputValue}<span fg={colors.primary}>█</span>
561
+ </text>
562
+ ) : (
563
+ <text fg={isSelected ? colors.background : colors.textMuted}>
564
+ {displayValue}
565
+ </text>
566
+ )}
567
+ </box>
568
+ );
569
+ })}
570
+ </box>
571
+ )}
572
+ </box>
573
+ </box>
574
+
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>
584
+ </box>
585
+ </ModalBackdrop>
586
+ );
587
+ }
@@ -0,0 +1,87 @@
1
+ import { useMemo } from 'react';
2
+ import { useColors } from '../contexts/ThemeContext.tsx';
3
+ import type { AgentSessionAggregate, AgentSessionStream } from '../../agents/types.ts';
4
+
5
+ interface SidebarBreakdownProps {
6
+ sessions: AgentSessionAggregate[];
7
+ focusedPanel: 'sessions' | 'sidebar' | 'limits';
8
+ getProviderColor: (id: string) => string;
9
+ }
10
+
11
+ function formatCurrency(val: number): string {
12
+ return `$${val.toFixed(2)}`;
13
+ }
14
+
15
+ export function SidebarBreakdown({
16
+ sessions,
17
+ focusedPanel,
18
+ getProviderColor,
19
+ }: SidebarBreakdownProps) {
20
+ const colors = useColors();
21
+
22
+ const { modelStats, modelProviders } = useMemo(() => {
23
+ const stats: Record<string, number> = {};
24
+ const providers: Record<string, string> = {};
25
+ sessions.forEach(s => {
26
+ s.streams.forEach((st: AgentSessionStream) => {
27
+ stats[st.modelId] = (stats[st.modelId] || 0) + (st.costUsd ?? 0);
28
+ if (!providers[st.modelId]) providers[st.modelId] = st.providerId;
29
+ });
30
+ });
31
+ return {
32
+ modelStats: Object.entries(stats).sort(([, a], [, b]) => b - a).slice(0, 5),
33
+ modelProviders: providers,
34
+ };
35
+ }, [sessions]);
36
+
37
+ const providerStats = useMemo(() => {
38
+ const stats: Record<string, number> = {};
39
+ sessions.forEach(s => {
40
+ s.streams.forEach((st: AgentSessionStream) => {
41
+ stats[st.providerId] = (stats[st.providerId] || 0) + (st.costUsd ?? 0);
42
+ });
43
+ });
44
+ return Object.entries(stats).sort(([, a], [, b]) => b - a);
45
+ }, [sessions]);
46
+
47
+ const maxModelCost = Math.max(...modelStats.map(([, c]) => c), 0.01);
48
+
49
+ return (
50
+ <box
51
+ flexDirection="column"
52
+ width={35}
53
+ gap={1}
54
+ border
55
+ borderStyle={focusedPanel === 'sidebar' ? 'double' : 'single'}
56
+ borderColor={focusedPanel === 'sidebar' ? colors.primary : colors.border}
57
+ overflow="hidden"
58
+ >
59
+ <box flexDirection="column" padding={1} flexGrow={1} overflow="hidden">
60
+ <text height={1} fg={colors.textMuted} marginBottom={1}>MODEL BREAKDOWN</text>
61
+ {modelStats.map(([modelId, cost]) => (
62
+ <box key={modelId} flexDirection="column" marginBottom={1}>
63
+ <box flexDirection="row" justifyContent="space-between" height={1}>
64
+ <text height={1} fg={colors.text}>{(modelId.length > 15 ? modelId.slice(0, 14) + '…' : modelId).padEnd(18)}</text>
65
+ <text height={1} fg={colors.textMuted}>{formatCurrency(cost).padStart(7)}</text>
66
+ </box>
67
+ <box flexDirection="row" height={1}>
68
+ <text height={1} fg={getProviderColor(modelProviders[modelId] ?? modelId)}>
69
+ {'█'.repeat(Math.ceil((cost / maxModelCost) * 20)).padEnd(20)}
70
+ </text>
71
+ </box>
72
+ </box>
73
+ ))}
74
+ </box>
75
+
76
+ <box flexDirection="column" padding={1} flexGrow={1} overflow="hidden">
77
+ <text height={1} fg={colors.textMuted} marginBottom={1}>BY PROVIDER</text>
78
+ {providerStats.map(([provider, cost]) => (
79
+ <box key={provider} flexDirection="row" justifyContent="space-between" height={1}>
80
+ <text height={1} fg={getProviderColor(provider)}>{provider.padEnd(18)}</text>
81
+ <text height={1} fg={colors.text}>{formatCurrency(cost).padStart(7)}</text>
82
+ </box>
83
+ ))}
84
+ </box>
85
+ </box>
86
+ );
87
+ }