@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,660 @@
1
+ import { useState, useRef, useEffect, useMemo } from 'react';
2
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react';
3
+ import type { ScrollBoxRenderable } from '@opentui/core';
4
+ import { useColors } from '../contexts/ThemeContext.tsx';
5
+ import { useLogs, type LogEntry, type LogLevel } from '../contexts/LogContext.tsx';
6
+ import { useInputFocus } from '../contexts/InputContext.tsx';
7
+ import { pluginRegistry } from '@/plugins/registry.ts';
8
+ import { checkAllPluginUpdates, type PluginUpdateInfo } from '@/plugins/update-checker.ts';
9
+ import { ModalBackdrop, Z_INDEX } from './ModalBackdrop.tsx';
10
+
11
+ type DebugTab = 'logs' | 'inspector' | 'plugins';
12
+ type PluginSort = 'name' | 'type' | 'official';
13
+
14
+ interface DebugInspectorData {
15
+ sessions: Array<{
16
+ sessionId: string;
17
+ agentName: string;
18
+ status: string;
19
+ totals: { input: number; output: number };
20
+ lastActivityAt: number;
21
+ }>;
22
+ debugData: {
23
+ lastDeltaTokens: number;
24
+ lastDt: number;
25
+ bucketsShifted: number;
26
+ currentBucketValue: number;
27
+ refreshCount: number;
28
+ lastRefreshTime: number;
29
+ };
30
+ activity: {
31
+ instantRate: number;
32
+ avgRate: number;
33
+ isSpike: boolean;
34
+ };
35
+ sparkData: number[];
36
+ }
37
+
38
+ interface DebugPanelProps {
39
+ onClose: () => void;
40
+ inspectorData?: DebugInspectorData;
41
+ }
42
+
43
+ function padRight(str: string, len: number): string {
44
+ return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
45
+ }
46
+
47
+ const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error'];
48
+ const LOG_LEVEL_RANK: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
49
+
50
+ function filterByLevel(logs: LogEntry[], minLevel: LogLevel): LogEntry[] {
51
+ const minRank = LOG_LEVEL_RANK[minLevel];
52
+ if (minRank === 0) return logs;
53
+ return logs.filter(e => LOG_LEVEL_RANK[e.level] >= minRank);
54
+ }
55
+
56
+ function nextLevel(current: LogLevel): LogLevel {
57
+ const idx = LOG_LEVELS.indexOf(current);
58
+ return LOG_LEVELS[(idx + 1) % LOG_LEVELS.length] ?? 'debug';
59
+ }
60
+
61
+ export function DebugPanel({ onClose, inspectorData }: DebugPanelProps) {
62
+ const colors = useColors();
63
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
64
+ const { logs, clearLogs } = useLogs();
65
+ const { setInputFocused } = useInputFocus();
66
+ const [activeTab, setActiveTab] = useState<DebugTab>('logs');
67
+ const [follow, setFollow] = useState(true);
68
+ const [minLevel, setMinLevel] = useState<LogLevel>('info');
69
+ const [pluginSort, setPluginSort] = useState<PluginSort>('type');
70
+ const scrollboxRef = useRef<ScrollBoxRenderable>(null);
71
+ const inspectorScrollboxRef = useRef<ScrollBoxRenderable>(null);
72
+ const pluginsScrollboxRef = useRef<ScrollBoxRenderable>(null);
73
+ const lastKeyRef = useRef<string | null>(null);
74
+
75
+ const width = Math.min(termWidth - 4, 120);
76
+ const height = Math.min(termHeight - 4, 35);
77
+ const SCROLL_STEP = 3;
78
+
79
+ useEffect(() => {
80
+ setInputFocused(true);
81
+ return () => setInputFocused(false);
82
+ }, [setInputFocused]);
83
+
84
+ useEffect(() => {
85
+ if (follow && activeTab === 'logs') {
86
+ const scrollbox = scrollboxRef.current;
87
+ if (scrollbox) {
88
+ scrollbox.scrollTo(scrollbox.scrollHeight);
89
+ }
90
+ }
91
+ }, [logs.length, follow, activeTab]);
92
+
93
+ useKeyboard((key) => {
94
+ if (key.name === 'escape') {
95
+ onClose();
96
+ return;
97
+ }
98
+
99
+ const tabs: DebugTab[] = ['logs', 'inspector', 'plugins'];
100
+ if (key.name === 'tab' || key.sequence === '[' || key.sequence === ']') {
101
+ setActiveTab(prev => tabs[(tabs.indexOf(prev) + 1) % tabs.length] ?? 'logs');
102
+ lastKeyRef.current = null;
103
+ return;
104
+ }
105
+ if (key.name === '1') {
106
+ setActiveTab('logs');
107
+ lastKeyRef.current = null;
108
+ return;
109
+ }
110
+ if (key.name === '2') {
111
+ setActiveTab('inspector');
112
+ lastKeyRef.current = null;
113
+ return;
114
+ }
115
+ if (key.name === '3') {
116
+ setActiveTab('plugins');
117
+ lastKeyRef.current = null;
118
+ return;
119
+ }
120
+
121
+ if (activeTab === 'logs') {
122
+ if (key.name === 'f') {
123
+ setFollow(prev => !prev);
124
+ return;
125
+ }
126
+ if (key.name === 'l') {
127
+ setMinLevel(prev => nextLevel(prev));
128
+ return;
129
+ }
130
+ if (key.name === 'c') {
131
+ clearLogs();
132
+ return;
133
+ }
134
+ if (key.name === 'down' || key.name === 'j') {
135
+ const box = scrollboxRef.current;
136
+ if (box) box.scrollTo(Math.min(box.scrollTop + SCROLL_STEP, box.scrollHeight));
137
+ setFollow(false);
138
+ lastKeyRef.current = null;
139
+ return;
140
+ }
141
+ if (key.name === 'up' || key.name === 'k') {
142
+ const box = scrollboxRef.current;
143
+ if (box) box.scrollTo(Math.max(box.scrollTop - SCROLL_STEP, 0));
144
+ setFollow(false);
145
+ lastKeyRef.current = null;
146
+ return;
147
+ }
148
+ if (key.shift && key.name === 'g') {
149
+ scrollboxRef.current?.scrollTo(scrollboxRef.current.scrollHeight);
150
+ lastKeyRef.current = null;
151
+ return;
152
+ }
153
+ if (key.name === 'g') {
154
+ if (lastKeyRef.current === 'g') {
155
+ scrollboxRef.current?.scrollTo(0);
156
+ lastKeyRef.current = null;
157
+ } else {
158
+ lastKeyRef.current = 'g';
159
+ }
160
+ return;
161
+ }
162
+ }
163
+
164
+ if (activeTab === 'inspector') {
165
+ if (key.name === 'down' || key.name === 'j') {
166
+ const box = inspectorScrollboxRef.current;
167
+ if (box) box.scrollTo(Math.min(box.scrollTop + SCROLL_STEP, box.scrollHeight));
168
+ lastKeyRef.current = null;
169
+ return;
170
+ }
171
+ if (key.name === 'up' || key.name === 'k') {
172
+ const box = inspectorScrollboxRef.current;
173
+ if (box) box.scrollTo(Math.max(box.scrollTop - SCROLL_STEP, 0));
174
+ lastKeyRef.current = null;
175
+ return;
176
+ }
177
+ if (key.shift && key.name === 'g') {
178
+ inspectorScrollboxRef.current?.scrollTo(inspectorScrollboxRef.current.scrollHeight);
179
+ lastKeyRef.current = null;
180
+ return;
181
+ }
182
+ if (key.name === 'g') {
183
+ if (lastKeyRef.current === 'g') {
184
+ inspectorScrollboxRef.current?.scrollTo(0);
185
+ lastKeyRef.current = null;
186
+ } else {
187
+ lastKeyRef.current = 'g';
188
+ }
189
+ return;
190
+ }
191
+ }
192
+
193
+ if (activeTab === 'plugins') {
194
+ if (key.name === 's') {
195
+ setPluginSort(prev => {
196
+ const order: PluginSort[] = ['name', 'type', 'official'];
197
+ return order[(order.indexOf(prev) + 1) % order.length] ?? 'name';
198
+ });
199
+ return;
200
+ }
201
+ if (key.name === 'down' || key.name === 'j') {
202
+ const box = pluginsScrollboxRef.current;
203
+ if (box) box.scrollTo(Math.min(box.scrollTop + SCROLL_STEP, box.scrollHeight));
204
+ lastKeyRef.current = null;
205
+ return;
206
+ }
207
+ if (key.name === 'up' || key.name === 'k') {
208
+ const box = pluginsScrollboxRef.current;
209
+ if (box) box.scrollTo(Math.max(box.scrollTop - SCROLL_STEP, 0));
210
+ lastKeyRef.current = null;
211
+ return;
212
+ }
213
+ if (key.shift && key.name === 'g') {
214
+ pluginsScrollboxRef.current?.scrollTo(pluginsScrollboxRef.current.scrollHeight);
215
+ lastKeyRef.current = null;
216
+ return;
217
+ }
218
+ if (key.name === 'g') {
219
+ if (lastKeyRef.current === 'g') {
220
+ pluginsScrollboxRef.current?.scrollTo(0);
221
+ lastKeyRef.current = null;
222
+ } else {
223
+ lastKeyRef.current = 'g';
224
+ }
225
+ return;
226
+ }
227
+ }
228
+
229
+ lastKeyRef.current = null;
230
+ });
231
+
232
+ const levelColors: Record<LogLevel, string> = {
233
+ debug: colors.textSubtle,
234
+ info: colors.info,
235
+ warn: colors.warning,
236
+ error: colors.error,
237
+ };
238
+
239
+ const levelLabels: Record<LogLevel, string> = {
240
+ debug: 'DBG',
241
+ info: 'INF',
242
+ warn: 'WRN',
243
+ error: 'ERR',
244
+ };
245
+
246
+ return (
247
+ <ModalBackdrop zIndex={Z_INDEX.MODAL}>
248
+ <box
249
+ width={width}
250
+ height={height}
251
+ border
252
+ borderStyle="double"
253
+ borderColor={colors.warning}
254
+ flexDirection="column"
255
+ backgroundColor={colors.background}
256
+ overflow="hidden"
257
+ >
258
+ <box
259
+ flexDirection="row"
260
+ justifyContent="space-between"
261
+ paddingX={1}
262
+ backgroundColor={colors.foreground}
263
+ height={1}
264
+ flexShrink={0}
265
+ >
266
+ <box flexDirection="row" gap={2}>
267
+ <text
268
+ fg={activeTab === 'logs' ? colors.background : colors.textMuted}
269
+ {...(activeTab === 'logs' ? { bg: colors.primary } : {})}
270
+ >
271
+ {' LOGS '}
272
+ </text>
273
+ <text
274
+ fg={activeTab === 'inspector' ? colors.background : colors.textMuted}
275
+ {...(activeTab === 'inspector' ? { bg: colors.primary } : {})}
276
+ >
277
+ {' INSPECTOR '}
278
+ </text>
279
+ <text
280
+ fg={activeTab === 'plugins' ? colors.background : colors.textMuted}
281
+ {...(activeTab === 'plugins' ? { bg: colors.primary } : {})}
282
+ >
283
+ {' PLUGINS '}
284
+ </text>
285
+ </box>
286
+ <text fg={colors.textSubtle}>1/2/3:switch Esc:close</text>
287
+ </box>
288
+
289
+ {activeTab === 'logs' && (
290
+ <LogsTab
291
+ logs={filterByLevel(logs, minLevel)}
292
+ follow={follow}
293
+ scrollboxRef={scrollboxRef}
294
+ levelColors={levelColors}
295
+ levelLabels={levelLabels}
296
+ colors={colors}
297
+ />
298
+ )}
299
+
300
+ {activeTab === 'inspector' && (
301
+ <InspectorTab data={inspectorData} colors={colors} scrollboxRef={inspectorScrollboxRef} />
302
+ )}
303
+
304
+ {activeTab === 'plugins' && (
305
+ <PluginsTab colors={colors} scrollboxRef={pluginsScrollboxRef} sortBy={pluginSort} />
306
+ )}
307
+
308
+ <box
309
+ flexDirection="row"
310
+ paddingX={1}
311
+ backgroundColor={colors.foreground}
312
+ height={1}
313
+ flexShrink={0}
314
+ >
315
+ {activeTab === 'logs' && (
316
+ <text fg={colors.textSubtle}>
317
+ j/k:scroll f:follow{follow ? '(on)' : '(off)'} l:{minLevel.toUpperCase()} c:clear gg:top G:bottom
318
+ </text>
319
+ )}
320
+ {activeTab === 'inspector' && (
321
+ <text fg={colors.textSubtle}>
322
+ j/k:scroll gg:top G:bottom
323
+ </text>
324
+ )}
325
+ {activeTab === 'plugins' && (
326
+ <text fg={colors.textSubtle}>
327
+ j/k:scroll s:sort({pluginSort}) gg:top G:bottom
328
+ </text>
329
+ )}
330
+ </box>
331
+ </box>
332
+ </ModalBackdrop>
333
+ );
334
+ }
335
+
336
+ interface LogsTabProps {
337
+ logs: LogEntry[];
338
+ follow: boolean;
339
+ scrollboxRef: React.RefObject<ScrollBoxRenderable | null>;
340
+ levelColors: Record<LogLevel, string>;
341
+ levelLabels: Record<LogLevel, string>;
342
+ colors: ReturnType<typeof useColors>;
343
+ }
344
+
345
+ function LogsTab({ logs, follow, scrollboxRef, levelColors, levelLabels, colors }: LogsTabProps) {
346
+ const frozenLogsRef = useRef<LogEntry[] | null>(null);
347
+ const frozenAtCountRef = useRef(0);
348
+
349
+ if (follow) {
350
+ frozenLogsRef.current = null;
351
+ frozenAtCountRef.current = 0;
352
+ } else if (frozenLogsRef.current === null) {
353
+ frozenLogsRef.current = logs.slice(-200);
354
+ frozenAtCountRef.current = logs.length;
355
+ }
356
+
357
+ const visibleLogs = frozenLogsRef.current ?? logs.slice(-200);
358
+ const pendingCount = frozenLogsRef.current ? Math.max(0, logs.length - frozenAtCountRef.current) : 0;
359
+
360
+ return (
361
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
362
+ <box paddingLeft={1} height={1} flexShrink={0}>
363
+ <text fg={colors.textMuted}>
364
+ {logs.length} entries{follow ? ' [FOLLOW]' : ` [PAUSED${pendingCount > 0 ? ` +${pendingCount} new` : ''}]`}
365
+ </text>
366
+ </box>
367
+ <scrollbox
368
+ ref={scrollboxRef}
369
+ focused
370
+ flexGrow={1}
371
+ style={{
372
+ rootOptions: { backgroundColor: colors.background },
373
+ viewportOptions: { backgroundColor: colors.background },
374
+ scrollbarOptions: {
375
+ trackOptions: {
376
+ foregroundColor: colors.textSubtle,
377
+ backgroundColor: colors.background,
378
+ },
379
+ },
380
+ }}
381
+ >
382
+ <box flexDirection="column" padding={1}>
383
+ {visibleLogs.length === 0 ? (
384
+ <text fg={colors.textSubtle}>No logs yet.</text>
385
+ ) : (
386
+ visibleLogs.map((entry) => (
387
+ <LogLine
388
+ key={entry.id}
389
+ entry={entry}
390
+ levelColors={levelColors}
391
+ levelLabels={levelLabels}
392
+ colors={colors}
393
+ />
394
+ ))
395
+ )}
396
+ </box>
397
+ </scrollbox>
398
+ </box>
399
+ );
400
+ }
401
+
402
+ interface LogLineProps {
403
+ entry: LogEntry;
404
+ levelColors: Record<LogLevel, string>;
405
+ levelLabels: Record<LogLevel, string>;
406
+ colors: ReturnType<typeof useColors>;
407
+ }
408
+
409
+ function LogLine({ entry, levelColors, levelLabels, colors }: LogLineProps) {
410
+ const time = new Date(entry.timestamp).toLocaleTimeString('en-US', {
411
+ hour12: false,
412
+ hour: '2-digit',
413
+ minute: '2-digit',
414
+ second: '2-digit',
415
+ });
416
+
417
+ const levelColor = levelColors[entry.level];
418
+ const levelLabel = levelLabels[entry.level];
419
+ const source = entry.source ? `[${entry.source}]` : '';
420
+ const dataStr = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
421
+
422
+ return (
423
+ <text height={1}>
424
+ <span fg={colors.textSubtle}>{time}</span>
425
+ {' '}
426
+ <span fg={levelColor}>{levelLabel}</span>
427
+ {' '}
428
+ {source && <span fg={colors.textMuted}>{source} </span>}
429
+ <span fg={colors.text}>{entry.message}</span>
430
+ {dataStr && <span fg={colors.textSubtle}>{dataStr}</span>}
431
+ </text>
432
+ );
433
+ }
434
+
435
+ interface InspectorTabProps {
436
+ data: DebugInspectorData | undefined;
437
+ colors: ReturnType<typeof useColors>;
438
+ scrollboxRef: React.RefObject<ScrollBoxRenderable | null>;
439
+ }
440
+
441
+ function InspectorTab({ data, colors, scrollboxRef }: InspectorTabProps) {
442
+ const { height: termHeight } = useTerminalDimensions();
443
+ const isCompact = termHeight < 30;
444
+
445
+ if (!data) {
446
+ return (
447
+ <box flexGrow={1} justifyContent="center" alignItems="center">
448
+ <text fg={colors.textMuted}>Inspector data not available</text>
449
+ </box>
450
+ );
451
+ }
452
+
453
+ const { sessions, debugData, activity, sparkData } = data;
454
+ const totalTokens = sessions.reduce((sum, s) => sum + s.totals.input + s.totals.output, 0);
455
+ const activeSessions = sessions.filter(s => s.status === 'active');
456
+ const now = Date.now();
457
+
458
+ if (isCompact) {
459
+ return (
460
+ <box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
461
+ <box flexDirection="row" height={1} gap={2} flexShrink={0}>
462
+ <text fg={colors.textMuted}>rate:{(activity.instantRate || 0).toFixed(0)}/s</text>
463
+ <text fg={colors.textMuted}>avg:{(activity.avgRate || 0).toFixed(0)}/s</text>
464
+ <text fg={colors.textMuted}>tokens:{totalTokens.toLocaleString()}</text>
465
+ <text fg={activity.isSpike ? colors.warning : colors.textMuted}>{activity.isSpike ? 'SPIKE' : ''}</text>
466
+ </box>
467
+ <box flexDirection="row" marginTop={1} height={1}>
468
+ <text width={18} fg={colors.textSubtle}>{padRight('SESSION', 18)}</text>
469
+ <text width={10} fg={colors.textSubtle}>{padRight('AGENT', 10)}</text>
470
+ <text width={8} fg={colors.textSubtle}>{padRight('STATUS', 8)}</text>
471
+ <text width={10} fg={colors.textSubtle}>{padRight('TOKENS', 10)}</text>
472
+ </box>
473
+ <scrollbox ref={scrollboxRef} flexGrow={1}>
474
+ {sessions.slice(0, 15).map(s => (
475
+ <box key={s.sessionId} flexDirection="row" height={1}>
476
+ <text width={18} fg={colors.text}>{padRight(s.sessionId.slice(0, 17), 18)}</text>
477
+ <text width={10} fg={colors.text}>{padRight(s.agentName.slice(0, 9), 10)}</text>
478
+ <text width={8} fg={s.status === 'active' ? colors.success : colors.textMuted}>{padRight(s.status.slice(0, 7), 8)}</text>
479
+ <text width={10} fg={colors.text}>{padRight((s.totals.input + s.totals.output).toLocaleString(), 10)}</text>
480
+ </box>
481
+ ))}
482
+ </scrollbox>
483
+ </box>
484
+ );
485
+ }
486
+
487
+ return (
488
+ <box flexDirection="column" flexGrow={1} padding={1} overflow="hidden">
489
+ <box flexDirection="row" gap={2} height={8} flexShrink={0}>
490
+ <box flexDirection="column" flexGrow={1} border borderColor={colors.border} padding={1} overflow="hidden">
491
+ <text height={1} fg={colors.primary}>{padRight('Bucket Data', 30)}</text>
492
+ <text height={1} fg={colors.textMuted}>{padRight('deltaTokens: ' + debugData.lastDeltaTokens.toLocaleString(), 30)}</text>
493
+ <text height={1} fg={colors.textMuted}>{padRight('dt: ' + debugData.lastDt.toFixed(3) + 's', 30)}</text>
494
+ <text height={1} fg={colors.textMuted}>{padRight('shifted: ' + debugData.bucketsShifted, 30)}</text>
495
+ <text height={1} fg={colors.textMuted}>{padRight('currBucket: ' + debugData.currentBucketValue.toFixed(1), 30)}</text>
496
+ <text height={1} fg={colors.textMuted}>{padRight('buckets[5]: [' + sparkData.slice(-5).map(v => v.toFixed(0)).join(',') + ']', 30)}</text>
497
+ </box>
498
+
499
+ <box flexDirection="column" flexGrow={1} border borderColor={colors.border} padding={1} overflow="hidden">
500
+ <text height={1} fg={colors.primary}>{padRight('Activity State', 30)}</text>
501
+ <text height={1} fg={colors.textMuted}>{padRight('instantRate: ' + (activity.instantRate || 0).toFixed(1) + '/s', 30)}</text>
502
+ <text height={1} fg={colors.textMuted}>{padRight('avgRate: ' + (activity.avgRate || 0).toFixed(1) + '/s', 30)}</text>
503
+ <text height={1} fg={colors.textMuted}>{padRight('isSpike: ' + (activity.isSpike ? 'YES' : 'no'), 30)}</text>
504
+ </box>
505
+
506
+ <box flexDirection="column" flexGrow={1} border borderColor={colors.border} padding={1} overflow="hidden">
507
+ <text height={1} fg={colors.primary}>{padRight('Refresh Stats', 30)}</text>
508
+ <text height={1} fg={colors.textMuted}>{padRight('count: ' + String(debugData.refreshCount), 30)}</text>
509
+ <text height={1} fg={colors.textMuted}>{padRight('last: ' + new Date(debugData.lastRefreshTime).toLocaleTimeString(), 30)}</text>
510
+ <text height={1} fg={colors.textMuted}>{padRight('age: ' + ((now - debugData.lastRefreshTime) / 1000).toFixed(1) + 's', 30)}</text>
511
+ <text height={1} fg={colors.textMuted}>{padRight('tokens: ' + totalTokens.toLocaleString(), 30)}</text>
512
+ </box>
513
+ </box>
514
+
515
+ <box flexDirection="column" flexGrow={1} marginTop={1} border borderColor={colors.border} padding={1} overflow="hidden">
516
+ <text height={1} fg={colors.primary}>
517
+ <strong>Sessions ({sessions.length} total, {activeSessions.length} active)</strong>
518
+ </text>
519
+ <box flexDirection="row" marginTop={1} height={1}>
520
+ <text width={22} fg={colors.textSubtle}>{padRight('SESSION ID', 22)}</text>
521
+ <text width={12} fg={colors.textSubtle}>{padRight('AGENT', 12)}</text>
522
+ <text width={10} fg={colors.textSubtle}>{padRight('STATUS', 10)}</text>
523
+ <text width={12} fg={colors.textSubtle}>{padRight('TOKENS', 12)}</text>
524
+ <text width={10} fg={colors.textSubtle}>{padRight('AGE', 10)}</text>
525
+ </box>
526
+ <scrollbox ref={scrollboxRef} flexGrow={1}>
527
+ {sessions.slice(0, 15).map(s => {
528
+ const age = now - s.lastActivityAt;
529
+ const ageStr = age < 60000 ? `${(age / 1000).toFixed(0)}s` : `${(age / 60000).toFixed(1)}m`;
530
+ return (
531
+ <box key={s.sessionId} flexDirection="row" height={1}>
532
+ <text width={22} fg={colors.text}>{padRight(s.sessionId.slice(0, 21), 22)}</text>
533
+ <text width={12} fg={colors.text}>{padRight(s.agentName, 12)}</text>
534
+ <text width={10} fg={s.status === 'active' ? colors.success : colors.textMuted}>{padRight(s.status, 10)}</text>
535
+ <text width={12} fg={colors.text}>{padRight((s.totals.input + s.totals.output).toLocaleString(), 12)}</text>
536
+ <text width={10} fg={age < 120000 ? colors.success : colors.textMuted}>{padRight(ageStr, 10)}</text>
537
+ </box>
538
+ );
539
+ })}
540
+ </scrollbox>
541
+ </box>
542
+ </box>
543
+ );
544
+ }
545
+
546
+ interface PluginsTabProps {
547
+ colors: ReturnType<typeof useColors>;
548
+ scrollboxRef: React.RefObject<ScrollBoxRenderable | null>;
549
+ sortBy: PluginSort;
550
+ }
551
+
552
+ const TYPE_ORDER: Record<string, number> = { provider: 0, agent: 1, theme: 2, notification: 3 };
553
+
554
+ function isOfficialPlugin(pluginId: string, pluginType: string): boolean {
555
+ const source = pluginRegistry.getSource(pluginType as 'provider' | 'agent' | 'theme' | 'notification', pluginId);
556
+ return source === 'builtin';
557
+ }
558
+
559
+ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
560
+ const allPlugins = useMemo(() => {
561
+ const plugins = pluginRegistry.getAllPlugins();
562
+ return [...plugins].sort((a, b) => {
563
+ if (sortBy === 'name') {
564
+ return a.name.localeCompare(b.name);
565
+ }
566
+ if (sortBy === 'official') {
567
+ const aOfficial = isOfficialPlugin(a.id, a.type) ? 0 : 1;
568
+ const bOfficial = isOfficialPlugin(b.id, b.type) ? 0 : 1;
569
+ if (aOfficial !== bOfficial) return aOfficial - bOfficial;
570
+ return a.name.localeCompare(b.name);
571
+ }
572
+ const typeA = TYPE_ORDER[a.type] ?? 99;
573
+ const typeB = TYPE_ORDER[b.type] ?? 99;
574
+ if (typeA !== typeB) return typeA - typeB;
575
+ return a.name.localeCompare(b.name);
576
+ });
577
+ }, [sortBy]);
578
+
579
+ const [updates, setUpdates] = useState<Map<string, PluginUpdateInfo>>(new Map());
580
+
581
+ useEffect(() => {
582
+ const npmPlugins = allPlugins.filter(
583
+ (p) => pluginRegistry.getSource(p.type, p.id) === 'npm',
584
+ );
585
+ if (npmPlugins.length === 0) return;
586
+
587
+ checkAllPluginUpdates(
588
+ npmPlugins.map((p) => ({ id: p.id, type: p.type, version: p.version })),
589
+ ).then(setUpdates);
590
+ }, [allPlugins]);
591
+
592
+ const typeCounts = useMemo(() => {
593
+ const counts: Record<string, number> = {};
594
+ for (const p of allPlugins) {
595
+ counts[p.type] = (counts[p.type] ?? 0) + 1;
596
+ }
597
+ return counts;
598
+ }, [allPlugins]);
599
+
600
+ const typeColors: Record<string, string> = {
601
+ provider: colors.info,
602
+ agent: colors.success,
603
+ theme: colors.warning,
604
+ notification: colors.primary,
605
+ };
606
+
607
+ const summary = Object.entries(typeCounts)
608
+ .map(([type, count]) => `${count} ${type}${count !== 1 ? 's' : ''}`)
609
+ .join(', ');
610
+
611
+ const updatesAvailable = [...updates.values()].filter((u) => u.hasUpdate).length;
612
+
613
+ return (
614
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
615
+ <box paddingLeft={1} height={1} flexShrink={0}>
616
+ <text fg={colors.textMuted}>
617
+ {allPlugins.length} plugins ({summary})
618
+ {updatesAvailable > 0 ? ` · ${updatesAvailable} update${updatesAvailable !== 1 ? 's' : ''} available` : ''}
619
+ </text>
620
+ </box>
621
+ <box flexDirection="row" paddingX={1} height={1} flexShrink={0}>
622
+ <text width={22} fg={colors.textSubtle}>{padRight('PLUGIN', 22)}</text>
623
+ <text width={14} fg={colors.textSubtle}>{padRight('TYPE', 14)}</text>
624
+ <text width={14} fg={colors.textSubtle}>{padRight('VERSION', 14)}</text>
625
+ <text fg={colors.textSubtle}>DESCRIPTION</text>
626
+ </box>
627
+ <scrollbox ref={scrollboxRef} flexGrow={1}>
628
+ <box flexDirection="column" paddingX={1}>
629
+ {allPlugins.map((plugin) => {
630
+ const key = `${plugin.type}-${plugin.id}`;
631
+ const updateInfo = updates.get(key);
632
+ const official = isOfficialPlugin(plugin.id, plugin.type);
633
+
634
+ let versionSuffix = '';
635
+ let versionColor = colors.textMuted;
636
+ if (updateInfo?.hasUpdate && updateInfo.latestVersion) {
637
+ versionSuffix = ' ↑';
638
+ versionColor = colors.warning;
639
+ } else if (updateInfo && !updateInfo.hasUpdate && updateInfo.latestVersion) {
640
+ versionSuffix = ' ✓';
641
+ versionColor = colors.success;
642
+ }
643
+
644
+ return (
645
+ <box key={key} flexDirection="row" height={1}>
646
+ <text width={22}>
647
+ {official && <span fg={colors.success}>❖ </span>}
648
+ <span fg={colors.text}>{padRight(plugin.name, official ? 20 : 22)}</span>
649
+ </text>
650
+ <text width={14} fg={typeColors[plugin.type] ?? colors.textMuted}>{padRight(plugin.type, 14)}</text>
651
+ <text width={14} fg={versionColor}>{padRight(plugin.version + versionSuffix, 14)}</text>
652
+ <text fg={colors.textSubtle}>{plugin.meta?.description ?? ''}</text>
653
+ </box>
654
+ );
655
+ })}
656
+ </box>
657
+ </scrollbox>
658
+ </box>
659
+ );
660
+ }
@@ -0,0 +1,22 @@
1
+ import { useColors } from '../contexts/ThemeContext.tsx';
2
+
3
+ export interface HintItem {
4
+ key: string;
5
+ action: string;
6
+ }
7
+
8
+ interface FooterHintsProps {
9
+ hints: HintItem[];
10
+ }
11
+
12
+ export function FooterHints({ hints }: FooterHintsProps) {
13
+ const colors = useColors();
14
+
15
+ return (
16
+ <box flexDirection="row" height={1} paddingLeft={1}>
17
+ <text fg={colors.textSubtle}>
18
+ {hints.map((h, i) => `${h.key} ${h.action}${i < hints.length - 1 ? ' ' : ''}`).join('')}
19
+ </text>
20
+ </box>
21
+ );
22
+ }