@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.
Files changed (43) hide show
  1. package/README.md +33 -11
  2. package/package.json +4 -4
  3. package/src/agents/aggregator.test.ts +269 -1
  4. package/src/agents/aggregator.ts +196 -8
  5. package/src/agents/costing.test.ts +584 -0
  6. package/src/agents/costing.ts +144 -30
  7. package/src/agents/types.ts +7 -0
  8. package/src/config/schema.ts +1 -1
  9. package/src/demo/simulator.test.ts +92 -0
  10. package/src/demo/simulator.ts +579 -211
  11. package/src/plugins/notification-bus.ts +27 -0
  12. package/src/plugins/notifications/terminal-bell.ts +80 -16
  13. package/src/plugins/notifications/visual-flash.ts +5 -18
  14. package/src/plugins/plugin-context-factory.ts +9 -1
  15. package/src/plugins/providers/anthropic.test.ts +145 -0
  16. package/src/plugins/providers/anthropic.ts +18 -12
  17. package/src/plugins/providers/chutes.ts +2 -0
  18. package/src/plugins/providers/github-copilot.ts +3 -0
  19. package/src/plugins/providers/minimax.ts +2 -0
  20. package/src/plugins/providers/openai-api.ts +4 -0
  21. package/src/plugins/providers/zai.ts +2 -0
  22. package/src/pricing/estimator.ts +7 -0
  23. package/src/pricing/index.ts +5 -0
  24. package/src/pricing/models-dev.test.ts +117 -0
  25. package/src/pricing/models-dev.ts +22 -0
  26. package/src/storage/persistence-service.test.ts +201 -0
  27. package/src/storage/persistence-service.ts +54 -5
  28. package/src/storage/repos/agentSessions.ts +49 -0
  29. package/src/tui/App.tsx +10 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  32. package/src/tui/components/SessionsTable.tsx +25 -7
  33. package/src/tui/components/SettingsModal.tsx +20 -4
  34. package/src/tui/components/Toast.tsx +1 -1
  35. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  36. package/src/tui/contexts/PluginContext.tsx +39 -10
  37. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  38. package/src/tui/utils/scrollFollow.test.ts +71 -0
  39. package/src/tui/utils/scrollFollow.ts +12 -0
  40. package/src/tui/views/Dashboard.tsx +0 -6
  41. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  42. package/src/tui/views/SettingsView.tsx +26 -8
  43. 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} fg={costColor} {...bgProp}>
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} fg={costColor} {...bgProp}>
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
- {animatedSessions.map((entry) => {
531
- const isSelectedRow = !entry.isExiting && activeIndex++ === selectedRow;
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: "Delivery",
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?.key === "theme") {
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.key === "theme") {
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 = 2000, onDismiss, type = "success" }: ToastProps) {
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 { aggregateSessionUsage, deduplicateAggregates } from "@/agents/aggregator.ts";
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
- // This is intentionally simple — no crypto hashing needed, just string comparison.
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
- fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount};`;
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
- const ctx = { http, logger, config: {}, signal: AbortSignal.timeout(60_000) };
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 = sessions.length === 0;
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 = pricedSessions.map((session) => ({
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}/${pricedSessions.length} sessions to storage`,
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(`Session refresh complete`, { count: pricedSessions.length }, "agent-sessions");
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
- [sessions.length, discoverAgents, fetchAgentSessions, debug, warn, logError, demoMode],
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 = sessions.some((s) => s.status === "active");
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, sessions, debug]);
328
-
329
- const value: AgentSessionContextValue = {
330
- sessions,
331
- agents,
332
- isLoading,
333
- lastRefreshAt,
334
- error,
335
- refreshSessions,
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
- // Skip providers that are currently rate-limited (backoff)
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