@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,381 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { useKeyboard } from '@opentui/react';
3
+
4
+ import { captureFrameToFile, createBurstRecorder, type BurstRecorder } from './debug/captureFrame.ts';
5
+ import { pluginLifecycle } from '@/plugins/lifecycle.ts';
6
+ import { ThemeProvider, useColors, useTheme, resolveTheme } from './contexts/ThemeContext.tsx';
7
+ import * as builtinThemeExports from '@/plugins/themes/index.ts';
8
+ import type { ThemePlugin } from '@/plugins/types/theme.ts';
9
+
10
+ const builtinThemeList = Object.values(builtinThemeExports) as ThemePlugin[];
11
+ import { PluginProvider, usePlugins } from './contexts/PluginContext.tsx';
12
+ import { LogProvider, useLogs } from './contexts/LogContext.tsx';
13
+ import { InputProvider, useInputFocus } from './contexts/InputContext.tsx';
14
+ import { AgentSessionProvider, useAgentSessions } from './contexts/AgentSessionContext.tsx';
15
+ import { StorageProvider } from './contexts/StorageContext.tsx';
16
+ import { TimeWindowProvider } from './contexts/TimeWindowContext.tsx';
17
+ import { ConfigProvider, useConfig } from './contexts/ConfigContext.tsx';
18
+ import { DrawerProvider, useDrawer } from './contexts/DrawerContext.tsx';
19
+ import { SessionDetailsDrawer } from './components/SessionDetailsDrawer.tsx';
20
+ import { Header } from './components/Header.tsx';
21
+ import { StatusBar } from './components/StatusBar.tsx';
22
+ import { Toast } from './components/Toast.tsx';
23
+ import { ToastProvider, useToastContext } from './contexts/ToastContext.tsx';
24
+ import { DashboardRuntimeProvider, useDashboardRuntime } from './contexts/DashboardRuntimeContext.tsx';
25
+ import { RealTimeActivityProvider } from './contexts/RealTimeActivityContext.tsx';
26
+ import { RealTimeDashboard } from './views/RealTimeDashboard.tsx';
27
+ import { Dashboard } from './views/Dashboard.tsx';
28
+ import { HistoricalTrendsView } from './views/HistoricalTrendsView.tsx';
29
+ import { ProjectsView } from './views/ProjectsView.tsx';
30
+ import { CommandPalette, type CommandAction } from './components/CommandPalette.tsx';
31
+ import { SettingsModal } from './components/SettingsModal.tsx';
32
+ import { DebugPanel } from './components/DebugPanel.tsx';
33
+ import { copyToClipboard } from '@/utils/clipboard.ts';
34
+ import { useSafeRenderer } from './hooks/useSafeRenderer.ts';
35
+ import type { DemoPreset } from '@/demo/simulator.ts';
36
+ import { DemoModeProvider, useDemoMode } from './contexts/DemoModeContext.tsx';
37
+
38
+ interface AppProps {
39
+ debug?: boolean;
40
+ demoMode?: boolean;
41
+ demoSeed?: number;
42
+ demoPreset?: DemoPreset;
43
+ cliPlugins?: string[];
44
+ }
45
+
46
+ type View = 'dashboard' | 'providers' | 'trends' | 'projects';
47
+
48
+ function AppContent() {
49
+ const renderer = useSafeRenderer();
50
+ const colors = useColors();
51
+ const { theme, setTheme } = useTheme();
52
+ const { refreshAllProviders, isInitialized, themes } = usePlugins();
53
+ const { info } = useLogs();
54
+ const { toast, showToast, dismissToast } = useToastContext();
55
+ const { isInputFocused } = useInputFocus();
56
+ const { config, updateConfig } = useConfig();
57
+ const { demoMode } = useDemoMode();
58
+ const { selectedSession, hideDrawer, isOpen: isDrawerOpen } = useDrawer();
59
+ const { sessions } = useAgentSessions();
60
+ const { debugDataRef, activity, sparkData } = useDashboardRuntime();
61
+ const burstRecorderRef = { current: null as BurstRecorder | null };
62
+
63
+ const refreshInterval = config.refresh.pauseAutoRefresh ? 0 : config.refresh.intervalMs;
64
+
65
+ const [lastRefresh, setLastRefresh] = useState<number | null>(null);
66
+ const [activeView, setActiveView] = useState<View>('dashboard');
67
+ const [showCommandPalette, setShowCommandPalette] = useState(false);
68
+ const [showSettings, setShowSettings] = useState(false);
69
+ const [showDebugPanel, setShowDebugPanel] = useState(false);
70
+
71
+ const isModalOpen = showCommandPalette || showSettings || showDebugPanel || isDrawerOpen;
72
+
73
+ const shutdownPromiseRef = { current: null as Promise<void> | null };
74
+ const gracefulShutdown = useCallback(async () => {
75
+ if (shutdownPromiseRef.current) return shutdownPromiseRef.current;
76
+ shutdownPromiseRef.current = (async () => {
77
+ await pluginLifecycle.stopAll();
78
+ await pluginLifecycle.destroyAll();
79
+ renderer?.destroy();
80
+ })();
81
+ return shutdownPromiseRef.current;
82
+ }, [renderer]);
83
+
84
+ const inspectorData = useMemo(() => ({
85
+ sessions: sessions.map(s => ({
86
+ sessionId: s.sessionId,
87
+ agentName: s.agentName,
88
+ status: s.status,
89
+ totals: s.totals,
90
+ lastActivityAt: s.lastActivityAt,
91
+ })),
92
+ debugData: debugDataRef.current,
93
+ activity,
94
+ sparkData,
95
+ }), [sessions, debugDataRef, activity, sparkData]);
96
+
97
+ const handleCaptureFrame = useCallback(async () => {
98
+ if (!renderer) {
99
+ showToast('No renderer available', 'error');
100
+ return;
101
+ }
102
+ try {
103
+ const result = await captureFrameToFile(renderer, 'manual');
104
+ info(`Frame captured: ${result.framePath}`);
105
+ if (config.notifications.toastsEnabled) {
106
+ showToast('Frame captured');
107
+ }
108
+ } catch (err) {
109
+ const msg = err instanceof Error ? err.message : 'Unknown error';
110
+ info(`Frame capture failed: ${msg}`);
111
+ if (config.notifications.toastsEnabled) {
112
+ showToast('Capture failed', 'error');
113
+ }
114
+ }
115
+ }, [renderer, info, showToast, config.notifications.toastsEnabled]);
116
+
117
+ const handleBurstRecord = useCallback(async () => {
118
+ if (!renderer) return;
119
+ if (burstRecorderRef.current?.recording) {
120
+ const frames = burstRecorderRef.current.stop();
121
+ info(`Burst stopped: ${frames.length} frames captured`);
122
+ if (config.notifications.toastsEnabled) {
123
+ showToast(`Burst: ${frames.length} frames`);
124
+ }
125
+ burstRecorderRef.current = null;
126
+ return;
127
+ }
128
+
129
+ burstRecorderRef.current = createBurstRecorder(renderer, { frameCount: 10, minInterval: 200 });
130
+ info('Burst recording started (10 frames)');
131
+ if (config.notifications.toastsEnabled) {
132
+ showToast('Recording burst...');
133
+ }
134
+
135
+ const frames = await burstRecorderRef.current.start();
136
+ info(`Burst complete: ${frames.length} frames in ${frames[0]?.framePath.split('/').slice(0, -1).join('/')}`);
137
+ if (config.notifications.toastsEnabled) {
138
+ showToast(`Burst: ${frames.length} frames`);
139
+ }
140
+ burstRecorderRef.current = null;
141
+ }, [renderer, info, showToast, config.notifications.toastsEnabled]);
142
+
143
+ const handleMouseUp = useCallback(async () => {
144
+ if (!renderer) return;
145
+ const selection = renderer.getSelection();
146
+ const text = selection?.getSelectedText();
147
+ if (text && text.length > 0) {
148
+ try {
149
+ await copyToClipboard(text);
150
+ if (config.notifications.toastsEnabled) {
151
+ showToast('Copied to clipboard');
152
+ }
153
+ } catch {
154
+ if (config.notifications.toastsEnabled) {
155
+ showToast('Copy failed', 'error');
156
+ }
157
+ }
158
+ renderer.clearSelection();
159
+ }
160
+ }, [renderer, showToast, config.notifications.toastsEnabled]);
161
+
162
+ const commands: CommandAction[] = useMemo(() => {
163
+ const base: CommandAction[] = [
164
+ { id: 'view-dashboard', label: 'Go to Dashboard', shortcut: '1', action: () => setActiveView('dashboard') },
165
+ { id: 'view-providers', label: 'Go to Providers', shortcut: '2', action: () => setActiveView('providers') },
166
+ { id: 'view-trends', label: 'Go to Trends', shortcut: '3', action: () => setActiveView('trends') },
167
+ { id: 'view-projects', label: 'Go to Projects', shortcut: '4', action: () => setActiveView('projects') },
168
+ { id: 'open-settings', label: 'Open Settings', shortcut: ',', action: () => setShowSettings(true) },
169
+ { id: 'refresh', label: 'Refresh Data', shortcut: 'r', action: () => {
170
+ if (isInitialized) {
171
+ info('Manual refresh triggered');
172
+ refreshAllProviders().then(() => setLastRefresh(Date.now()));
173
+ }
174
+ }},
175
+ { id: 'toggle-debug', label: 'Toggle Debug Panel', shortcut: '~', action: () => setShowDebugPanel(prev => !prev) },
176
+ { id: 'capture-frame', label: 'Capture Frame', shortcut: 'Ctrl+P', action: () => handleCaptureFrame() },
177
+ { id: 'quit', label: 'Quit', shortcut: 'q', action: () => void gracefulShutdown() },
178
+ ];
179
+
180
+ const themeCommands: CommandAction[] = themes.map(t => ({
181
+ id: `theme-${t.id}`,
182
+ label: `Theme: ${t.name}`,
183
+ action: () => updateConfig({ ...config, display: { ...config.display, theme: t.id } }),
184
+ }));
185
+
186
+ return [...base, ...themeCommands];
187
+ }, [isInitialized, refreshAllProviders, info, handleCaptureFrame, renderer, gracefulShutdown, themes, config, updateConfig]);
188
+
189
+ useKeyboard((key) => {
190
+ if (key.ctrl && key.name === 'p') {
191
+ handleCaptureFrame();
192
+ return;
193
+ }
194
+ if (key.ctrl && key.shift && key.name === 'p') {
195
+ handleBurstRecord();
196
+ return;
197
+ }
198
+
199
+ if (isModalOpen) {
200
+ return;
201
+ }
202
+
203
+ if (isInputFocused) {
204
+ return;
205
+ }
206
+
207
+ if (key.name === '1') {
208
+ setActiveView('dashboard');
209
+ }
210
+ if (key.name === '2') {
211
+ setActiveView('providers');
212
+ }
213
+ if (key.name === '3') {
214
+ setActiveView('trends');
215
+ }
216
+ if (key.name === '4') {
217
+ setActiveView('projects');
218
+ }
219
+ if (key.sequence === ',') {
220
+ setShowSettings(true);
221
+ }
222
+
223
+ if (key.sequence === ':' || (key.shift && key.name === ';')) {
224
+ setShowCommandPalette(true);
225
+ return;
226
+ }
227
+
228
+ if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
229
+ void gracefulShutdown();
230
+ }
231
+ if (key.name === 'r' && isInitialized) {
232
+ info('Manual refresh triggered');
233
+ refreshAllProviders().then(() => setLastRefresh(Date.now()));
234
+ }
235
+ if (key.sequence === '~' || (key.shift && key.name === 'd')) {
236
+ setShowDebugPanel(true);
237
+ }
238
+ });
239
+
240
+ useEffect(() => {
241
+ if (themes.length === 0) return;
242
+ const resolved = resolveTheme(
243
+ config.display.theme,
244
+ config.display.colorScheme,
245
+ themes,
246
+ renderer?.themeMode ?? null,
247
+ );
248
+ if (resolved.id !== theme.id) {
249
+ setTheme(resolved);
250
+ }
251
+ }, [themes, config.display.theme, config.display.colorScheme, renderer]);
252
+
253
+ useEffect(() => {
254
+ if (isInitialized) {
255
+ info('Application initialized');
256
+ refreshAllProviders().then(() => setLastRefresh(Date.now()));
257
+ }
258
+ }, [isInitialized]);
259
+
260
+ useEffect(() => {
261
+ if (!isInitialized || refreshInterval <= 0) return;
262
+
263
+ const interval = setInterval(() => {
264
+ info('Auto-refresh triggered');
265
+ refreshAllProviders().then(() => setLastRefresh(Date.now()));
266
+ }, refreshInterval);
267
+
268
+ return () => clearInterval(interval);
269
+ }, [isInitialized, refreshInterval, refreshAllProviders, info]);
270
+
271
+ return (
272
+ <box
273
+ flexDirection="column"
274
+ width="100%"
275
+ height="100%"
276
+ backgroundColor={colors.background}
277
+ onMouseUp={handleMouseUp}
278
+ position="relative"
279
+ >
280
+ <Header
281
+ activeView={activeView}
282
+ demoMode={demoMode}
283
+ />
284
+
285
+ {activeView === 'dashboard' && <RealTimeDashboard />}
286
+ {activeView === 'providers' && <Dashboard />}
287
+ {activeView === 'trends' && <HistoricalTrendsView />}
288
+ {activeView === 'projects' && <ProjectsView />}
289
+
290
+ <StatusBar
291
+ lastRefresh={lastRefresh ?? 0}
292
+ nextRefresh={lastRefresh ? lastRefresh + refreshInterval : 0}
293
+ demoMode={demoMode}
294
+ />
295
+
296
+ {toast && config.notifications.toastsEnabled && (
297
+ <Toast
298
+ message={toast.message}
299
+ type={toast.type}
300
+ onDismiss={dismissToast}
301
+ />
302
+ )}
303
+
304
+ {showCommandPalette && (
305
+ <CommandPalette
306
+ commands={commands}
307
+ onClose={() => setShowCommandPalette(false)}
308
+ />
309
+ )}
310
+
311
+ {showSettings && (
312
+ <SettingsModal onClose={() => setShowSettings(false)} />
313
+ )}
314
+
315
+ {showDebugPanel && (
316
+ <DebugPanel
317
+ onClose={() => setShowDebugPanel(false)}
318
+ inspectorData={inspectorData}
319
+ />
320
+ )}
321
+
322
+ {isDrawerOpen && selectedSession && (
323
+ <SessionDetailsDrawer
324
+ session={selectedSession}
325
+ onClose={hideDrawer}
326
+ />
327
+ )}
328
+ </box>
329
+ );
330
+ }
331
+
332
+ function ConfiguredApp({ cliPlugins }: { cliPlugins?: string[] }) {
333
+ const { config, isLoading } = useConfig();
334
+
335
+ if (isLoading) {
336
+ return null;
337
+ }
338
+
339
+ const initialTheme = builtinThemeList.find(t => t.id === config.display.theme);
340
+ const themeProps = initialTheme ? { initialTheme } : {};
341
+
342
+ return (
343
+ <ThemeProvider {...themeProps}>
344
+ <TimeWindowProvider defaultWindow={config.display.defaultTimeWindow}>
345
+ <ToastProvider>
346
+ <PluginProvider {...(cliPlugins ? { cliPlugins } : {})}>
347
+ <RealTimeActivityProvider>
348
+ <AgentSessionProvider autoRefresh={true} refreshInterval={1000}>
349
+ <DashboardRuntimeProvider>
350
+ <DrawerProvider>
351
+ <AppContent />
352
+ </DrawerProvider>
353
+ </DashboardRuntimeProvider>
354
+ </AgentSessionProvider>
355
+ </RealTimeActivityProvider>
356
+ </PluginProvider>
357
+ </ToastProvider>
358
+ </TimeWindowProvider>
359
+ </ThemeProvider>
360
+ );
361
+ }
362
+
363
+ export function App({ debug = false, demoMode = false, demoSeed, demoPreset, cliPlugins }: AppProps) {
364
+ const demoProviderProps: { demoMode: boolean; demoSeed?: number; demoPreset?: DemoPreset } = { demoMode };
365
+ if (demoSeed !== undefined) demoProviderProps.demoSeed = demoSeed;
366
+ if (demoPreset !== undefined) demoProviderProps.demoPreset = demoPreset;
367
+
368
+ return (
369
+ <DemoModeProvider {...demoProviderProps}>
370
+ <LogProvider debugEnabled={debug}>
371
+ <InputProvider>
372
+ <StorageProvider>
373
+ <ConfigProvider>
374
+ <ConfiguredApp {...(cliPlugins ? { cliPlugins } : {})} />
375
+ </ConfigProvider>
376
+ </StorageProvider>
377
+ </InputProvider>
378
+ </LogProvider>
379
+ </DemoModeProvider>
380
+ );
381
+ }
@@ -0,0 +1,166 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react';
3
+ import { RGBA } from '@opentui/core';
4
+ import { useColors } from '../contexts/ThemeContext.tsx';
5
+ import { useInputFocus } from '../contexts/InputContext.tsx';
6
+
7
+ const OVERLAY_BG = RGBA.fromValues(0.0, 0.0, 0.0, 0.5);
8
+
9
+ export interface CommandAction {
10
+ id: string;
11
+ label: string;
12
+ shortcut?: string;
13
+ action: () => void;
14
+ }
15
+
16
+ interface CommandPaletteProps {
17
+ commands: CommandAction[];
18
+ onClose: () => void;
19
+ }
20
+
21
+ export function CommandPalette({ commands, onClose }: CommandPaletteProps) {
22
+ const colors = useColors();
23
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
24
+ const { setInputFocused } = useInputFocus();
25
+ const [query, setQuery] = useState('');
26
+ const [selectedIndex, setSelectedIndex] = useState(0);
27
+
28
+ useEffect(() => {
29
+ setInputFocused(true);
30
+ return () => setInputFocused(false);
31
+ }, [setInputFocused]);
32
+
33
+ const filteredCommands = useMemo(() => {
34
+ if (!query) return commands;
35
+ const lowerQuery = query.toLowerCase();
36
+ return commands.filter(cmd =>
37
+ cmd.label.toLowerCase().includes(lowerQuery) ||
38
+ cmd.id.toLowerCase().includes(lowerQuery)
39
+ );
40
+ }, [commands, query]);
41
+
42
+ const width = Math.max(50, Math.min(termWidth - 4, 80));
43
+ const maxVisibleItems = 15;
44
+ const listHeight = Math.min(filteredCommands.length, maxVisibleItems);
45
+ const height = Math.min(listHeight + 5, termHeight - 4);
46
+
47
+ useEffect(() => {
48
+ setSelectedIndex(0);
49
+ }, [query]);
50
+
51
+ useKeyboard((key) => {
52
+ if (key.name === 'escape') {
53
+ onClose();
54
+ return;
55
+ }
56
+
57
+ if (key.name === 'return') {
58
+ const selected = filteredCommands[selectedIndex];
59
+ if (selected) {
60
+ onClose();
61
+ selected.action();
62
+ }
63
+ return;
64
+ }
65
+
66
+ if (query === '' && !key.ctrl && !key.meta) {
67
+ const keyStr = key.sequence || '';
68
+ const matchingCmd = commands.find(cmd => cmd.shortcut === keyStr);
69
+ if (matchingCmd) {
70
+ onClose();
71
+ matchingCmd.action();
72
+ return;
73
+ }
74
+ }
75
+
76
+ if (key.name === 'down' || (key.ctrl && key.name === 'n') || (query === '' && key.name === 'j')) {
77
+ setSelectedIndex(i => Math.min(i + 1, filteredCommands.length - 1));
78
+ return;
79
+ }
80
+
81
+ if (key.name === 'up' || (key.ctrl && key.name === 'p') || (query === '' && key.name === 'k')) {
82
+ setSelectedIndex(i => Math.max(i - 1, 0));
83
+ return;
84
+ }
85
+
86
+ if (key.name === 'backspace') {
87
+ setQuery(q => q.slice(0, -1));
88
+ return;
89
+ }
90
+
91
+ if (key.sequence && key.sequence.length === 1 && /^[\w\-_., ~:;!@#$%^&*()]$/.test(key.sequence)) {
92
+ setQuery(q => q + key.sequence);
93
+ return;
94
+ }
95
+ });
96
+
97
+ return (
98
+ <box
99
+ position="absolute"
100
+ left={0}
101
+ top={0}
102
+ width="100%"
103
+ height="100%"
104
+ justifyContent="center"
105
+ alignItems="center"
106
+ zIndex={100}
107
+ backgroundColor={OVERLAY_BG}
108
+ >
109
+ <box
110
+ width={width}
111
+ height={height}
112
+ border
113
+ borderStyle="double"
114
+ borderColor={colors.primary}
115
+ flexDirection="column"
116
+ backgroundColor={colors.background}
117
+ overflow="hidden"
118
+ >
119
+ <box padding={1} height={1}>
120
+ <text height={1}>
121
+ <span fg={colors.textMuted}>: </span>
122
+ <span fg={colors.text}>{query}</span>
123
+ <span fg={colors.primary}>│</span>
124
+ </text>
125
+ </box>
126
+
127
+ <scrollbox flexGrow={1}>
128
+ <box flexDirection="column">
129
+ {filteredCommands.length === 0 ? (
130
+ <box padding={1} height={1}>
131
+ <text height={1} fg={colors.textMuted}>No matching commands</text>
132
+ </box>
133
+ ) : (
134
+ filteredCommands.map((cmd, idx) => {
135
+ const isSelected = idx === selectedIndex;
136
+ return (
137
+ <box
138
+ key={cmd.id}
139
+ flexDirection="row"
140
+ justifyContent="space-between"
141
+ paddingX={1}
142
+ height={1}
143
+ {...(isSelected ? { backgroundColor: colors.primary } : {})}
144
+ >
145
+ <text height={1} fg={isSelected ? colors.background : colors.text}>
146
+ {cmd.label}
147
+ </text>
148
+ {cmd.shortcut && (
149
+ <text height={1} fg={isSelected ? colors.background : colors.textSubtle}>
150
+ {cmd.shortcut}
151
+ </text>
152
+ )}
153
+ </box>
154
+ );
155
+ })
156
+ )}
157
+ </box>
158
+ </scrollbox>
159
+
160
+ <box paddingX={1} height={1}>
161
+ <text height={1} fg={colors.textSubtle}>↑↓ navigate Enter select Esc close</text>
162
+ </box>
163
+ </box>
164
+ </box>
165
+ );
166
+ }
@@ -0,0 +1,161 @@
1
+ import { useRef, useImperativeHandle, forwardRef, useEffect } from 'react';
2
+ import type { ScrollBoxRenderable } from '@opentui/core';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import { useLogs, type LogEntry, type LogLevel } from '../contexts/LogContext.tsx';
5
+ import { copyToClipboard } from '@/utils/clipboard.ts';
6
+
7
+ interface DebugConsoleProps {
8
+ height?: number;
9
+ follow?: boolean;
10
+ minLevel?: LogLevel;
11
+ }
12
+
13
+ export interface DebugConsoleHandle {
14
+ scrollToTop: () => void;
15
+ scrollToBottom: () => void;
16
+ }
17
+
18
+ const LOG_LEVEL_RANK: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
19
+
20
+ export const DebugConsole = forwardRef<DebugConsoleHandle, DebugConsoleProps>(function DebugConsole({ height = 15, follow = true, minLevel = 'info' }, ref) {
21
+ const colors = useColors();
22
+ const { logs } = useLogs();
23
+ const scrollboxRef = useRef<ScrollBoxRenderable>(null);
24
+
25
+ useImperativeHandle(ref, () => ({
26
+ scrollToTop: () => {
27
+ scrollboxRef.current?.scrollTo(0);
28
+ },
29
+ scrollToBottom: () => {
30
+ const scrollbox = scrollboxRef.current;
31
+ if (scrollbox) {
32
+ scrollbox.scrollTo(scrollbox.scrollHeight);
33
+ }
34
+ },
35
+ }));
36
+
37
+ useEffect(() => {
38
+ if (follow) {
39
+ const scrollbox = scrollboxRef.current;
40
+ if (scrollbox) {
41
+ scrollbox.scrollTo(scrollbox.scrollHeight);
42
+ }
43
+ }
44
+ }, [logs.length, follow]);
45
+
46
+ const levelColors: Record<LogLevel, string> = {
47
+ debug: colors.textSubtle,
48
+ info: colors.info,
49
+ warn: colors.warning,
50
+ error: colors.error,
51
+ };
52
+
53
+ const levelLabels: Record<LogLevel, string> = {
54
+ debug: 'DBG',
55
+ info: 'INF',
56
+ warn: 'WRN',
57
+ error: 'ERR',
58
+ };
59
+
60
+ const minRank = LOG_LEVEL_RANK[minLevel];
61
+ const filtered = minRank === 0 ? logs : logs.filter(e => LOG_LEVEL_RANK[e.level] >= minRank);
62
+ const visibleLogs = filtered.slice(-50);
63
+
64
+ return (
65
+ <box
66
+ flexDirection="column"
67
+ height={height}
68
+ borderStyle="single"
69
+ borderColor={colors.border}
70
+ backgroundColor={colors.background}
71
+ >
72
+ <box
73
+ flexDirection="row"
74
+ justifyContent="space-between"
75
+ paddingX={1}
76
+ backgroundColor={colors.foreground}
77
+ height={1}
78
+ flexShrink={0}
79
+ >
80
+ <text>
81
+ <span fg={colors.primary}>
82
+ <strong>Debug Console</strong>
83
+ </span>
84
+ <span fg={colors.textMuted}> ({logs.length} entries)</span>
85
+ {follow && <span fg={colors.success}> [FOLLOW]</span>}
86
+ </text>
87
+ <text fg={colors.textSubtle}>~:close f:follow c:clear G:bottom gg:top</text>
88
+ </box>
89
+
90
+ <scrollbox
91
+ ref={scrollboxRef}
92
+ focused
93
+ flexGrow={1}
94
+ style={{
95
+ rootOptions: { backgroundColor: colors.background },
96
+ viewportOptions: { backgroundColor: colors.background },
97
+ scrollbarOptions: {
98
+ trackOptions: {
99
+ foregroundColor: colors.textSubtle,
100
+ backgroundColor: colors.background,
101
+ },
102
+ },
103
+ }}
104
+ >
105
+ <box flexDirection="column" padding={1}>
106
+ {visibleLogs.length === 0 ? (
107
+ <text fg={colors.textSubtle}>No logs yet. Actions will appear here.</text>
108
+ ) : (
109
+ visibleLogs.map((entry) => (
110
+ <LogLine key={entry.id} entry={entry} levelColors={levelColors} levelLabels={levelLabels} colors={colors} />
111
+ ))
112
+ )}
113
+ </box>
114
+ </scrollbox>
115
+ </box>
116
+ );
117
+ });
118
+
119
+ interface LogLineProps {
120
+ entry: LogEntry;
121
+ levelColors: Record<LogLevel, string>;
122
+ levelLabels: Record<LogLevel, string>;
123
+ colors: ReturnType<typeof useColors>;
124
+ }
125
+
126
+ function LogLine({ entry, levelColors, levelLabels, colors }: LogLineProps) {
127
+ const time = new Date(entry.timestamp).toLocaleTimeString('en-US', {
128
+ hour12: false,
129
+ hour: '2-digit',
130
+ minute: '2-digit',
131
+ second: '2-digit',
132
+ });
133
+
134
+ const levelColor = levelColors[entry.level];
135
+ const levelLabel = levelLabels[entry.level];
136
+
137
+ const source = entry.source ? `[${entry.source}]` : '';
138
+ const dataStr = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
139
+
140
+ return (
141
+ <text>
142
+ <span fg={colors.textSubtle}>{time}</span>
143
+ {' '}
144
+ <span fg={levelColor}>{levelLabel}</span>
145
+ {' '}
146
+ {source && <span fg={colors.textMuted}>{source} </span>}
147
+ <span fg={colors.text}>{entry.message}</span>
148
+ {dataStr && <span fg={colors.textSubtle}>{dataStr}</span>}
149
+ </text>
150
+ );
151
+ }
152
+
153
+ export async function copyLogsToClipboard(logs: LogEntry[]): Promise<void> {
154
+ const lines = logs.map((entry) => {
155
+ const time = new Date(entry.timestamp).toISOString();
156
+ const src = entry.source ? `[${entry.source}]` : '';
157
+ const dataStr = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
158
+ return `${time} ${entry.level.toUpperCase().padEnd(5)} ${src} ${entry.message}${dataStr}`;
159
+ });
160
+ await copyToClipboard(lines.join('\n'));
161
+ }