@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.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
|
2
|
+
import type { AgentPlugin, SessionParseOptions } from '@/plugins/types/agent.ts';
|
|
3
|
+
import type { AgentSessionAggregate, AgentInfo, AgentId } from '@/agents/types.ts';
|
|
4
|
+
import { aggregateSessionUsage } from '@/agents/aggregator.ts';
|
|
5
|
+
import { priceSessions } from '@/agents/costing.ts';
|
|
6
|
+
import { pluginRegistry } from '@/plugins/registry.ts';
|
|
7
|
+
import { createSandboxedHttpClient, createPluginLogger } from '@/plugins/sandbox.ts';
|
|
8
|
+
import { createPluginContext } from '@/plugins/plugin-context-factory.ts';
|
|
9
|
+
import { useLogs } from './LogContext.tsx';
|
|
10
|
+
import { usePlugins } from './PluginContext.tsx';
|
|
11
|
+
import { useStorage } from './StorageContext.tsx';
|
|
12
|
+
import type { PricingSource } from '@/storage/types.ts';
|
|
13
|
+
import { useDemoMode } from './DemoModeContext.tsx';
|
|
14
|
+
import { useTimeWindow } from './TimeWindowContext.tsx';
|
|
15
|
+
|
|
16
|
+
interface AgentSessionContextValue {
|
|
17
|
+
sessions: AgentSessionAggregate[];
|
|
18
|
+
agents: AgentInfo[];
|
|
19
|
+
isLoading: boolean;
|
|
20
|
+
lastRefreshAt: number | null;
|
|
21
|
+
error: string | null;
|
|
22
|
+
refreshSessions: (options?: SessionParseOptions) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const AgentSessionContext = createContext<AgentSessionContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
const sessionPersistenceFingerprints = new Map<string, string>();
|
|
28
|
+
|
|
29
|
+
function sessionFingerprint(session: AgentSessionAggregate): string {
|
|
30
|
+
return `${session.lastActivityAt}:${session.totalCostUsd ?? 0}:${session.requestCount}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface AgentSessionProviderProps {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
autoRefresh?: boolean;
|
|
36
|
+
refreshInterval?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function AgentSessionProvider({
|
|
40
|
+
children,
|
|
41
|
+
autoRefresh = false,
|
|
42
|
+
refreshInterval = 30000,
|
|
43
|
+
}: AgentSessionProviderProps) {
|
|
44
|
+
const [sessions, setSessions] = useState<AgentSessionAggregate[]>([]);
|
|
45
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
46
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
47
|
+
const [lastRefreshAt, setLastRefreshAt] = useState<number | null>(null);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const { debug, warn, error: logError } = useLogs();
|
|
50
|
+
const { isInitialized: pluginsInitialized } = usePlugins();
|
|
51
|
+
const { isReady: storageReady, recordAgentSession } = useStorage();
|
|
52
|
+
const { demoMode, simulator } = useDemoMode();
|
|
53
|
+
const { windowMs } = useTimeWindow();
|
|
54
|
+
const hasBackfilled = useRef(false);
|
|
55
|
+
|
|
56
|
+
const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
|
|
57
|
+
const agentPlugins = pluginRegistry.getAll('agent');
|
|
58
|
+
const discovered: AgentInfo[] = [];
|
|
59
|
+
|
|
60
|
+
for (const plugin of agentPlugins) {
|
|
61
|
+
const agentId = plugin.id;
|
|
62
|
+
const agentName = plugin.name;
|
|
63
|
+
const ctx = createPluginContext(plugin.id, plugin.permissions);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const installed = await plugin.isInstalled(ctx);
|
|
67
|
+
const agentInfo: AgentInfo = {
|
|
68
|
+
agentId,
|
|
69
|
+
name: agentName,
|
|
70
|
+
installed,
|
|
71
|
+
sessionParsingSupported: plugin.capabilities.sessionParsing,
|
|
72
|
+
};
|
|
73
|
+
discovered.push(agentInfo);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const agentInfo: AgentInfo = {
|
|
76
|
+
agentId,
|
|
77
|
+
name: agentName,
|
|
78
|
+
installed: false,
|
|
79
|
+
sessionParsingSupported: false,
|
|
80
|
+
};
|
|
81
|
+
agentInfo.error = err instanceof Error ? err.message : 'Unknown error';
|
|
82
|
+
discovered.push(agentInfo);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return discovered;
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const fetchAgentSessions = useCallback(async (
|
|
90
|
+
plugin: AgentPlugin,
|
|
91
|
+
options: SessionParseOptions
|
|
92
|
+
): Promise<AgentSessionAggregate[]> => {
|
|
93
|
+
const agentId = plugin.id;
|
|
94
|
+
const agentName = plugin.name;
|
|
95
|
+
|
|
96
|
+
const http = createSandboxedHttpClient(plugin.id, plugin.permissions);
|
|
97
|
+
const logger = createPluginLogger(plugin.id);
|
|
98
|
+
const ctx = { http, logger, config: {}, signal: AbortSignal.timeout(60_000) };
|
|
99
|
+
|
|
100
|
+
const rawSessions = await plugin.parseSessions(options, ctx);
|
|
101
|
+
|
|
102
|
+
if (rawSessions.length === 0) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const aggregated = aggregateSessionUsage({
|
|
107
|
+
agentId,
|
|
108
|
+
agentName,
|
|
109
|
+
rows: rawSessions,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return aggregated;
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const refreshSessions = useCallback(async (options: SessionParseOptions = {}) => {
|
|
116
|
+
const isInitialLoad = sessions.length === 0;
|
|
117
|
+
if (isInitialLoad) {
|
|
118
|
+
setIsLoading(true);
|
|
119
|
+
}
|
|
120
|
+
setError(null);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
if (demoMode && simulator) {
|
|
124
|
+
const snapshot = simulator.tick();
|
|
125
|
+
const demoSessions = snapshot.sessions;
|
|
126
|
+
setSessions(demoSessions);
|
|
127
|
+
setLastRefreshAt(Date.now());
|
|
128
|
+
debug('Demo sessions refreshed', { count: demoSessions.length }, 'agent-sessions');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
debug('Starting session refresh...', undefined, 'agent-sessions');
|
|
133
|
+
|
|
134
|
+
const discoveredAgents = await discoverAgents();
|
|
135
|
+
setAgents(discoveredAgents);
|
|
136
|
+
|
|
137
|
+
const agentPlugins = pluginRegistry.getAll('agent');
|
|
138
|
+
const allAggregates: AgentSessionAggregate[] = [];
|
|
139
|
+
|
|
140
|
+
for (const plugin of agentPlugins) {
|
|
141
|
+
if (!plugin.capabilities.sessionParsing) {
|
|
142
|
+
debug(`Skipping ${plugin.id}: session parsing not supported`, undefined, 'agent-sessions');
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const pluginCtx = createPluginContext(plugin.id, plugin.permissions);
|
|
148
|
+
const installed = await plugin.isInstalled(pluginCtx);
|
|
149
|
+
if (!installed) {
|
|
150
|
+
debug(`Skipping ${plugin.id}: not installed`, undefined, 'agent-sessions');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const aggregates = await fetchAgentSessions(plugin, options);
|
|
155
|
+
allAggregates.push(...aggregates);
|
|
156
|
+
debug(`Fetched ${aggregates.length} sessions from ${plugin.id}`, undefined, 'agent-sessions');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
159
|
+
warn(`Failed to fetch sessions from ${plugin.id}: ${errorMsg}`, undefined, 'agent-sessions');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pricedSessions = await priceSessions(allAggregates);
|
|
164
|
+
|
|
165
|
+
pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
166
|
+
|
|
167
|
+
if (storageReady) {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
let persistedCount = 0;
|
|
170
|
+
for (const session of pricedSessions) {
|
|
171
|
+
const fp = sessionFingerprint(session);
|
|
172
|
+
const prevFp = sessionPersistenceFingerprints.get(session.sessionId);
|
|
173
|
+
if (prevFp === fp) continue;
|
|
174
|
+
|
|
175
|
+
sessionPersistenceFingerprints.set(session.sessionId, fp);
|
|
176
|
+
persistedCount++;
|
|
177
|
+
|
|
178
|
+
recordAgentSession(
|
|
179
|
+
{
|
|
180
|
+
agentId: session.agentId,
|
|
181
|
+
sessionId: session.sessionId,
|
|
182
|
+
projectPath: session.projectPath ?? null,
|
|
183
|
+
startedAt: session.startedAt ?? null,
|
|
184
|
+
lastSeenAt: now,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
timestamp: now,
|
|
188
|
+
lastActivityAt: session.lastActivityAt,
|
|
189
|
+
status: session.status,
|
|
190
|
+
totalInputTokens: session.totals.input,
|
|
191
|
+
totalOutputTokens: session.totals.output,
|
|
192
|
+
totalCacheReadTokens: session.totals.cacheRead ?? 0,
|
|
193
|
+
totalCacheWriteTokens: session.totals.cacheWrite ?? 0,
|
|
194
|
+
totalCostUsd: session.totalCostUsd ?? 0,
|
|
195
|
+
requestCount: session.requestCount,
|
|
196
|
+
},
|
|
197
|
+
session.streams.map(s => ({
|
|
198
|
+
provider: s.providerId,
|
|
199
|
+
model: s.modelId,
|
|
200
|
+
inputTokens: s.tokens.input,
|
|
201
|
+
outputTokens: s.tokens.output,
|
|
202
|
+
cacheReadTokens: s.tokens.cacheRead ?? 0,
|
|
203
|
+
cacheWriteTokens: s.tokens.cacheWrite ?? 0,
|
|
204
|
+
costUsd: s.costUsd ?? 0,
|
|
205
|
+
requestCount: s.requestCount,
|
|
206
|
+
pricingSource: (s.pricingSource as PricingSource) ?? null,
|
|
207
|
+
}))
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
debug(`Persisted ${persistedCount}/${pricedSessions.length} changed sessions to storage`, undefined, 'agent-sessions');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
setSessions(pricedSessions);
|
|
214
|
+
setLastRefreshAt(Date.now());
|
|
215
|
+
|
|
216
|
+
debug(`Session refresh complete`, { count: pricedSessions.length }, 'agent-sessions');
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
219
|
+
logError(`Session refresh failed: ${errorMsg}`, undefined, 'agent-sessions');
|
|
220
|
+
setError(errorMsg);
|
|
221
|
+
} finally {
|
|
222
|
+
if (isInitialLoad) {
|
|
223
|
+
setIsLoading(false);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}, [sessions.length, discoverAgents, fetchAgentSessions, debug, warn, logError, storageReady, recordAgentSession, demoMode]);
|
|
227
|
+
|
|
228
|
+
const refreshSessionsRef = useRef(refreshSessions);
|
|
229
|
+
refreshSessionsRef.current = refreshSessions;
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!(demoMode || pluginsInitialized)) return;
|
|
233
|
+
|
|
234
|
+
const since = windowMs !== null ? Date.now() - windowMs : undefined;
|
|
235
|
+
refreshSessions(since ? { since } : {}).then(() => {
|
|
236
|
+
if (since && !hasBackfilled.current) {
|
|
237
|
+
hasBackfilled.current = true;
|
|
238
|
+
refreshSessionsRef.current();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}, [pluginsInitialized, demoMode]);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (!autoRefresh) return;
|
|
245
|
+
|
|
246
|
+
debug(`Setting up session refresh interval: ${refreshInterval}ms`, undefined, 'agent-sessions');
|
|
247
|
+
|
|
248
|
+
const intervalId = setInterval(() => {
|
|
249
|
+
debug('Interval tick - refreshing sessions', undefined, 'agent-sessions');
|
|
250
|
+
refreshSessionsRef.current();
|
|
251
|
+
}, refreshInterval);
|
|
252
|
+
|
|
253
|
+
return () => {
|
|
254
|
+
debug('Clearing session refresh interval', undefined, 'agent-sessions');
|
|
255
|
+
clearInterval(intervalId);
|
|
256
|
+
};
|
|
257
|
+
}, [autoRefresh, refreshInterval, debug]);
|
|
258
|
+
|
|
259
|
+
const value: AgentSessionContextValue = {
|
|
260
|
+
sessions,
|
|
261
|
+
agents,
|
|
262
|
+
isLoading,
|
|
263
|
+
lastRefreshAt,
|
|
264
|
+
error,
|
|
265
|
+
refreshSessions,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<AgentSessionContext.Provider value={value}>
|
|
270
|
+
{children}
|
|
271
|
+
</AgentSessionContext.Provider>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function useAgentSessions(): AgentSessionContextValue {
|
|
276
|
+
const context = useContext(AgentSessionContext);
|
|
277
|
+
if (!context) {
|
|
278
|
+
throw new Error('useAgentSessions must be used within AgentSessionProvider');
|
|
279
|
+
}
|
|
280
|
+
return context;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function useActiveSessions(): AgentSessionAggregate[] {
|
|
284
|
+
const { sessions } = useAgentSessions();
|
|
285
|
+
return sessions.filter(s => s.status === 'active');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function useSessionsByAgent(agentId: AgentId): AgentSessionAggregate[] {
|
|
289
|
+
const { sessions } = useAgentSessions();
|
|
290
|
+
return sessions.filter(s => s.agentId === agentId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function useTotalCost(): number {
|
|
294
|
+
const { sessions } = useAgentSessions();
|
|
295
|
+
return sessions.reduce((sum, s) => sum + (s.totalCostUsd ?? 0), 0);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function useTotalTokens(): { input: number; output: number } {
|
|
299
|
+
const { sessions } = useAgentSessions();
|
|
300
|
+
return sessions.reduce(
|
|
301
|
+
(acc, s) => ({
|
|
302
|
+
input: acc.input + s.totals.input,
|
|
303
|
+
output: acc.output + s.totals.output,
|
|
304
|
+
}),
|
|
305
|
+
{ input: 0, output: 0 }
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
|
2
|
+
import { loadConfig, saveConfig, DEFAULT_CONFIG, type AppConfig } from '@/config/schema.ts';
|
|
3
|
+
import { useDemoMode } from './DemoModeContext.tsx';
|
|
4
|
+
|
|
5
|
+
interface ConfigContextValue {
|
|
6
|
+
config: AppConfig;
|
|
7
|
+
isLoading: boolean;
|
|
8
|
+
updateConfig: (updates: Partial<AppConfig>) => void;
|
|
9
|
+
updateRefresh: (updates: Partial<AppConfig['refresh']>) => void;
|
|
10
|
+
updateDisplay: (updates: Partial<AppConfig['display']>) => void;
|
|
11
|
+
updateNotifications: (updates: Partial<AppConfig['notifications']>) => void;
|
|
12
|
+
resetToDefaults: () => void;
|
|
13
|
+
saveNow: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ConfigContext = createContext<ConfigContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
interface ConfigProviderProps {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
/** For testing: skip async loading and use this config directly */
|
|
21
|
+
initialConfig?: AppConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ConfigProvider({ children, initialConfig }: ConfigProviderProps) {
|
|
25
|
+
const { demoMode } = useDemoMode();
|
|
26
|
+
const demoConfig: AppConfig = {
|
|
27
|
+
...DEFAULT_CONFIG,
|
|
28
|
+
refresh: {
|
|
29
|
+
...DEFAULT_CONFIG.refresh,
|
|
30
|
+
intervalMs: 5000,
|
|
31
|
+
pauseAutoRefresh: false,
|
|
32
|
+
},
|
|
33
|
+
display: {
|
|
34
|
+
...DEFAULT_CONFIG.display,
|
|
35
|
+
defaultTimeWindow: '1h',
|
|
36
|
+
sidebarCollapsed: false,
|
|
37
|
+
},
|
|
38
|
+
budgets: {
|
|
39
|
+
daily: 50,
|
|
40
|
+
weekly: 200,
|
|
41
|
+
monthly: 800,
|
|
42
|
+
currency: 'USD',
|
|
43
|
+
},
|
|
44
|
+
alerts: {
|
|
45
|
+
...DEFAULT_CONFIG.alerts,
|
|
46
|
+
criticalPercent: 90,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const [config, setConfig] = useState<AppConfig>(initialConfig ?? (demoMode ? demoConfig : DEFAULT_CONFIG));
|
|
50
|
+
const [isLoading, setIsLoading] = useState(!initialConfig);
|
|
51
|
+
const [pendingSave, setPendingSave] = useState(false);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (initialConfig) return; // Skip loading if initial config provided
|
|
55
|
+
if (demoMode) {
|
|
56
|
+
setConfig(demoConfig);
|
|
57
|
+
setIsLoading(false);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
loadConfig().then((loaded) => {
|
|
61
|
+
setConfig(loaded);
|
|
62
|
+
setIsLoading(false);
|
|
63
|
+
});
|
|
64
|
+
}, [initialConfig, demoMode]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (demoMode || isLoading || !pendingSave) return;
|
|
68
|
+
|
|
69
|
+
const timeout = setTimeout(() => {
|
|
70
|
+
saveConfig(config).catch(() => {});
|
|
71
|
+
setPendingSave(false);
|
|
72
|
+
}, 500);
|
|
73
|
+
|
|
74
|
+
return () => clearTimeout(timeout);
|
|
75
|
+
}, [config, isLoading, pendingSave]);
|
|
76
|
+
|
|
77
|
+
const updateConfig = useCallback((updates: Partial<AppConfig>) => {
|
|
78
|
+
setConfig((prev) => ({ ...prev, ...updates }));
|
|
79
|
+
setPendingSave(true);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const updateRefresh = useCallback((updates: Partial<AppConfig['refresh']>) => {
|
|
83
|
+
setConfig((prev) => ({
|
|
84
|
+
...prev,
|
|
85
|
+
refresh: { ...prev.refresh, ...updates },
|
|
86
|
+
}));
|
|
87
|
+
setPendingSave(true);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const updateDisplay = useCallback((updates: Partial<AppConfig['display']>) => {
|
|
91
|
+
setConfig((prev) => ({
|
|
92
|
+
...prev,
|
|
93
|
+
display: { ...prev.display, ...updates },
|
|
94
|
+
}));
|
|
95
|
+
setPendingSave(true);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const updateNotifications = useCallback((updates: Partial<AppConfig['notifications']>) => {
|
|
99
|
+
setConfig((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
notifications: { ...prev.notifications, ...updates },
|
|
102
|
+
}));
|
|
103
|
+
setPendingSave(true);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const resetToDefaults = useCallback(() => {
|
|
107
|
+
setConfig(DEFAULT_CONFIG);
|
|
108
|
+
setPendingSave(true);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const saveNow = useCallback(async () => {
|
|
112
|
+
if (demoMode) return;
|
|
113
|
+
await saveConfig(config);
|
|
114
|
+
setPendingSave(false);
|
|
115
|
+
}, [config, demoMode]);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<ConfigContext.Provider
|
|
119
|
+
value={{
|
|
120
|
+
config,
|
|
121
|
+
isLoading,
|
|
122
|
+
updateConfig,
|
|
123
|
+
updateRefresh,
|
|
124
|
+
updateDisplay,
|
|
125
|
+
updateNotifications,
|
|
126
|
+
resetToDefaults,
|
|
127
|
+
saveNow,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
</ConfigContext.Provider>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function useConfig(): ConfigContextValue {
|
|
136
|
+
const context = useContext(ConfigContext);
|
|
137
|
+
if (!context) {
|
|
138
|
+
throw new Error('useConfig must be used within a ConfigProvider');
|
|
139
|
+
}
|
|
140
|
+
return context;
|
|
141
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect, useRef, type ReactNode } from 'react';
|
|
2
|
+
import { useAgentSessions } from './AgentSessionContext.tsx';
|
|
3
|
+
import { useTimeWindow } from './TimeWindowContext.tsx';
|
|
4
|
+
import { useRealTimeActivity } from './RealTimeActivityContext.tsx';
|
|
5
|
+
import { useEmaActivity, type ActivityState, type ActivityDebugData } from '../hooks/useEmaActivity.ts';
|
|
6
|
+
|
|
7
|
+
interface Deltas {
|
|
8
|
+
cost: number;
|
|
9
|
+
tokens: number;
|
|
10
|
+
windowSec: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DashboardRuntimeContextValue {
|
|
14
|
+
activity: ActivityState;
|
|
15
|
+
sparkData: number[];
|
|
16
|
+
|
|
17
|
+
deltas: Deltas;
|
|
18
|
+
history: Array<{time: number, cost: number, tokens: number}>;
|
|
19
|
+
|
|
20
|
+
debugDataRef: React.MutableRefObject<ActivityDebugData>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DashboardRuntimeContext = createContext<DashboardRuntimeContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
export function DashboardRuntimeProvider({ children }: { children: ReactNode }) {
|
|
26
|
+
const { sessions: agentSessions, isLoading } = useAgentSessions();
|
|
27
|
+
const { windowMs } = useTimeWindow();
|
|
28
|
+
const { subscribe } = useRealTimeActivity();
|
|
29
|
+
|
|
30
|
+
const totalTokens = agentSessions.reduce((sum, s) => sum + s.totals.input + s.totals.output, 0);
|
|
31
|
+
const { activity, sparkData, debugDataRef, injectDelta } = useEmaActivity(totalTokens);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
return subscribe((delta, _timestamp) => {
|
|
35
|
+
injectDelta(delta);
|
|
36
|
+
});
|
|
37
|
+
}, [subscribe, injectDelta]);
|
|
38
|
+
|
|
39
|
+
const historyRef = useRef<{time: number, cost: number, tokens: number}[]>([]);
|
|
40
|
+
const [deltas, setDeltas] = useState<Deltas>({ cost: 0, tokens: 0, windowSec: 0 });
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (isLoading || agentSessions.length === 0) return;
|
|
44
|
+
|
|
45
|
+
const totalCost = agentSessions.reduce((sum, s) => sum + (s.totalCostUsd ?? 0), 0);
|
|
46
|
+
const currentTime = Date.now();
|
|
47
|
+
|
|
48
|
+
const lastEntry = historyRef.current[historyRef.current.length - 1];
|
|
49
|
+
const isDiscontinuity = lastEntry && (
|
|
50
|
+
totalTokens < lastEntry.tokens ||
|
|
51
|
+
totalTokens > lastEntry.tokens * 2
|
|
52
|
+
);
|
|
53
|
+
if (isDiscontinuity) {
|
|
54
|
+
historyRef.current = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
historyRef.current.push({ time: currentTime, cost: totalCost, tokens: totalTokens });
|
|
58
|
+
|
|
59
|
+
if (historyRef.current.length > 300) historyRef.current.shift();
|
|
60
|
+
|
|
61
|
+
const targetTime = windowMs !== null ? currentTime - windowMs : 0;
|
|
62
|
+
let baseline = historyRef.current[0];
|
|
63
|
+
|
|
64
|
+
for (let i = historyRef.current.length - 1; i >= 0; i--) {
|
|
65
|
+
if (historyRef.current[i]!.time <= targetTime) {
|
|
66
|
+
baseline = historyRef.current[i];
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (baseline) {
|
|
72
|
+
const windowSec = (currentTime - baseline.time) / 1000;
|
|
73
|
+
setDeltas({
|
|
74
|
+
cost: totalCost - baseline.cost,
|
|
75
|
+
tokens: totalTokens - baseline.tokens,
|
|
76
|
+
windowSec,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}, [agentSessions, isLoading, windowMs, totalTokens]);
|
|
80
|
+
|
|
81
|
+
const value: DashboardRuntimeContextValue = {
|
|
82
|
+
activity,
|
|
83
|
+
sparkData,
|
|
84
|
+
deltas,
|
|
85
|
+
history: historyRef.current,
|
|
86
|
+
debugDataRef
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<DashboardRuntimeContext.Provider value={value}>
|
|
91
|
+
{children}
|
|
92
|
+
</DashboardRuntimeContext.Provider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useDashboardRuntime() {
|
|
97
|
+
const context = useContext(DashboardRuntimeContext);
|
|
98
|
+
if (!context) {
|
|
99
|
+
throw new Error('useDashboardRuntime must be used within DashboardRuntimeProvider');
|
|
100
|
+
}
|
|
101
|
+
return context;
|
|
102
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { DemoSimulator, type DemoPreset, type DemoSimulatorOptions } from '@/demo/simulator.ts';
|
|
3
|
+
|
|
4
|
+
interface DemoModeContextValue {
|
|
5
|
+
demoMode: boolean;
|
|
6
|
+
simulator: DemoSimulator | null;
|
|
7
|
+
seed: number | null;
|
|
8
|
+
preset: DemoPreset | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DemoModeContext = createContext<DemoModeContextValue>({
|
|
12
|
+
demoMode: false,
|
|
13
|
+
simulator: null,
|
|
14
|
+
seed: null,
|
|
15
|
+
preset: null,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
interface DemoModeProviderProps {
|
|
19
|
+
demoMode: boolean;
|
|
20
|
+
demoSeed?: number;
|
|
21
|
+
demoPreset?: DemoPreset;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildSimulatorOptions(seed: number | undefined, preset: DemoPreset | undefined): DemoSimulatorOptions {
|
|
26
|
+
if (seed !== undefined && preset !== undefined) {
|
|
27
|
+
return { seed, preset };
|
|
28
|
+
}
|
|
29
|
+
if (seed !== undefined) {
|
|
30
|
+
return { seed };
|
|
31
|
+
}
|
|
32
|
+
if (preset !== undefined) {
|
|
33
|
+
return { preset };
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function DemoModeProvider({ demoMode, demoSeed, demoPreset, children }: DemoModeProviderProps) {
|
|
39
|
+
const simulator = useMemo(() => {
|
|
40
|
+
if (!demoMode) return null;
|
|
41
|
+
return new DemoSimulator(buildSimulatorOptions(demoSeed, demoPreset));
|
|
42
|
+
}, [demoMode, demoSeed, demoPreset]);
|
|
43
|
+
|
|
44
|
+
const value = useMemo<DemoModeContextValue>(() => ({
|
|
45
|
+
demoMode,
|
|
46
|
+
simulator,
|
|
47
|
+
seed: simulator?.getSeed() ?? null,
|
|
48
|
+
preset: simulator?.getPreset() ?? null,
|
|
49
|
+
}), [demoMode, simulator]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<DemoModeContext.Provider value={value}>
|
|
53
|
+
{children}
|
|
54
|
+
</DemoModeContext.Provider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useDemoMode(): DemoModeContextValue {
|
|
59
|
+
return useContext(DemoModeContext);
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
|
2
|
+
import type { AgentSessionAggregate } from '@/agents/types.ts';
|
|
3
|
+
|
|
4
|
+
interface DrawerContextValue {
|
|
5
|
+
selectedSession: AgentSessionAggregate | null;
|
|
6
|
+
showDrawer: (session: AgentSessionAggregate) => void;
|
|
7
|
+
hideDrawer: () => void;
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
export function DrawerProvider({ children }: { children: ReactNode }) {
|
|
14
|
+
const [selectedSession, setSelectedSession] = useState<AgentSessionAggregate | null>(null);
|
|
15
|
+
|
|
16
|
+
const showDrawer = useCallback((session: AgentSessionAggregate) => {
|
|
17
|
+
setSelectedSession(session);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
const hideDrawer = useCallback(() => {
|
|
21
|
+
setSelectedSession(null);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<DrawerContext.Provider value={{
|
|
26
|
+
selectedSession,
|
|
27
|
+
showDrawer,
|
|
28
|
+
hideDrawer,
|
|
29
|
+
isOpen: selectedSession !== null,
|
|
30
|
+
}}>
|
|
31
|
+
{children}
|
|
32
|
+
</DrawerContext.Provider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useDrawer(): DrawerContextValue {
|
|
37
|
+
const context = useContext(DrawerContext);
|
|
38
|
+
if (!context) {
|
|
39
|
+
throw new Error('useDrawer must be used within DrawerProvider');
|
|
40
|
+
}
|
|
41
|
+
return context;
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface InputContextType {
|
|
4
|
+
isInputFocused: boolean;
|
|
5
|
+
setInputFocused: (focused: boolean) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const InputContext = createContext<InputContextType | undefined>(undefined);
|
|
9
|
+
|
|
10
|
+
export function InputProvider({ children }: { children: ReactNode }) {
|
|
11
|
+
const [isInputFocused, setIsInputFocused] = useState(false);
|
|
12
|
+
|
|
13
|
+
const setInputFocused = useCallback((focused: boolean) => {
|
|
14
|
+
setIsInputFocused(focused);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<InputContext.Provider value={{ isInputFocused, setInputFocused }}>
|
|
19
|
+
{children}
|
|
20
|
+
</InputContext.Provider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useInputFocus() {
|
|
25
|
+
const context = useContext(InputContext);
|
|
26
|
+
if (context === undefined) {
|
|
27
|
+
throw new Error('useInputFocus must be used within an InputProvider');
|
|
28
|
+
}
|
|
29
|
+
return context;
|
|
30
|
+
}
|