@tokentop/ttop 0.6.0 → 0.7.0
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/README.md +33 -11
- package/package.json +4 -4
- package/src/agents/aggregator.test.ts +269 -1
- package/src/agents/aggregator.ts +196 -8
- package/src/agents/costing.test.ts +584 -0
- package/src/agents/costing.ts +144 -30
- package/src/agents/types.ts +7 -0
- package/src/config/schema.ts +1 -1
- package/src/demo/simulator.test.ts +92 -0
- package/src/demo/simulator.ts +579 -211
- package/src/plugins/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- package/src/plugins/plugin-context-factory.ts +9 -1
- package/src/plugins/providers/anthropic.test.ts +145 -0
- package/src/plugins/providers/anthropic.ts +18 -12
- package/src/plugins/providers/chutes.ts +2 -0
- package/src/plugins/providers/github-copilot.ts +3 -0
- package/src/plugins/providers/minimax.ts +2 -0
- package/src/plugins/providers/openai-api.ts +4 -0
- package/src/plugins/providers/zai.ts +2 -0
- package/src/pricing/estimator.ts +7 -0
- package/src/pricing/index.ts +5 -0
- package/src/pricing/models-dev.test.ts +117 -0
- package/src/pricing/models-dev.ts +22 -0
- package/src/storage/persistence-service.test.ts +201 -0
- package/src/storage/persistence-service.ts +54 -5
- package/src/storage/repos/agentSessions.ts +49 -0
- package/src/tui/App.tsx +10 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
- package/src/tui/components/SessionsTable.tsx +25 -7
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +59 -21
- package/src/tui/contexts/PluginContext.tsx +39 -10
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/utils/scrollFollow.test.ts +71 -0
- package/src/tui/utils/scrollFollow.ts +12 -0
- package/src/tui/views/Dashboard.tsx +0 -6
- package/src/tui/views/RealTimeDashboard.tsx +105 -38
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/version.ts +1 -1
|
@@ -13,6 +13,8 @@ import { getSortDirectionIndicator, getSortFieldLabel } from "../types/sort.ts";
|
|
|
13
13
|
interface SessionsTableProps {
|
|
14
14
|
sessions: AgentSessionAggregate[];
|
|
15
15
|
selectedRow: number;
|
|
16
|
+
scrollOffset: number;
|
|
17
|
+
visibleRows: number;
|
|
16
18
|
isLoading: boolean;
|
|
17
19
|
isFiltering: boolean;
|
|
18
20
|
filterQuery: string;
|
|
@@ -159,6 +161,7 @@ const SessionRow = memo(function SessionRow({
|
|
|
159
161
|
const cacheRead = session.totals.cacheRead ?? 0;
|
|
160
162
|
const hasCacheData = cacheRead > 0;
|
|
161
163
|
const isEstimated = session.metadata?.isEstimated === true;
|
|
164
|
+
const hasLongContext = session.streams.some((s) => s.hasLongContext === true);
|
|
162
165
|
const costUsd = session.totalCostUsd ?? 0;
|
|
163
166
|
|
|
164
167
|
const animatedTokens = useAnimatedValue(effectiveTokens, { durationMs: 300, precision: 0 });
|
|
@@ -271,8 +274,9 @@ const SessionRow = memo(function SessionRow({
|
|
|
271
274
|
</span>
|
|
272
275
|
<span fg={textMutedColor}>{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}</span>
|
|
273
276
|
</text>
|
|
274
|
-
<text width={8} height={1}
|
|
275
|
-
{costDisplay.padStart(7)}
|
|
277
|
+
<text width={8} height={1} {...bgProp}>
|
|
278
|
+
<span fg={costColor}>{costDisplay.padStart(7)}</span>
|
|
279
|
+
<span fg={textMutedColor}>{hasLongContext ? "↑" : " "}</span>
|
|
276
280
|
</text>
|
|
277
281
|
<text width={14} height={1} fg={textSubtleColor} {...bgProp}>
|
|
278
282
|
{projectDisplay}
|
|
@@ -329,8 +333,9 @@ const SessionRow = memo(function SessionRow({
|
|
|
329
333
|
{hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
|
|
330
334
|
</span>
|
|
331
335
|
</text>
|
|
332
|
-
<text width={7} height={1}
|
|
333
|
-
{formatCostVal(costUsd).padStart(6)}
|
|
336
|
+
<text width={7} height={1} {...bgProp}>
|
|
337
|
+
<span fg={costColor}>{formatCostVal(costUsd).padStart(6)}</span>
|
|
338
|
+
<span fg={textMutedColor}>{hasLongContext ? "↑" : " "}</span>
|
|
334
339
|
</text>
|
|
335
340
|
<text flexGrow={1} height={1} fg={textSubtleColor} {...bgProp}>
|
|
336
341
|
{projectDisplay}
|
|
@@ -351,6 +356,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
351
356
|
{
|
|
352
357
|
sessions,
|
|
353
358
|
selectedRow,
|
|
359
|
+
scrollOffset,
|
|
360
|
+
visibleRows,
|
|
354
361
|
isLoading,
|
|
355
362
|
isFiltering,
|
|
356
363
|
filterQuery,
|
|
@@ -398,10 +405,18 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
398
405
|
bulkThreshold: 100,
|
|
399
406
|
});
|
|
400
407
|
|
|
401
|
-
let activeIndex = 0;
|
|
402
408
|
const selectedSession = sessions[selectedRow] ?? null;
|
|
403
409
|
const inspector = selectedSession ? getInspectorData(selectedSession) : null;
|
|
404
410
|
|
|
411
|
+
// Spacer boxes preserve scrollbox inner height so scrollTo(scrollOffset) still maps correctly.
|
|
412
|
+
const RENDER_BUFFER = 4;
|
|
413
|
+
const visibleStart = Math.max(0, scrollOffset - RENDER_BUFFER);
|
|
414
|
+
const visibleEnd = Math.min(animatedSessions.length, scrollOffset + visibleRows + RENDER_BUFFER);
|
|
415
|
+
const visibleSlice =
|
|
416
|
+
animatedSessions.length > 0 ? animatedSessions.slice(visibleStart, visibleEnd) : [];
|
|
417
|
+
const topSpacer = visibleStart;
|
|
418
|
+
const bottomSpacer = Math.max(0, animatedSessions.length - visibleEnd);
|
|
419
|
+
|
|
405
420
|
// Sort indicator helpers for column headers
|
|
406
421
|
const sortIndicator = (field: string) =>
|
|
407
422
|
sortField === field ? (sortDirection === "desc" ? "▼" : "▲") : " ";
|
|
@@ -527,8 +542,10 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
527
542
|
</text>
|
|
528
543
|
</box>
|
|
529
544
|
)}
|
|
530
|
-
{
|
|
531
|
-
|
|
545
|
+
{topSpacer > 0 && <box height={topSpacer} flexShrink={0} />}
|
|
546
|
+
{visibleSlice.map((entry, i) => {
|
|
547
|
+
const absIndex = visibleStart + i;
|
|
548
|
+
const isSelectedRow = !entry.isExiting && absIndex === selectedRow;
|
|
532
549
|
return (
|
|
533
550
|
<SessionRow
|
|
534
551
|
key={entry.item.sessionId}
|
|
@@ -544,6 +561,7 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
544
561
|
/>
|
|
545
562
|
);
|
|
546
563
|
})}
|
|
564
|
+
{bottomSpacer > 0 && <box height={bottomSpacer} flexShrink={0} />}
|
|
547
565
|
</box>
|
|
548
566
|
</scrollbox>
|
|
549
567
|
{terminalHeight >= 30 && (
|
|
@@ -2,6 +2,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
|
2
2
|
import type { ConfigField, PluginType } from "@tokentop/plugin-sdk";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
|
|
5
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
5
6
|
import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
|
|
6
7
|
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
7
8
|
import { useDemoMode } from "../contexts/DemoModeContext.tsx";
|
|
@@ -20,10 +21,11 @@ interface SettingItem {
|
|
|
20
21
|
description?: string;
|
|
21
22
|
category: SettingCategory;
|
|
22
23
|
pluginId?: string;
|
|
23
|
-
type: "toggle" | "select" | "number";
|
|
24
|
+
type: "toggle" | "select" | "number" | "action";
|
|
24
25
|
options?: string[];
|
|
25
26
|
getValue: (config: AppConfig) => string | number | boolean | null;
|
|
26
27
|
setValue: (config: AppConfig, value: string | number | boolean | null) => AppConfig;
|
|
28
|
+
onAction?: () => void;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const BASE_SETTINGS: SettingItem[] = [
|
|
@@ -205,6 +207,16 @@ const BASE_SETTINGS: SettingItem[] = [
|
|
|
205
207
|
notifications: { ...c.notifications, soundEnabled: v as boolean },
|
|
206
208
|
}),
|
|
207
209
|
},
|
|
210
|
+
{
|
|
211
|
+
key: "testNotification",
|
|
212
|
+
label: "Test Notifications",
|
|
213
|
+
description: "Send a test notification to verify toast, flash, and sound alerts are working.",
|
|
214
|
+
category: "notifications",
|
|
215
|
+
type: "action",
|
|
216
|
+
onAction: () => void notificationBus.testNotification(),
|
|
217
|
+
getValue: () => "▸ Run",
|
|
218
|
+
setValue: (c) => c,
|
|
219
|
+
},
|
|
208
220
|
];
|
|
209
221
|
|
|
210
222
|
const CATEGORIES: { id: SettingCategory; label: string }[] = [
|
|
@@ -362,7 +374,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
362
374
|
provider: "Providers",
|
|
363
375
|
agent: "Agents",
|
|
364
376
|
theme: "Themes",
|
|
365
|
-
notification: "
|
|
377
|
+
notification: "Notifications",
|
|
366
378
|
};
|
|
367
379
|
|
|
368
380
|
// Stable ordering for plugin type groups in sidebar
|
|
@@ -621,7 +633,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
621
633
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
622
634
|
} else if (key.name === "return" || key.name === "space") {
|
|
623
635
|
const setting = categorySettings[selectedIndex];
|
|
624
|
-
if (setting?.
|
|
636
|
+
if (setting?.type === "action" && setting.onAction) {
|
|
637
|
+
setting.onAction();
|
|
638
|
+
} else if (setting?.key === "theme") {
|
|
625
639
|
setShowThemePicker(true);
|
|
626
640
|
} else if (setting?.type === "number") {
|
|
627
641
|
startEditingNumber();
|
|
@@ -832,7 +846,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
832
846
|
setting.type === "number" && editingSettingKey === setting.key;
|
|
833
847
|
|
|
834
848
|
let displayValue: string;
|
|
835
|
-
if (setting.
|
|
849
|
+
if (setting.type === "action") {
|
|
850
|
+
displayValue = "▸ Run";
|
|
851
|
+
} else if (setting.key === "theme") {
|
|
836
852
|
const themeName = themes.find((t) => t.id === value)?.name ?? value;
|
|
837
853
|
displayValue = `${themeName} ▸`;
|
|
838
854
|
} else if (setting.type === "toggle") {
|
|
@@ -8,7 +8,7 @@ interface ToastProps {
|
|
|
8
8
|
type?: "info" | "success" | "warning" | "error";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function Toast({ message, duration =
|
|
11
|
+
export function Toast({ message, duration = 4000, onDismiss, type = "success" }: ToastProps) {
|
|
12
12
|
const colors = useColors();
|
|
13
13
|
|
|
14
14
|
useEffect(() => {
|
|
@@ -5,10 +5,17 @@ import {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
|
+
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
10
11
|
} from "react";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
aggregateSessionUsage,
|
|
14
|
+
deduplicateAggregates,
|
|
15
|
+
isCrossAgentShadow,
|
|
16
|
+
markCrossAgentShadows,
|
|
17
|
+
selectEffectiveSessions,
|
|
18
|
+
} from "@/agents/aggregator.ts";
|
|
12
19
|
import { priceSessions } from "@/agents/costing.ts";
|
|
13
20
|
import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
|
|
14
21
|
import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
|
|
@@ -48,10 +55,12 @@ interface AgentSessionProviderProps {
|
|
|
48
55
|
*/
|
|
49
56
|
function computeSessionFingerprint(sessions: AgentSessionAggregate[]): string {
|
|
50
57
|
// Build a compact string from the fields that actually affect the UI.
|
|
51
|
-
//
|
|
58
|
+
// Shadow bit is included so that flipping a session's dedup status
|
|
59
|
+
// (gaining or losing its cross-agent shadow mark) triggers a re-render.
|
|
52
60
|
let fp = `${sessions.length}:`;
|
|
53
61
|
for (const s of sessions) {
|
|
54
|
-
|
|
62
|
+
const shadow = isCrossAgentShadow(s) ? "1" : "0";
|
|
63
|
+
fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount},${shadow};`;
|
|
55
64
|
}
|
|
56
65
|
return fp;
|
|
57
66
|
}
|
|
@@ -68,6 +77,9 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
68
77
|
const { windowMs } = useTimeWindow();
|
|
69
78
|
const hasBackfilled = useRef(false);
|
|
70
79
|
const isRefreshingRef = useRef(false);
|
|
80
|
+
// Bugfix: track first completed load separately so an empty 1h window does not
|
|
81
|
+
// keep re-entering the initial loading state forever.
|
|
82
|
+
const hasLoadedOnceRef = useRef(false);
|
|
71
83
|
const lastFingerprintRef = useRef<string>("");
|
|
72
84
|
|
|
73
85
|
const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
|
|
@@ -110,7 +122,17 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
110
122
|
|
|
111
123
|
const http = createSandboxedHttpClient(plugin.id, plugin.permissions);
|
|
112
124
|
const logger = createPluginLogger(plugin.id);
|
|
113
|
-
|
|
125
|
+
// Lazy signal: only create the 120s timer if the plugin actually reads ctx.signal.
|
|
126
|
+
let parseSignal: AbortSignal | undefined;
|
|
127
|
+
const ctx = {
|
|
128
|
+
http,
|
|
129
|
+
logger,
|
|
130
|
+
config: {},
|
|
131
|
+
get signal(): AbortSignal {
|
|
132
|
+
if (!parseSignal) parseSignal = AbortSignal.timeout(120_000);
|
|
133
|
+
return parseSignal;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
114
136
|
|
|
115
137
|
const rawSessions = await plugin.parseSessions(options, ctx);
|
|
116
138
|
|
|
@@ -134,7 +156,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
134
156
|
if (isRefreshingRef.current) return;
|
|
135
157
|
isRefreshingRef.current = true;
|
|
136
158
|
|
|
137
|
-
const isInitialLoad =
|
|
159
|
+
const isInitialLoad = !hasLoadedOnceRef.current;
|
|
138
160
|
if (isInitialLoad) {
|
|
139
161
|
setIsLoading(true);
|
|
140
162
|
}
|
|
@@ -193,7 +215,6 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
193
215
|
}
|
|
194
216
|
}
|
|
195
217
|
|
|
196
|
-
// Deduplicate sessions claimed by multiple agents scanning the same directory.
|
|
197
218
|
const dedupedAggregates = deduplicateAggregates(allAggregates);
|
|
198
219
|
if (dedupedAggregates.length < allAggregates.length) {
|
|
199
220
|
debug(
|
|
@@ -203,6 +224,8 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
203
224
|
);
|
|
204
225
|
}
|
|
205
226
|
|
|
227
|
+
markCrossAgentShadows(dedupedAggregates);
|
|
228
|
+
|
|
206
229
|
const pricedSessions = await priceSessions(dedupedAggregates);
|
|
207
230
|
|
|
208
231
|
pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
@@ -221,11 +244,13 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
221
244
|
return;
|
|
222
245
|
}
|
|
223
246
|
|
|
247
|
+
const effectiveSessions = selectEffectiveSessions(pricedSessions);
|
|
248
|
+
|
|
224
249
|
// Persist to storage — the persistence service handles throttling
|
|
225
250
|
// internally (fingerprint gate + 5-minute per-session throttle).
|
|
226
251
|
// Safe to call every tick; state lives on globalThis, survives HMR.
|
|
227
252
|
const now = Date.now();
|
|
228
|
-
const persistData =
|
|
253
|
+
const persistData = effectiveSessions.map((session) => ({
|
|
229
254
|
session: {
|
|
230
255
|
agentId: session.agentId,
|
|
231
256
|
sessionId: session.sessionId,
|
|
@@ -258,7 +283,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
258
283
|
}));
|
|
259
284
|
const persistedCount = persistSessions(persistData);
|
|
260
285
|
debug(
|
|
261
|
-
`Persisted ${persistedCount}/${
|
|
286
|
+
`Persisted ${persistedCount}/${effectiveSessions.length} sessions to storage`,
|
|
262
287
|
undefined,
|
|
263
288
|
"agent-sessions",
|
|
264
289
|
);
|
|
@@ -266,7 +291,11 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
266
291
|
setSessions(pricedSessions);
|
|
267
292
|
setLastRefreshAt(Date.now());
|
|
268
293
|
|
|
269
|
-
debug(
|
|
294
|
+
debug(
|
|
295
|
+
`Session refresh complete`,
|
|
296
|
+
{ count: pricedSessions.length, effective: effectiveSessions.length },
|
|
297
|
+
"agent-sessions",
|
|
298
|
+
);
|
|
270
299
|
} catch (err) {
|
|
271
300
|
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
|
272
301
|
logError(`Session refresh failed: ${errorMsg}`, undefined, "agent-sessions");
|
|
@@ -275,15 +304,19 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
275
304
|
isRefreshingRef.current = false;
|
|
276
305
|
if (isInitialLoad) {
|
|
277
306
|
setIsLoading(false);
|
|
307
|
+
hasLoadedOnceRef.current = true;
|
|
278
308
|
}
|
|
279
309
|
}
|
|
280
310
|
},
|
|
281
|
-
[
|
|
311
|
+
[discoverAgents, fetchAgentSessions, debug, warn, logError, demoMode, simulator],
|
|
282
312
|
);
|
|
283
313
|
|
|
284
314
|
const refreshSessionsRef = useRef(refreshSessions);
|
|
285
315
|
refreshSessionsRef.current = refreshSessions;
|
|
286
316
|
|
|
317
|
+
const sessionsRef = useRef(sessions);
|
|
318
|
+
sessionsRef.current = sessions;
|
|
319
|
+
|
|
287
320
|
useEffect(() => {
|
|
288
321
|
if (!(demoMode || pluginsInitialized)) return;
|
|
289
322
|
|
|
@@ -304,7 +337,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
304
337
|
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
305
338
|
|
|
306
339
|
const scheduleNext = () => {
|
|
307
|
-
const hasActive =
|
|
340
|
+
const hasActive = sessionsRef.current.some((s) => s.status === "active");
|
|
308
341
|
const interval = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
|
|
309
342
|
|
|
310
343
|
timerId = setTimeout(() => {
|
|
@@ -324,16 +357,21 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
324
357
|
clearTimeout(timerId);
|
|
325
358
|
}
|
|
326
359
|
};
|
|
327
|
-
}, [autoRefresh,
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
360
|
+
}, [autoRefresh, debug]);
|
|
361
|
+
|
|
362
|
+
const effectiveSessions = useMemo(() => selectEffectiveSessions(sessions), [sessions]);
|
|
363
|
+
|
|
364
|
+
const value: AgentSessionContextValue = useMemo(
|
|
365
|
+
() => ({
|
|
366
|
+
sessions: effectiveSessions,
|
|
367
|
+
agents,
|
|
368
|
+
isLoading,
|
|
369
|
+
lastRefreshAt,
|
|
370
|
+
error,
|
|
371
|
+
refreshSessions,
|
|
372
|
+
}),
|
|
373
|
+
[effectiveSessions, agents, isLoading, lastRefreshAt, error, refreshSessions],
|
|
374
|
+
);
|
|
337
375
|
|
|
338
376
|
return <AgentSessionContext.Provider value={value}>{children}</AgentSessionContext.Provider>;
|
|
339
377
|
}
|
|
@@ -241,6 +241,14 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
241
241
|
initialize();
|
|
242
242
|
}, []);
|
|
243
243
|
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!isInitialized) return;
|
|
246
|
+
notificationBus.setPluginConfig("terminal-bell", {
|
|
247
|
+
...((config.pluginConfig ?? {})["terminal-bell"] ?? {}),
|
|
248
|
+
enabled: config.notifications.soundEnabled,
|
|
249
|
+
});
|
|
250
|
+
}, [config.notifications.soundEnabled, isInitialized]);
|
|
251
|
+
|
|
244
252
|
const prevPluginConfigRef = useRef<Record<string, Record<string, unknown>>>({});
|
|
245
253
|
useEffect(() => {
|
|
246
254
|
if (!isInitialized) return;
|
|
@@ -267,7 +275,11 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
267
275
|
return;
|
|
268
276
|
}
|
|
269
277
|
|
|
270
|
-
|
|
278
|
+
if (state.loading) {
|
|
279
|
+
debug(`Skipping refresh for ${providerId}: already in flight`, undefined, "refresh");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
271
283
|
const now = Date.now();
|
|
272
284
|
if (state.rateLimitUntil > now) {
|
|
273
285
|
const remainingSec = Math.round((state.rateLimitUntil - now) / 1000);
|
|
@@ -352,19 +364,25 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
352
364
|
|
|
353
365
|
const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
|
|
354
366
|
const logger = createPluginLogger(providerId);
|
|
367
|
+
// Lazy signal: only create the 30s timer if a plugin actually reads ctx.signal.
|
|
368
|
+
let fetchSignal: AbortSignal | undefined;
|
|
369
|
+
const fetchCtx = {
|
|
370
|
+
credentials: creds,
|
|
371
|
+
http,
|
|
372
|
+
logger,
|
|
373
|
+
config: {},
|
|
374
|
+
get signal(): AbortSignal {
|
|
375
|
+
if (!fetchSignal) fetchSignal = AbortSignal.timeout(30_000);
|
|
376
|
+
return fetchSignal;
|
|
377
|
+
},
|
|
378
|
+
};
|
|
355
379
|
|
|
356
380
|
const fetchResult = await safeInvoke(
|
|
357
381
|
providerId,
|
|
358
382
|
"fetchUsage",
|
|
359
383
|
() =>
|
|
360
384
|
runInPluginGuard(providerId, state.plugin.permissions, () =>
|
|
361
|
-
state.plugin.fetchUsage(
|
|
362
|
-
credentials: creds,
|
|
363
|
-
http,
|
|
364
|
-
logger,
|
|
365
|
-
config: {},
|
|
366
|
-
signal: AbortSignal.timeout(30_000),
|
|
367
|
-
}),
|
|
385
|
+
state.plugin.fetchUsage(fetchCtx),
|
|
368
386
|
),
|
|
369
387
|
{
|
|
370
388
|
// Count non-rate-limit errors as circuit breaker failures.
|
|
@@ -505,14 +523,25 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
505
523
|
await Promise.all(configuredProviders.map(refreshProvider));
|
|
506
524
|
}, [providers, refreshProvider, info]);
|
|
507
525
|
|
|
526
|
+
const refreshProviderRef = useRef(refreshProvider);
|
|
527
|
+
const refreshAllProvidersRef = useRef(refreshAllProviders);
|
|
528
|
+
refreshProviderRef.current = refreshProvider;
|
|
529
|
+
refreshAllProvidersRef.current = refreshAllProviders;
|
|
530
|
+
|
|
531
|
+
const stableRefreshProvider = useCallback(
|
|
532
|
+
(providerId: string) => refreshProviderRef.current(providerId),
|
|
533
|
+
[],
|
|
534
|
+
);
|
|
535
|
+
const stableRefreshAllProviders = useCallback(() => refreshAllProvidersRef.current(), []);
|
|
536
|
+
|
|
508
537
|
const value: PluginContextValue = {
|
|
509
538
|
providers,
|
|
510
539
|
agents,
|
|
511
540
|
themes,
|
|
512
541
|
notifications,
|
|
513
542
|
isInitialized,
|
|
514
|
-
refreshProvider,
|
|
515
|
-
refreshAllProviders,
|
|
543
|
+
refreshProvider: stableRefreshProvider,
|
|
544
|
+
refreshAllProviders: stableRefreshAllProviders,
|
|
516
545
|
};
|
|
517
546
|
|
|
518
547
|
return <PluginContext.Provider value={value}>{children}</PluginContext.Provider>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NotificationEvent } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
4
|
+
import { useToastContext } from "../contexts/ToastContext.tsx";
|
|
5
|
+
|
|
6
|
+
function severityToToastType(
|
|
7
|
+
severity: NotificationEvent["severity"],
|
|
8
|
+
): "info" | "warning" | "error" {
|
|
9
|
+
switch (severity) {
|
|
10
|
+
case "critical":
|
|
11
|
+
return "error";
|
|
12
|
+
case "warning":
|
|
13
|
+
return "warning";
|
|
14
|
+
default:
|
|
15
|
+
return "info";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useNotificationBridge(): void {
|
|
20
|
+
const { showToast } = useToastContext();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handler = (event: NotificationEvent) => {
|
|
24
|
+
showToast(event.title, severityToToastType(event.severity));
|
|
25
|
+
};
|
|
26
|
+
notificationBus.setToastHandler(handler);
|
|
27
|
+
return () => notificationBus.setToastHandler(null);
|
|
28
|
+
}, [showToast]);
|
|
29
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { computeScrollOffset } from "./scrollFollow.ts";
|
|
3
|
+
|
|
4
|
+
describe("computeScrollOffset", () => {
|
|
5
|
+
test("cursor inside viewport leaves offset unchanged", () => {
|
|
6
|
+
expect(computeScrollOffset(5, 0, 10, 100)).toBe(0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("cursor at bottom edge leaves offset unchanged", () => {
|
|
10
|
+
expect(computeScrollOffset(9, 0, 10, 100)).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("cursor past bottom pulls offset down by one row", () => {
|
|
14
|
+
expect(computeScrollOffset(10, 0, 10, 100)).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("cursor past top pulls offset up", () => {
|
|
18
|
+
expect(computeScrollOffset(3, 5, 10, 100)).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("clamps offset to non-negative", () => {
|
|
22
|
+
expect(computeScrollOffset(0, 0, 10, 100)).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("clamps offset to total - visibleRows", () => {
|
|
26
|
+
expect(computeScrollOffset(99, 0, 10, 100)).toBe(90);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("offset stays 0 when content fits in viewport", () => {
|
|
30
|
+
expect(computeScrollOffset(3, 0, 10, 4)).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Regression test for cursor-disappears-then-jump bug: rapid j presses
|
|
34
|
+
// that React batches into a single render must still land with the cursor
|
|
35
|
+
// visible at the bottom edge. Simulating N sequential functional updates
|
|
36
|
+
// (the reducer runs each one in order) verifies the invariant.
|
|
37
|
+
test("batched rapid j-presses keep cursor at bottom edge each step", () => {
|
|
38
|
+
const vis = 10;
|
|
39
|
+
const total = 1000;
|
|
40
|
+
let state = { row: 0, offset: 0 };
|
|
41
|
+
for (let i = 0; i < 50; i++) {
|
|
42
|
+
const nextRow = Math.min(state.row + 1, total - 1);
|
|
43
|
+
const nextOffset = computeScrollOffset(nextRow, state.offset, vis, total);
|
|
44
|
+
state = { row: nextRow, offset: nextOffset };
|
|
45
|
+
expect(state.row).toBeGreaterThanOrEqual(state.offset);
|
|
46
|
+
expect(state.row).toBeLessThan(state.offset + vis);
|
|
47
|
+
}
|
|
48
|
+
expect(state.row).toBe(50);
|
|
49
|
+
expect(state.offset).toBe(41);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("batched rapid k-presses at top keep cursor at top edge", () => {
|
|
53
|
+
const vis = 10;
|
|
54
|
+
const total = 1000;
|
|
55
|
+
let state = { row: 50, offset: 41 };
|
|
56
|
+
for (let i = 0; i < 50; i++) {
|
|
57
|
+
const nextRow = Math.max(state.row - 1, 0);
|
|
58
|
+
const nextOffset = computeScrollOffset(nextRow, state.offset, vis, total);
|
|
59
|
+
state = { row: nextRow, offset: nextOffset };
|
|
60
|
+
expect(state.row).toBeGreaterThanOrEqual(state.offset);
|
|
61
|
+
expect(state.row).toBeLessThan(state.offset + vis);
|
|
62
|
+
}
|
|
63
|
+
expect(state.row).toBe(0);
|
|
64
|
+
expect(state.offset).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("re-clamp when list shrinks below current offset", () => {
|
|
68
|
+
expect(computeScrollOffset(5, 50, 10, 20)).toBe(5);
|
|
69
|
+
expect(computeScrollOffset(19, 50, 10, 20)).toBe(10);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function computeScrollOffset(
|
|
2
|
+
row: number,
|
|
3
|
+
prevOffset: number,
|
|
4
|
+
visibleRows: number,
|
|
5
|
+
total: number,
|
|
6
|
+
): number {
|
|
7
|
+
let next = prevOffset;
|
|
8
|
+
if (row < next) next = row;
|
|
9
|
+
else if (row >= next + visibleRows) next = row - visibleRows + 1;
|
|
10
|
+
const max = Math.max(0, total - visibleRows);
|
|
11
|
+
return Math.max(0, Math.min(next, max));
|
|
12
|
+
}
|
|
@@ -60,12 +60,6 @@ export function Dashboard() {
|
|
|
60
60
|
const cardRefs = useRef<(BoxRenderable | null)[]>([]);
|
|
61
61
|
const inputRef = useRef<InputRenderable>(null);
|
|
62
62
|
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (isInitialized) {
|
|
65
|
-
refreshAllProviders();
|
|
66
|
-
}
|
|
67
|
-
}, [isInitialized]);
|
|
68
|
-
|
|
69
63
|
const getMaxUsage = useCallback((state: ProviderState) => {
|
|
70
64
|
if (!state.usage?.limits) return 0;
|
|
71
65
|
|