@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,465 @@
1
+ import { useState, useCallback, useMemo } from 'react';
2
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import { useToastContext } from '../contexts/ToastContext.tsx';
5
+ import { useConfig } from '../contexts/ConfigContext.tsx';
6
+ import { usePlugins } from '../contexts/PluginContext.tsx';
7
+ import { useDemoMode } from '../contexts/DemoModeContext.tsx';
8
+ import { type AppConfig } from '@/config/schema.ts';
9
+ import type { ConfigField } from '@/plugins/types/base.ts';
10
+
11
+ type SettingCategory = 'refresh' | 'display' | 'budgets' | 'alerts' | 'notifications' | 'plugins';
12
+
13
+ interface SettingItem {
14
+ key: string;
15
+ label: string;
16
+ category: SettingCategory;
17
+ type: 'toggle' | 'select' | 'number';
18
+ options?: string[];
19
+ getValue: (config: AppConfig) => string | number | boolean;
20
+ setValue: (config: AppConfig, value: string | number | boolean) => AppConfig;
21
+ }
22
+
23
+ const SETTINGS: SettingItem[] = [
24
+ {
25
+ key: 'intervalMs',
26
+ label: 'Refresh Interval',
27
+ category: 'refresh',
28
+ type: 'select',
29
+ options: ['30s', '1m', '2m', '5m'],
30
+ getValue: (c) => {
31
+ const ms = c.refresh.intervalMs;
32
+ if (ms <= 30000) return '30s';
33
+ if (ms <= 60000) return '1m';
34
+ if (ms <= 120000) return '2m';
35
+ return '5m';
36
+ },
37
+ setValue: (c, v) => {
38
+ const map: Record<string, number> = { '30s': 30000, '1m': 60000, '2m': 120000, '5m': 300000 };
39
+ return { ...c, refresh: { ...c.refresh, intervalMs: map[v as string] ?? 60000 } };
40
+ },
41
+ },
42
+ {
43
+ key: 'pauseAutoRefresh',
44
+ label: 'Pause Auto-Refresh',
45
+ category: 'refresh',
46
+ type: 'toggle',
47
+ getValue: (c) => c.refresh.pauseAutoRefresh,
48
+ setValue: (c, v) => ({ ...c, refresh: { ...c.refresh, pauseAutoRefresh: v as boolean } }),
49
+ },
50
+ {
51
+ key: 'defaultTimeWindow',
52
+ label: 'Default Time Window',
53
+ category: 'display',
54
+ type: 'select',
55
+ options: ['5m', '15m', '1h', '24h', '7d', '30d', 'all'],
56
+ getValue: (c) => c.display.defaultTimeWindow,
57
+ setValue: (c, v) => ({ ...c, display: { ...c.display, defaultTimeWindow: v as AppConfig['display']['defaultTimeWindow'] } }),
58
+ },
59
+ {
60
+ key: 'sidebarCollapsed',
61
+ label: 'Sidebar Collapsed',
62
+ category: 'display',
63
+ type: 'toggle',
64
+ getValue: (c) => c.display.sidebarCollapsed,
65
+ setValue: (c, v) => ({ ...c, display: { ...c.display, sidebarCollapsed: v as boolean } }),
66
+ },
67
+ {
68
+ key: 'timeFormat',
69
+ label: 'Time Format',
70
+ category: 'display',
71
+ type: 'select',
72
+ options: ['12h', '24h'],
73
+ getValue: (c) => c.display.timeFormat,
74
+ setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as '12h' | '24h' } }),
75
+ },
76
+ // Color Scheme + Theme selection is handled by ThemePicker in SettingsModal
77
+ {
78
+ key: 'dailyBudget',
79
+ label: 'Daily Budget ($)',
80
+ category: 'budgets',
81
+ type: 'select',
82
+ options: ['None', '$10', '$25', '$50', '$100', '$200'],
83
+ getValue: (c) => {
84
+ const b = c.budgets.daily;
85
+ if (b === null) return 'None';
86
+ return `$${b}`;
87
+ },
88
+ setValue: (c, v) => {
89
+ const map: Record<string, number | null> = { 'None': null, '$10': 10, '$25': 25, '$50': 50, '$100': 100, '$200': 200 };
90
+ return { ...c, budgets: { ...c.budgets, daily: map[v as string] ?? null } };
91
+ },
92
+ },
93
+ {
94
+ key: 'weeklyBudget',
95
+ label: 'Weekly Budget ($)',
96
+ category: 'budgets',
97
+ type: 'select',
98
+ options: ['None', '$50', '$100', '$200', '$500', '$1000'],
99
+ getValue: (c) => {
100
+ const b = c.budgets.weekly;
101
+ if (b === null) return 'None';
102
+ return `$${b}`;
103
+ },
104
+ setValue: (c, v) => {
105
+ const map: Record<string, number | null> = { 'None': null, '$50': 50, '$100': 100, '$200': 200, '$500': 500, '$1000': 1000 };
106
+ return { ...c, budgets: { ...c.budgets, weekly: map[v as string] ?? null } };
107
+ },
108
+ },
109
+ {
110
+ key: 'monthlyBudget',
111
+ label: 'Monthly Budget ($)',
112
+ category: 'budgets',
113
+ type: 'select',
114
+ options: ['None', '$100', '$250', '$500', '$1000', '$2000'],
115
+ getValue: (c) => {
116
+ const b = c.budgets.monthly;
117
+ if (b === null) return 'None';
118
+ return `$${b}`;
119
+ },
120
+ setValue: (c, v) => {
121
+ const map: Record<string, number | null> = { 'None': null, '$100': 100, '$250': 250, '$500': 500, '$1000': 1000, '$2000': 2000 };
122
+ return { ...c, budgets: { ...c.budgets, monthly: map[v as string] ?? null } };
123
+ },
124
+ },
125
+ {
126
+ key: 'warningPercent',
127
+ label: 'Warning Threshold (%)',
128
+ category: 'alerts',
129
+ type: 'select',
130
+ options: ['55%', '60%', '65%', '70%', '75%', '80%', '85%', '90%', '95%'],
131
+ getValue: (c) => `${c.alerts.warningPercent}%`,
132
+ setValue: (c, v) => {
133
+ const percent = parseInt((v as string).replace('%', ''), 10);
134
+ return { ...c, alerts: { ...c.alerts, warningPercent: percent } };
135
+ },
136
+ },
137
+ {
138
+ key: 'criticalPercent',
139
+ label: 'Critical Threshold (%)',
140
+ category: 'alerts',
141
+ type: 'select',
142
+ options: ['75%', '80%', '85%', '90%', '95%', '98%', '100%'],
143
+ getValue: (c) => `${c.alerts.criticalPercent}%`,
144
+ setValue: (c, v) => {
145
+ const percent = parseInt((v as string).replace('%', ''), 10);
146
+ return { ...c, alerts: { ...c.alerts, criticalPercent: percent } };
147
+ },
148
+ },
149
+ {
150
+ key: 'toastsEnabled',
151
+ label: 'Show Toast Notifications',
152
+ category: 'notifications',
153
+ type: 'toggle',
154
+ getValue: (c) => c.notifications.toastsEnabled,
155
+ setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, toastsEnabled: v as boolean } }),
156
+ },
157
+ {
158
+ key: 'soundEnabled',
159
+ label: 'Sound Alerts',
160
+ category: 'notifications',
161
+ type: 'toggle',
162
+ getValue: (c) => c.notifications.soundEnabled,
163
+ setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, soundEnabled: v as boolean } }),
164
+ },
165
+ ];
166
+
167
+ const CATEGORIES: { id: SettingCategory; label: string }[] = [
168
+ { id: 'refresh', label: 'Refresh' },
169
+ { id: 'display', label: 'Display' },
170
+ { id: 'budgets', label: 'Budgets' },
171
+ { id: 'alerts', label: 'Alerts' },
172
+ { id: 'notifications', label: 'Notifications' },
173
+ { id: 'plugins', label: 'Plugins' },
174
+ ];
175
+
176
+ function buildPluginSettings(plugins: Array<{ id: string; name: string; configSchema?: Record<string, ConfigField> }>): SettingItem[] {
177
+ const items: SettingItem[] = [];
178
+ for (const plugin of plugins) {
179
+ if (!plugin.configSchema) continue;
180
+ for (const [fieldKey, field] of Object.entries(plugin.configSchema)) {
181
+ const settingKey = `${plugin.id}.${fieldKey}`;
182
+ const label = `${plugin.name} › ${field.description ?? fieldKey}`;
183
+
184
+ const getPluginValue = (c: AppConfig): unknown => {
185
+ const pluginCfg = c.pluginConfig[plugin.id];
186
+ const raw = pluginCfg?.[fieldKey];
187
+ return raw ?? field.default;
188
+ };
189
+
190
+ const setPluginValue = (c: AppConfig, value: unknown): AppConfig => ({
191
+ ...c,
192
+ pluginConfig: {
193
+ ...c.pluginConfig,
194
+ [plugin.id]: {
195
+ ...(c.pluginConfig[plugin.id] ?? {}),
196
+ [fieldKey]: value,
197
+ },
198
+ },
199
+ });
200
+
201
+ if (field.type === 'boolean') {
202
+ items.push({
203
+ key: settingKey,
204
+ label,
205
+ category: 'plugins' as SettingCategory,
206
+ type: 'toggle',
207
+ getValue: (c) => getPluginValue(c) as boolean,
208
+ setValue: (c, v) => setPluginValue(c, v),
209
+ });
210
+ } else if (field.type === 'select' && field.options) {
211
+ items.push({
212
+ key: settingKey,
213
+ label,
214
+ category: 'plugins' as SettingCategory,
215
+ type: 'select',
216
+ options: field.options.map((o) => o.label),
217
+ getValue: (c) => {
218
+ const val = getPluginValue(c) as string;
219
+ const opt = field.options?.find((o) => o.value === val);
220
+ return opt?.label ?? val ?? '';
221
+ },
222
+ setValue: (c, v) => {
223
+ const opt = field.options?.find((o) => o.label === v);
224
+ return setPluginValue(c, opt?.value ?? v);
225
+ },
226
+ });
227
+ } else if (field.type === 'number') {
228
+ items.push({
229
+ key: settingKey,
230
+ label,
231
+ category: 'plugins' as SettingCategory,
232
+ type: 'number',
233
+ getValue: (c) => (getPluginValue(c) as number) ?? 0,
234
+ setValue: (c, v) => setPluginValue(c, v),
235
+ });
236
+ } else {
237
+ items.push({
238
+ key: settingKey,
239
+ label,
240
+ category: 'plugins' as SettingCategory,
241
+ type: 'select',
242
+ getValue: (c) => String(getPluginValue(c) ?? ''),
243
+ setValue: (c, v) => setPluginValue(c, v),
244
+ });
245
+ }
246
+ }
247
+ }
248
+ return items;
249
+ }
250
+
251
+ export function SettingsView() {
252
+ const colors = useColors();
253
+ const { showToast } = useToastContext();
254
+ const { config, isLoading, updateConfig, resetToDefaults, saveNow } = useConfig();
255
+ const { providers, notifications } = usePlugins();
256
+ const { demoMode, seed, preset } = useDemoMode();
257
+ const { width: terminalWidth } = useTerminalDimensions();
258
+
259
+ const [selectedCategory, setSelectedCategory] = useState<SettingCategory>('refresh');
260
+ const [selectedIndex, setSelectedIndex] = useState(0);
261
+ const [focusedPane, setFocusedPane] = useState<'categories' | 'settings'>('settings');
262
+
263
+ const pluginSettings = useMemo(() => {
264
+ const allPlugins = [
265
+ ...Array.from(providers.values()).map((p) => p.plugin),
266
+ ...notifications,
267
+ ];
268
+ return buildPluginSettings(allPlugins);
269
+ }, [providers, notifications, config]);
270
+
271
+ const allSettings = useMemo(() => [...SETTINGS, ...pluginSettings], [pluginSettings]);
272
+ const categorySettings = allSettings.filter(s => s.category === selectedCategory);
273
+
274
+ const helpText = useMemo(() => {
275
+ if (terminalWidth >= 100) {
276
+ return 'Tab: switch panes ↑↓: navigate Enter/Space: toggle ←→: adjust Ctrl+S: save Shift+R: reset';
277
+ } else if (terminalWidth >= 80) {
278
+ return 'Tab: switch ↑↓: nav Enter: toggle ←→: adjust ^S: save R: reset';
279
+ }
280
+ return 'Tab ↑↓ Enter ←→ ^S:save R:reset';
281
+ }, [terminalWidth]);
282
+
283
+ const handleSave = useCallback(async () => {
284
+ try {
285
+ await saveNow();
286
+ showToast('Settings saved');
287
+ } catch {
288
+ showToast('Failed to save settings', 'error');
289
+ }
290
+ }, [saveNow, showToast]);
291
+
292
+ const handleReset = useCallback(() => {
293
+ resetToDefaults();
294
+ showToast('Reset to defaults');
295
+ }, [resetToDefaults, showToast]);
296
+
297
+ const toggleCurrentSetting = useCallback(() => {
298
+ const setting = categorySettings[selectedIndex];
299
+ if (!setting) return;
300
+
301
+ const currentValue = setting.getValue(config);
302
+ let newConfig: AppConfig;
303
+
304
+ if (setting.type === 'toggle') {
305
+ newConfig = setting.setValue(config, !currentValue);
306
+ } else if (setting.type === 'select' && setting.options) {
307
+ const currentIdx = setting.options.indexOf(currentValue as string);
308
+ const nextIdx = (currentIdx + 1) % setting.options.length;
309
+ newConfig = setting.setValue(config, setting.options[nextIdx]!);
310
+ } else {
311
+ return;
312
+ }
313
+
314
+ updateConfig(newConfig);
315
+ }, [categorySettings, selectedIndex, config, updateConfig]);
316
+
317
+ useKeyboard((key) => {
318
+ if (key.ctrl && key.name === 's') {
319
+ handleSave();
320
+ return;
321
+ }
322
+
323
+ if (key.shift && key.name === 'r') {
324
+ handleReset();
325
+ return;
326
+ }
327
+
328
+ if (key.name === 'tab') {
329
+ setFocusedPane(p => p === 'categories' ? 'settings' : 'categories');
330
+ return;
331
+ }
332
+
333
+ if (focusedPane === 'categories') {
334
+ const catIdx = CATEGORIES.findIndex(c => c.id === selectedCategory);
335
+ if (key.name === 'down' || key.name === 'j') {
336
+ const nextIdx = Math.min(catIdx + 1, CATEGORIES.length - 1);
337
+ setSelectedCategory(CATEGORIES[nextIdx]!.id);
338
+ setSelectedIndex(0);
339
+ } else if (key.name === 'up' || key.name === 'k') {
340
+ const prevIdx = Math.max(catIdx - 1, 0);
341
+ setSelectedCategory(CATEGORIES[prevIdx]!.id);
342
+ setSelectedIndex(0);
343
+ }
344
+ return;
345
+ }
346
+
347
+ if (focusedPane === 'settings') {
348
+ if (key.name === 'down' || key.name === 'j') {
349
+ setSelectedIndex(i => Math.min(i + 1, categorySettings.length - 1));
350
+ } else if (key.name === 'up' || key.name === 'k') {
351
+ setSelectedIndex(i => Math.max(i - 1, 0));
352
+ } else if (key.name === 'return' || key.name === 'space') {
353
+ toggleCurrentSetting();
354
+ } else if (key.name === 'left' || key.name === 'h') {
355
+ const setting = categorySettings[selectedIndex];
356
+ if (setting?.type === 'select' && setting.options) {
357
+ const currentValue = setting.getValue(config) as string;
358
+ const currentIdx = setting.options.indexOf(currentValue);
359
+ const prevIdx = (currentIdx - 1 + setting.options.length) % setting.options.length;
360
+ updateConfig(setting.setValue(config, setting.options[prevIdx]!));
361
+ }
362
+ } else if (key.name === 'right' || key.name === 'l') {
363
+ const setting = categorySettings[selectedIndex];
364
+ if (setting?.type === 'select' && setting.options) {
365
+ const currentValue = setting.getValue(config) as string;
366
+ const currentIdx = setting.options.indexOf(currentValue);
367
+ const nextIdx = (currentIdx + 1) % setting.options.length;
368
+ updateConfig(setting.setValue(config, setting.options[nextIdx]!));
369
+ }
370
+ }
371
+ }
372
+ });
373
+
374
+ if (isLoading) {
375
+ return (
376
+ <box flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center">
377
+ <text fg={colors.textMuted}>Loading settings...</text>
378
+ </box>
379
+ );
380
+ }
381
+
382
+ return (
383
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
384
+ {demoMode && (
385
+ <box flexDirection="row" height={1} paddingLeft={1} gap={2}>
386
+ <text fg={colors.warning}>DEMO MODE</text>
387
+ <text fg={colors.textMuted}>
388
+ Seed: {seed ?? 'default'} | Preset: {preset ?? 'normal'}
389
+ </text>
390
+ </box>
391
+ )}
392
+ <box flexDirection="row" gap={1} flexGrow={1}>
393
+ <box
394
+ flexDirection="column"
395
+ width={20}
396
+ border
397
+ borderStyle={focusedPane === 'categories' ? 'double' : 'single'}
398
+ borderColor={focusedPane === 'categories' ? colors.primary : colors.border}
399
+ padding={1}
400
+ >
401
+ <text fg={colors.textMuted} marginBottom={1}>CATEGORIES</text>
402
+ {CATEGORIES.map((cat) => {
403
+ const isActive = cat.id === selectedCategory;
404
+ const isFocusedActive = isActive && focusedPane === 'categories';
405
+ return (
406
+ <box key={cat.id} height={1}>
407
+ <text
408
+ fg={isFocusedActive ? colors.background : isActive ? colors.primary : colors.text}
409
+ {...(isFocusedActive ? { bg: colors.primary } : {})}
410
+ >
411
+ {isActive ? '▸ ' : ' '}{cat.label}
412
+ </text>
413
+ </box>
414
+ );
415
+ })}
416
+ </box>
417
+
418
+ <box
419
+ flexDirection="column"
420
+ flexGrow={1}
421
+ border
422
+ borderStyle={focusedPane === 'settings' ? 'double' : 'single'}
423
+ borderColor={focusedPane === 'settings' ? colors.primary : colors.border}
424
+ padding={1}
425
+ >
426
+ <text fg={colors.textMuted} marginBottom={1}>
427
+ {CATEGORIES.find(c => c.id === selectedCategory)?.label.toUpperCase()}
428
+ </text>
429
+
430
+ {categorySettings.map((setting, idx) => {
431
+ const isSelected = idx === selectedIndex && focusedPane === 'settings';
432
+ const value = setting.getValue(config);
433
+
434
+ return (
435
+ <box
436
+ key={setting.key}
437
+ flexDirection="row"
438
+ justifyContent="space-between"
439
+ height={2}
440
+ paddingX={1}
441
+ {...(isSelected ? { backgroundColor: colors.primary } : {})}
442
+ >
443
+ <text fg={isSelected ? colors.background : colors.text}>
444
+ {setting.label}
445
+ </text>
446
+ <text fg={isSelected ? colors.background : colors.textMuted}>
447
+ {setting.type === 'toggle'
448
+ ? (value ? '● ON' : '○ OFF')
449
+ : setting.type === 'select'
450
+ ? `◂ ${value} ▸`
451
+ : String(value)
452
+ }
453
+ </text>
454
+ </box>
455
+ );
456
+ })}
457
+ </box>
458
+ </box>
459
+
460
+ <box flexDirection="row" height={1} paddingLeft={1}>
461
+ <text fg={colors.textSubtle}>{helpText}</text>
462
+ </box>
463
+ </box>
464
+ );
465
+ }
@@ -0,0 +1,2 @@
1
+ export { Dashboard } from './Dashboard.tsx';
2
+ export { ProjectsView } from './ProjectsView.tsx';
@@ -0,0 +1,5 @@
1
+ declare global {
2
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
3
+ }
4
+
5
+ export {};
@@ -0,0 +1,18 @@
1
+ declare module '@opentui/react/test-utils' {
2
+ import type { TestRendererOptions } from '@opentui/core/testing';
3
+ import type { ReactNode } from 'react';
4
+
5
+ export function testRender(
6
+ node: ReactNode,
7
+ testRendererOptions: TestRendererOptions
8
+ ): Promise<{
9
+ renderer: import('@opentui/core/testing').TestRenderer;
10
+ mockInput: import('@opentui/core/testing').MockInput;
11
+ mockMouse: import('@opentui/core/testing').MockMouse;
12
+ renderOnce: () => Promise<void>;
13
+ captureCharFrame: () => string;
14
+ resize: (width: number, height: number) => void;
15
+ }>;
16
+ }
17
+
18
+ export {};
@@ -0,0 +1,118 @@
1
+ import { $ } from 'bun';
2
+ import { platform } from 'os';
3
+
4
+ /**
5
+ * Custom clipboard implementation with platform-specific fallbacks.
6
+ *
7
+ * NOTE: OpenTUI 0.1.76+ has native OSC 52 clipboard support via a private
8
+ * `Clipboard` class on the renderer (renderer.copyToClipboardOSC52). However,
9
+ * we keep our own implementation because:
10
+ * 1. The renderer's clipboard is a private property — not part of the public API.
11
+ * 2. Our implementation handles tmux/screen DCS passthrough for OSC 52.
12
+ * 3. We provide platform-specific fallbacks (osascript on macOS, xclip/xsel/
13
+ * wl-copy on Linux, PowerShell on Windows) for terminals that don't support
14
+ * OSC 52, giving broader cross-platform coverage.
15
+ *
16
+ * If OpenTUI exposes a public clipboard API in the future, revisit this.
17
+ */
18
+
19
+ /**
20
+ * Writes text to clipboard via OSC 52 escape sequence.
21
+ * This allows clipboard operations to work over SSH by having
22
+ * the terminal emulator handle the clipboard locally.
23
+ */
24
+ function writeOsc52(text: string): void {
25
+ if (!process.stdout.isTTY) return;
26
+ const base64 = Buffer.from(text).toString('base64');
27
+ const osc52 = `\x1b]52;c;${base64}\x07`;
28
+ // tmux and screen require DCS passthrough wrapping
29
+ const passthrough = process.env['TMUX'] || process.env['STY'];
30
+ const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52;
31
+ process.stdout.write(sequence);
32
+ }
33
+
34
+ let copyMethod: ((text: string) => Promise<void>) | null = null;
35
+
36
+ function getCopyMethod(): (text: string) => Promise<void> {
37
+ if (copyMethod) return copyMethod;
38
+
39
+ const os = platform();
40
+
41
+ if (os === 'darwin' && Bun.which('osascript')) {
42
+ copyMethod = async (text: string) => {
43
+ const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
44
+ await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet();
45
+ };
46
+ return copyMethod;
47
+ }
48
+
49
+ if (os === 'linux') {
50
+ if (process.env['WAYLAND_DISPLAY'] && Bun.which('wl-copy')) {
51
+ copyMethod = async (text: string) => {
52
+ const proc = Bun.spawn(['wl-copy'], { stdin: 'pipe', stdout: 'ignore', stderr: 'ignore' });
53
+ proc.stdin.write(text);
54
+ proc.stdin.end();
55
+ await proc.exited.catch(() => {});
56
+ };
57
+ return copyMethod;
58
+ }
59
+ if (Bun.which('xclip')) {
60
+ copyMethod = async (text: string) => {
61
+ const proc = Bun.spawn(['xclip', '-selection', 'clipboard'], {
62
+ stdin: 'pipe',
63
+ stdout: 'ignore',
64
+ stderr: 'ignore',
65
+ });
66
+ proc.stdin.write(text);
67
+ proc.stdin.end();
68
+ await proc.exited.catch(() => {});
69
+ };
70
+ return copyMethod;
71
+ }
72
+ if (Bun.which('xsel')) {
73
+ copyMethod = async (text: string) => {
74
+ const proc = Bun.spawn(['xsel', '--clipboard', '--input'], {
75
+ stdin: 'pipe',
76
+ stdout: 'ignore',
77
+ stderr: 'ignore',
78
+ });
79
+ proc.stdin.write(text);
80
+ proc.stdin.end();
81
+ await proc.exited.catch(() => {});
82
+ };
83
+ return copyMethod;
84
+ }
85
+ }
86
+
87
+ if (os === 'win32') {
88
+ copyMethod = async (text: string) => {
89
+ const proc = Bun.spawn(
90
+ [
91
+ 'powershell.exe',
92
+ '-NonInteractive',
93
+ '-NoProfile',
94
+ '-Command',
95
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())',
96
+ ],
97
+ {
98
+ stdin: 'pipe',
99
+ stdout: 'ignore',
100
+ stderr: 'ignore',
101
+ }
102
+ );
103
+ proc.stdin.write(text);
104
+ proc.stdin.end();
105
+ await proc.exited.catch(() => {});
106
+ };
107
+ return copyMethod;
108
+ }
109
+
110
+ // Fallback - OSC52 only
111
+ copyMethod = async () => {};
112
+ return copyMethod;
113
+ }
114
+
115
+ export async function copyToClipboard(text: string): Promise<void> {
116
+ writeOsc52(text);
117
+ await getCopyMethod()(text);
118
+ }