@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,233 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { initDatabase, closeDatabase, isDatabaseInitialized, getAppRunId } from '@/storage/db.ts';
|
|
3
|
+
import { insertProviderSnapshotBatch } from '@/storage/repos/providerSnapshots.ts';
|
|
4
|
+
import { insertUsageEventBatch } from '@/storage/repos/usageEvents.ts';
|
|
5
|
+
import { upsertAgentSession, insertAgentSessionSnapshot, getLatestStreamTotalsForAllSessions } from '@/storage/repos/agentSessions.ts';
|
|
6
|
+
import type {
|
|
7
|
+
ProviderSnapshotInsert,
|
|
8
|
+
UsageEventInsert,
|
|
9
|
+
AgentSessionUpsert,
|
|
10
|
+
AgentSessionSnapshotInsert,
|
|
11
|
+
AgentSessionStreamSnapshotRow,
|
|
12
|
+
StreamTotals,
|
|
13
|
+
} from '@/storage/types.ts';
|
|
14
|
+
import { computeStreamDelta } from '@/storage/types.ts';
|
|
15
|
+
import { useDemoMode } from './DemoModeContext.tsx';
|
|
16
|
+
|
|
17
|
+
interface StorageContextValue {
|
|
18
|
+
isReady: boolean;
|
|
19
|
+
appRunId: number | null;
|
|
20
|
+
recordProviderSnapshots: (snapshots: ProviderSnapshotInsert[]) => void;
|
|
21
|
+
recordUsageEvents: (events: UsageEventInsert[]) => void;
|
|
22
|
+
recordAgentSession: (
|
|
23
|
+
session: AgentSessionUpsert,
|
|
24
|
+
snapshot: Omit<AgentSessionSnapshotInsert, 'agentSessionId'>,
|
|
25
|
+
streams: Omit<AgentSessionStreamSnapshotRow, 'agentSessionSnapshotId'>[]
|
|
26
|
+
) => number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const StorageContext = createContext<StorageContextValue | null>(null);
|
|
30
|
+
|
|
31
|
+
const SNAPSHOT_INTERVAL_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
interface StorageProviderProps {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function StorageProvider({ children }: StorageProviderProps) {
|
|
38
|
+
const [isReady, setIsReady] = useState(false);
|
|
39
|
+
const [appRunId, setAppRunId] = useState<number | null>(null);
|
|
40
|
+
const lastProviderSnapshotRef = useRef<Map<string, number>>(new Map());
|
|
41
|
+
const lastSessionSnapshotRef = useRef<Map<string, number>>(new Map());
|
|
42
|
+
const previousTotalsRef = useRef<Map<string, StreamTotals>>(new Map());
|
|
43
|
+
const { demoMode, simulator } = useDemoMode();
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
// Skip real database in demo mode - all data stays in memory
|
|
47
|
+
if (demoMode) {
|
|
48
|
+
setIsReady(true);
|
|
49
|
+
setAppRunId(null);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let mounted = true;
|
|
54
|
+
|
|
55
|
+
async function init() {
|
|
56
|
+
try {
|
|
57
|
+
await initDatabase();
|
|
58
|
+
if (mounted) {
|
|
59
|
+
try {
|
|
60
|
+
const latestTotals = getLatestStreamTotalsForAllSessions();
|
|
61
|
+
for (const row of latestTotals) {
|
|
62
|
+
const streamKey = `${row.agentId}:${row.sessionId}:${row.provider}:${row.model}`;
|
|
63
|
+
previousTotalsRef.current.set(streamKey, {
|
|
64
|
+
inputTokens: row.inputTokens,
|
|
65
|
+
outputTokens: row.outputTokens,
|
|
66
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
67
|
+
cacheWriteTokens: row.cacheWriteTokens,
|
|
68
|
+
costUsd: row.costUsd,
|
|
69
|
+
requestCount: row.requestCount,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('Failed to seed previous totals from DB:', err);
|
|
74
|
+
}
|
|
75
|
+
setIsReady(true);
|
|
76
|
+
setAppRunId(getAppRunId());
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error('Failed to initialize database:', err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
init();
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
mounted = false;
|
|
87
|
+
if (isDatabaseInitialized()) {
|
|
88
|
+
closeDatabase();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}, [demoMode]);
|
|
92
|
+
|
|
93
|
+
const recordProviderSnapshots = useCallback((snapshots: ProviderSnapshotInsert[]) => {
|
|
94
|
+
if (!isReady || demoMode || snapshots.length === 0) return;
|
|
95
|
+
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const filtered = snapshots.filter(s => {
|
|
98
|
+
const lastTs = lastProviderSnapshotRef.current.get(s.provider) ?? 0;
|
|
99
|
+
return now - lastTs >= SNAPSHOT_INTERVAL_MS;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (filtered.length === 0) return;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
insertProviderSnapshotBatch(filtered);
|
|
106
|
+
for (const s of filtered) {
|
|
107
|
+
lastProviderSnapshotRef.current.set(s.provider, now);
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error('Failed to record provider snapshots:', err);
|
|
111
|
+
}
|
|
112
|
+
}, [isReady, demoMode]);
|
|
113
|
+
|
|
114
|
+
const recordUsageEvents = useCallback((events: UsageEventInsert[]) => {
|
|
115
|
+
if (!isReady || demoMode || events.length === 0) return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
insertUsageEventBatch(events);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('Failed to record usage events:', err);
|
|
121
|
+
}
|
|
122
|
+
}, [isReady, demoMode]);
|
|
123
|
+
|
|
124
|
+
const recordAgentSession = useCallback((
|
|
125
|
+
session: AgentSessionUpsert,
|
|
126
|
+
snapshot: Omit<AgentSessionSnapshotInsert, 'agentSessionId'>,
|
|
127
|
+
streams: Omit<AgentSessionStreamSnapshotRow, 'agentSessionSnapshotId'>[]
|
|
128
|
+
): number | null => {
|
|
129
|
+
if (!isReady || demoMode) return null;
|
|
130
|
+
|
|
131
|
+
const sessionKey = `${session.agentId}:${session.sessionId}`;
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const lastTs = lastSessionSnapshotRef.current.get(sessionKey) ?? 0;
|
|
134
|
+
|
|
135
|
+
if (now - lastTs < SNAPSHOT_INTERVAL_MS) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const agentSessionId = upsertAgentSession(session);
|
|
141
|
+
|
|
142
|
+
const snapshotId = insertAgentSessionSnapshot(
|
|
143
|
+
{ ...snapshot, agentSessionId },
|
|
144
|
+
streams
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
lastSessionSnapshotRef.current.set(sessionKey, now);
|
|
148
|
+
|
|
149
|
+
const usageEvents: UsageEventInsert[] = [];
|
|
150
|
+
for (const stream of streams) {
|
|
151
|
+
const streamKey = `${sessionKey}:${stream.provider}:${stream.model}`;
|
|
152
|
+
const current: StreamTotals = {
|
|
153
|
+
inputTokens: stream.inputTokens,
|
|
154
|
+
outputTokens: stream.outputTokens,
|
|
155
|
+
cacheReadTokens: stream.cacheReadTokens,
|
|
156
|
+
cacheWriteTokens: stream.cacheWriteTokens,
|
|
157
|
+
costUsd: stream.costUsd,
|
|
158
|
+
requestCount: stream.requestCount,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const previous = previousTotalsRef.current.get(streamKey);
|
|
162
|
+
const delta = computeStreamDelta(current, previous);
|
|
163
|
+
|
|
164
|
+
if (delta) {
|
|
165
|
+
usageEvents.push({
|
|
166
|
+
timestamp: now,
|
|
167
|
+
source: 'agent',
|
|
168
|
+
provider: stream.provider,
|
|
169
|
+
model: stream.model,
|
|
170
|
+
agentId: session.agentId,
|
|
171
|
+
sessionId: session.sessionId,
|
|
172
|
+
projectPath: session.projectPath ?? null,
|
|
173
|
+
inputTokens: delta.inputTokens,
|
|
174
|
+
outputTokens: delta.outputTokens,
|
|
175
|
+
cacheReadTokens: delta.cacheReadTokens,
|
|
176
|
+
cacheWriteTokens: delta.cacheWriteTokens,
|
|
177
|
+
costUsd: delta.costUsd,
|
|
178
|
+
requestCount: delta.requestCount,
|
|
179
|
+
pricingSource: stream.pricingSource ?? null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
previousTotalsRef.current.set(streamKey, current);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (usageEvents.length > 0) {
|
|
187
|
+
insertUsageEventBatch(usageEvents);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return snapshotId;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('Failed to record agent session:', err);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}, [isReady, demoMode]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!demoMode || !isReady || !simulator) return;
|
|
199
|
+
|
|
200
|
+
const interval = setInterval(() => {
|
|
201
|
+
simulator.tick();
|
|
202
|
+
}, 1000);
|
|
203
|
+
|
|
204
|
+
return () => clearInterval(interval);
|
|
205
|
+
}, [demoMode, isReady, simulator]);
|
|
206
|
+
|
|
207
|
+
const value: StorageContextValue = useMemo(() => ({
|
|
208
|
+
isReady,
|
|
209
|
+
appRunId,
|
|
210
|
+
recordProviderSnapshots,
|
|
211
|
+
recordUsageEvents,
|
|
212
|
+
recordAgentSession,
|
|
213
|
+
}), [isReady, appRunId, recordProviderSnapshots, recordUsageEvents, recordAgentSession]);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<StorageContext.Provider value={value}>
|
|
217
|
+
{children}
|
|
218
|
+
</StorageContext.Provider>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function useStorage(): StorageContextValue {
|
|
223
|
+
const context = useContext(StorageContext);
|
|
224
|
+
if (!context) {
|
|
225
|
+
throw new Error('useStorage must be used within StorageProvider');
|
|
226
|
+
}
|
|
227
|
+
return context;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function useStorageReady(): boolean {
|
|
231
|
+
const { isReady } = useStorage();
|
|
232
|
+
return isReady;
|
|
233
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
2
|
+
import type { ThemePlugin, ThemeColors, ThemeComponents, ColorScheme, ColorSchemePreference } from '@/plugins/types/theme.ts';
|
|
3
|
+
import { tokyoNightTheme } from '@/plugins/themes/tokyo-night.ts';
|
|
4
|
+
|
|
5
|
+
interface ThemeContextValue {
|
|
6
|
+
theme: ThemePlugin;
|
|
7
|
+
colors: ThemeColors;
|
|
8
|
+
components: ThemeComponents;
|
|
9
|
+
colorScheme: ColorScheme;
|
|
10
|
+
setTheme: (theme: ThemePlugin) => void;
|
|
11
|
+
previewTheme: ThemePlugin | null;
|
|
12
|
+
setPreviewTheme: (theme: ThemePlugin | null) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultComponents: ThemeComponents = {
|
|
16
|
+
header: {},
|
|
17
|
+
statusBar: {},
|
|
18
|
+
gauge: { height: 1, borderRadius: 0 },
|
|
19
|
+
commandPalette: {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
23
|
+
|
|
24
|
+
interface ThemeProviderProps {
|
|
25
|
+
initialTheme?: ThemePlugin;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
|
|
30
|
+
const [theme, setTheme] = useState<ThemePlugin>(initialTheme ?? tokyoNightTheme);
|
|
31
|
+
const [previewTheme, setPreviewTheme] = useState<ThemePlugin | null>(null);
|
|
32
|
+
|
|
33
|
+
const active = previewTheme ?? theme;
|
|
34
|
+
|
|
35
|
+
const value: ThemeContextValue = {
|
|
36
|
+
theme,
|
|
37
|
+
colors: active.colors,
|
|
38
|
+
components: { ...defaultComponents, ...active.components },
|
|
39
|
+
colorScheme: active.colorScheme,
|
|
40
|
+
setTheme,
|
|
41
|
+
previewTheme,
|
|
42
|
+
setPreviewTheme,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<ThemeContext.Provider value={value}>
|
|
47
|
+
{children}
|
|
48
|
+
</ThemeContext.Provider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useTheme(): ThemeContextValue {
|
|
53
|
+
const context = useContext(ThemeContext);
|
|
54
|
+
if (!context) {
|
|
55
|
+
throw new Error('useTheme must be used within ThemeProvider');
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useColors(): ThemeColors {
|
|
61
|
+
return useTheme().colors;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveTheme(
|
|
65
|
+
themeId: string,
|
|
66
|
+
schemePref: ColorSchemePreference,
|
|
67
|
+
themes: ThemePlugin[],
|
|
68
|
+
detectedMode: ColorScheme | null,
|
|
69
|
+
): ThemePlugin {
|
|
70
|
+
if (themes.length === 0) return tokyoNightTheme;
|
|
71
|
+
|
|
72
|
+
const targetScheme: ColorScheme | null =
|
|
73
|
+
schemePref === 'auto' ? detectedMode :
|
|
74
|
+
schemePref === 'light' ? 'light' :
|
|
75
|
+
schemePref === 'dark' ? 'dark' :
|
|
76
|
+
null;
|
|
77
|
+
|
|
78
|
+
const stored = themes.find(t => t.id === themeId);
|
|
79
|
+
|
|
80
|
+
if (!stored) {
|
|
81
|
+
if (targetScheme) {
|
|
82
|
+
return themes.find(t => t.colorScheme === targetScheme) ?? themes[0]!;
|
|
83
|
+
}
|
|
84
|
+
return themes[0]!;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!targetScheme || stored.colorScheme === targetScheme) {
|
|
88
|
+
return stored;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const familySibling = themes.find(
|
|
92
|
+
t => t.family === stored.family && t.colorScheme === targetScheme && t.id !== stored.id,
|
|
93
|
+
);
|
|
94
|
+
return familySibling ?? stored;
|
|
95
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type TimeWindow = '5m' | '15m' | '1h' | '24h' | '7d' | '30d' | 'all';
|
|
4
|
+
|
|
5
|
+
export const TIME_WINDOW_OPTIONS: TimeWindow[] = ['5m', '15m', '1h', '24h', '7d', '30d', 'all'];
|
|
6
|
+
|
|
7
|
+
export const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
|
|
8
|
+
'5m': '5 min',
|
|
9
|
+
'15m': '15 min',
|
|
10
|
+
'1h': '1 hour',
|
|
11
|
+
'24h': '24 hours',
|
|
12
|
+
'7d': '7 days',
|
|
13
|
+
'30d': '30 days',
|
|
14
|
+
'all': 'All time',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const TIME_WINDOW_MS: Record<TimeWindow, number | null> = {
|
|
18
|
+
'5m': 5 * 60 * 1000,
|
|
19
|
+
'15m': 15 * 60 * 1000,
|
|
20
|
+
'1h': 60 * 60 * 1000,
|
|
21
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
22
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
23
|
+
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
24
|
+
'all': null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type BudgetType = 'daily' | 'weekly' | 'monthly' | 'none';
|
|
28
|
+
|
|
29
|
+
export const TIME_WINDOW_BUDGET_TYPE: Record<TimeWindow, BudgetType> = {
|
|
30
|
+
'5m': 'daily',
|
|
31
|
+
'15m': 'daily',
|
|
32
|
+
'1h': 'daily',
|
|
33
|
+
'24h': 'daily',
|
|
34
|
+
'7d': 'weekly',
|
|
35
|
+
'30d': 'monthly',
|
|
36
|
+
'all': 'none',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const BUDGET_TYPE_LABELS: Record<BudgetType, string> = {
|
|
40
|
+
daily: 'Daily',
|
|
41
|
+
weekly: 'Weekly',
|
|
42
|
+
monthly: 'Monthly',
|
|
43
|
+
none: 'Total',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type BudgetLock = BudgetType | 'sync';
|
|
47
|
+
|
|
48
|
+
const BUDGET_LOCK_CYCLE: BudgetLock[] = ['sync', 'daily', 'weekly', 'monthly'];
|
|
49
|
+
|
|
50
|
+
export const BUDGET_LOCK_LABELS: Record<BudgetLock, string> = {
|
|
51
|
+
sync: 'Sync',
|
|
52
|
+
daily: 'Daily',
|
|
53
|
+
weekly: 'Weekly',
|
|
54
|
+
monthly: 'Monthly',
|
|
55
|
+
none: 'None',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface TimeWindowContextValue {
|
|
59
|
+
window: TimeWindow;
|
|
60
|
+
setWindow: (w: TimeWindow) => void;
|
|
61
|
+
cycleWindow: () => void;
|
|
62
|
+
cycleWindowBack: () => void;
|
|
63
|
+
windowMs: number | null;
|
|
64
|
+
windowLabel: string;
|
|
65
|
+
getWindowStart: () => number | null;
|
|
66
|
+
budgetType: BudgetType;
|
|
67
|
+
budgetTypeLabel: string;
|
|
68
|
+
budgetLock: BudgetLock;
|
|
69
|
+
setBudgetLock: (lock: BudgetLock) => void;
|
|
70
|
+
cycleBudgetLock: () => void;
|
|
71
|
+
budgetLockLabel: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TimeWindowContext = createContext<TimeWindowContextValue | null>(null);
|
|
75
|
+
|
|
76
|
+
interface TimeWindowProviderProps {
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
defaultWindow?: TimeWindow;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getNextWindow(current: TimeWindow): TimeWindow {
|
|
82
|
+
const idx = TIME_WINDOW_OPTIONS.indexOf(current);
|
|
83
|
+
const nextIdx = (idx + 1) % TIME_WINDOW_OPTIONS.length;
|
|
84
|
+
return TIME_WINDOW_OPTIONS[nextIdx] as TimeWindow;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getPrevWindow(current: TimeWindow): TimeWindow {
|
|
88
|
+
const idx = TIME_WINDOW_OPTIONS.indexOf(current);
|
|
89
|
+
const prevIdx = (idx - 1 + TIME_WINDOW_OPTIONS.length) % TIME_WINDOW_OPTIONS.length;
|
|
90
|
+
return TIME_WINDOW_OPTIONS[prevIdx] as TimeWindow;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function TimeWindowProvider({ children, defaultWindow = '24h' }: TimeWindowProviderProps) {
|
|
94
|
+
const [window, setWindowState] = useState<TimeWindow>(defaultWindow);
|
|
95
|
+
const [budgetLock, setBudgetLock] = useState<BudgetLock>('sync');
|
|
96
|
+
|
|
97
|
+
const setWindow = useCallback((w: TimeWindow) => {
|
|
98
|
+
setWindowState(w);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const cycleWindow = useCallback(() => {
|
|
102
|
+
setWindowState(getNextWindow);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const cycleWindowBack = useCallback(() => {
|
|
106
|
+
setWindowState(getPrevWindow);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const cycleBudgetLock = useCallback(() => {
|
|
110
|
+
setBudgetLock(current => {
|
|
111
|
+
const idx = BUDGET_LOCK_CYCLE.indexOf(current);
|
|
112
|
+
const nextIdx = (idx + 1) % BUDGET_LOCK_CYCLE.length;
|
|
113
|
+
return BUDGET_LOCK_CYCLE[nextIdx] as BudgetLock;
|
|
114
|
+
});
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const windowMs = TIME_WINDOW_MS[window];
|
|
118
|
+
const windowLabel = TIME_WINDOW_LABELS[window];
|
|
119
|
+
const budgetType = budgetLock === 'sync' ? TIME_WINDOW_BUDGET_TYPE[window] : budgetLock;
|
|
120
|
+
const budgetTypeLabel = BUDGET_TYPE_LABELS[budgetType];
|
|
121
|
+
const budgetLockLabel = BUDGET_LOCK_LABELS[budgetLock];
|
|
122
|
+
|
|
123
|
+
const getWindowStart = useCallback((): number | null => {
|
|
124
|
+
const ms = TIME_WINDOW_MS[window];
|
|
125
|
+
if (ms === null) return null;
|
|
126
|
+
return Date.now() - ms;
|
|
127
|
+
}, [window]);
|
|
128
|
+
|
|
129
|
+
const value: TimeWindowContextValue = useMemo(() => ({
|
|
130
|
+
window,
|
|
131
|
+
setWindow,
|
|
132
|
+
cycleWindow,
|
|
133
|
+
cycleWindowBack,
|
|
134
|
+
windowMs,
|
|
135
|
+
windowLabel,
|
|
136
|
+
getWindowStart,
|
|
137
|
+
budgetType,
|
|
138
|
+
budgetTypeLabel,
|
|
139
|
+
budgetLock,
|
|
140
|
+
setBudgetLock,
|
|
141
|
+
cycleBudgetLock,
|
|
142
|
+
budgetLockLabel,
|
|
143
|
+
}), [window, setWindow, cycleWindow, windowMs, windowLabel, getWindowStart, budgetType, budgetTypeLabel, budgetLock, cycleBudgetLock, budgetLockLabel]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<TimeWindowContext.Provider value={value}>
|
|
147
|
+
{children}
|
|
148
|
+
</TimeWindowContext.Provider>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function useTimeWindow(): TimeWindowContextValue {
|
|
153
|
+
const context = useContext(TimeWindowContext);
|
|
154
|
+
if (!context) {
|
|
155
|
+
throw new Error('useTimeWindow must be used within TimeWindowProvider');
|
|
156
|
+
}
|
|
157
|
+
return context;
|
|
158
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ToastState {
|
|
4
|
+
message: string;
|
|
5
|
+
type: 'info' | 'success' | 'warning' | 'error';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ToastContextValue {
|
|
9
|
+
toast: ToastState | null;
|
|
10
|
+
showToast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
|
|
11
|
+
dismissToast: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
17
|
+
const [toast, setToast] = useState<ToastState | null>(null);
|
|
18
|
+
|
|
19
|
+
const showToast = useCallback((message: string, type: 'info' | 'success' | 'warning' | 'error' = 'success') => {
|
|
20
|
+
setToast({ message, type });
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const dismissToast = useCallback(() => {
|
|
24
|
+
setToast(null);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<ToastContext.Provider value={{ toast, showToast, dismissToast }}>
|
|
29
|
+
{children}
|
|
30
|
+
</ToastContext.Provider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useToastContext() {
|
|
35
|
+
const context = useContext(ToastContext);
|
|
36
|
+
if (!context) {
|
|
37
|
+
throw new Error('useToastContext must be used within a ToastProvider');
|
|
38
|
+
}
|
|
39
|
+
return context;
|
|
40
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ThemeProvider, useTheme, useColors } from './ThemeContext.tsx';
|
|
2
|
+
export { PluginProvider, usePlugins, useProvider, useConfiguredProviders } from './PluginContext.tsx';
|
|
3
|
+
export { LogProvider, useLogs, useLogger, type LogLevel, type LogEntry } from './LogContext.tsx';
|
|
4
|
+
export { InputProvider, useInputFocus } from './InputContext.tsx';
|
|
5
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { DemoPreset } from '@/demo/simulator.ts';
|
|
3
|
+
import { TestModeContext } from './hooks/useSafeRenderer.ts';
|
|
4
|
+
import { App } from './App.tsx';
|
|
5
|
+
|
|
6
|
+
export { App };
|
|
7
|
+
|
|
8
|
+
export interface CreateAppOptions {
|
|
9
|
+
debug?: boolean;
|
|
10
|
+
refreshInterval?: number;
|
|
11
|
+
testMode?: boolean;
|
|
12
|
+
demoMode?: boolean;
|
|
13
|
+
demoSeed?: number;
|
|
14
|
+
demoPreset?: DemoPreset;
|
|
15
|
+
cliPlugins?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_APP_OPTIONS = {
|
|
19
|
+
debug: false,
|
|
20
|
+
refreshInterval: 60000,
|
|
21
|
+
testMode: false,
|
|
22
|
+
demoMode: false,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export function createAppElement(options: CreateAppOptions = {}): ReactNode {
|
|
26
|
+
const isTestMode = options.testMode ?? DEFAULT_APP_OPTIONS.testMode;
|
|
27
|
+
|
|
28
|
+
const appElement = (
|
|
29
|
+
<App
|
|
30
|
+
debug={options.debug ?? DEFAULT_APP_OPTIONS.debug}
|
|
31
|
+
demoMode={options.demoMode ?? DEFAULT_APP_OPTIONS.demoMode}
|
|
32
|
+
{...(options.demoSeed !== undefined ? { demoSeed: options.demoSeed } : {})}
|
|
33
|
+
{...(options.demoPreset !== undefined ? { demoPreset: options.demoPreset } : {})}
|
|
34
|
+
{...(options.cliPlugins ? { cliPlugins: options.cliPlugins } : {})}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (isTestMode) {
|
|
39
|
+
return (
|
|
40
|
+
<TestModeContext.Provider value={true}>
|
|
41
|
+
{appElement}
|
|
42
|
+
</TestModeContext.Provider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return appElement;
|
|
47
|
+
}
|