@tokentop/ttop 0.4.0 → 0.6.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 (102) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -5
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +44 -2
  5. package/src/agents/types.ts +3 -0
  6. package/src/cli.ts +16 -2
  7. package/src/demo/simulator.ts +2 -2
  8. package/src/plugins/agents/index.ts +3 -0
  9. package/src/plugins/auth-sources.ts +2 -3
  10. package/src/plugins/lifecycle.ts +2 -2
  11. package/src/plugins/loader.test.ts +1 -1
  12. package/src/plugins/loader.ts +15 -7
  13. package/src/plugins/notification-bus.ts +5 -2
  14. package/src/plugins/notifications/terminal-bell.ts +1 -1
  15. package/src/plugins/notifications/visual-flash.ts +1 -1
  16. package/src/plugins/plugin-context-factory.ts +4 -19
  17. package/src/plugins/plugin-host.test.ts +197 -0
  18. package/src/plugins/plugin-host.ts +45 -1
  19. package/src/plugins/providers/anthropic.test.ts +202 -0
  20. package/src/plugins/providers/anthropic.ts +21 -2
  21. package/src/plugins/providers/antigravity.ts +17 -1
  22. package/src/plugins/providers/chutes.ts +205 -0
  23. package/src/plugins/providers/codex.ts +17 -1
  24. package/src/plugins/providers/gemini.ts +17 -1
  25. package/src/plugins/providers/github-copilot.ts +17 -1
  26. package/src/plugins/providers/index.ts +1 -0
  27. package/src/plugins/providers/minimax.ts +17 -1
  28. package/src/plugins/providers/openai-api.ts +53 -1
  29. package/src/plugins/providers/opencode-zen.ts +1 -1
  30. package/src/plugins/providers/perplexity.ts +11 -2
  31. package/src/plugins/providers/zai.ts +17 -1
  32. package/src/plugins/registry.ts +9 -9
  33. package/src/plugins/sandbox-guard.test.ts +1 -1
  34. package/src/plugins/sandbox-guard.ts +1 -1
  35. package/src/plugins/sandbox.ts +1 -1
  36. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  37. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  38. package/src/plugins/themes/claude-code.ts +1 -1
  39. package/src/plugins/themes/dracula.ts +1 -1
  40. package/src/plugins/themes/github-light.ts +1 -1
  41. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  42. package/src/plugins/themes/gruvbox-light.ts +1 -1
  43. package/src/plugins/themes/kanagawa.ts +1 -1
  44. package/src/plugins/themes/nord.ts +1 -1
  45. package/src/plugins/themes/one-dark.ts +1 -1
  46. package/src/plugins/themes/opencode.ts +1 -1
  47. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  48. package/src/plugins/themes/rose-pine.ts +1 -1
  49. package/src/plugins/themes/solarized-light.ts +1 -1
  50. package/src/plugins/themes/tokyo-night.ts +1 -1
  51. package/src/plugins/update-checker.ts +2 -2
  52. package/src/pricing/estimator.test.ts +1 -1
  53. package/src/pricing/estimator.ts +1 -1
  54. package/src/pricing/fallback.ts +5 -1
  55. package/src/pricing/index.ts +1 -1
  56. package/src/pricing/models-dev.ts +1 -1
  57. package/src/storage/index.ts +6 -0
  58. package/src/storage/migrations/index.ts +17 -0
  59. package/src/storage/repos/pluginStorage.ts +35 -0
  60. package/src/tui/App.tsx +17 -9
  61. package/src/tui/components/HelpOverlay.tsx +1 -1
  62. package/src/tui/components/LimitGauge.tsx +12 -4
  63. package/src/tui/components/ProviderCard.tsx +39 -23
  64. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  65. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  66. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  67. package/src/tui/components/SessionsTable.tsx +62 -37
  68. package/src/tui/components/SettingsModal.tsx +1 -1
  69. package/src/tui/components/Skeleton.tsx +9 -6
  70. package/src/tui/components/SortOverlay.tsx +167 -0
  71. package/src/tui/components/Spinner.tsx +16 -11
  72. package/src/tui/components/ThemePicker.tsx +1 -1
  73. package/src/tui/contexts/AgentSessionContext.tsx +70 -17
  74. package/src/tui/contexts/PluginContext.tsx +72 -14
  75. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  76. package/src/tui/contexts/ThemeContext.tsx +7 -4
  77. package/src/tui/createApp.tsx +2 -0
  78. package/src/tui/hooks/index.ts +1 -0
  79. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  80. package/src/tui/hooks/useAnimationTick.ts +93 -0
  81. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  82. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  83. package/src/tui/hooks/usePulse.ts +13 -7
  84. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  85. package/src/tui/hooks/useValueFlash.ts +25 -33
  86. package/src/tui/index.tsx +4 -0
  87. package/src/tui/types/projectSort.ts +35 -0
  88. package/src/tui/types/sort.ts +61 -0
  89. package/src/tui/views/ProjectsView.tsx +104 -38
  90. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  91. package/src/tui/views/SettingsView.tsx +1 -1
  92. package/src/utils/__tests__/error-category.test.ts +147 -0
  93. package/src/utils/currency.ts +4 -2
  94. package/src/utils/error-category.ts +91 -0
  95. package/src/version.ts +1 -1
  96. package/src/plugins/types/agent.ts +0 -108
  97. package/src/plugins/types/base.ts +0 -278
  98. package/src/plugins/types/index.ts +0 -19
  99. package/src/plugins/types/notification.ts +0 -51
  100. package/src/plugins/types/provider.ts +0 -237
  101. package/src/plugins/types/theme.ts +0 -78
  102. package/src/storage/database.ts +0 -623
@@ -7,6 +7,8 @@ import { useAnimatedValue } from "../hooks/useAnimatedValue.ts";
7
7
  import { applyEntranceFade, useEntranceAnimation } from "../hooks/useEntranceAnimation.ts";
8
8
  import { useExitAnimation } from "../hooks/useExitAnimation.ts";
9
9
  import { interpolateColor, useValueFlash } from "../hooks/useValueFlash.ts";
10
+ import type { SortDirection, SortField } from "../types/sort.ts";
11
+ import { getSortDirectionIndicator, getSortFieldLabel } from "../types/sort.ts";
10
12
 
11
13
  interface SessionsTableProps {
12
14
  sessions: AgentSessionAggregate[];
@@ -17,6 +19,8 @@ interface SessionsTableProps {
17
19
  focusedPanel: "sessions" | "sidebar" | "limits";
18
20
  windowLabel: string;
19
21
  getProviderColor: (id: string) => string;
22
+ sortField: SortField;
23
+ sortDirection: SortDirection;
20
24
  }
21
25
 
22
26
  function extractRepoName(projectPath: string | null): string {
@@ -154,6 +158,7 @@ const SessionRow = memo(function SessionRow({
154
158
  const effectiveTokens = session.totals.input + session.totals.output;
155
159
  const cacheRead = session.totals.cacheRead ?? 0;
156
160
  const hasCacheData = cacheRead > 0;
161
+ const isEstimated = session.metadata?.isEstimated === true;
157
162
  const costUsd = session.totalCostUsd ?? 0;
158
163
 
159
164
  const animatedTokens = useAnimatedValue(effectiveTokens, { durationMs: 300, precision: 0 });
@@ -260,10 +265,11 @@ const SessionRow = memo(function SessionRow({
260
265
  {String(session.requestCount).padStart(4)}
261
266
  </text>
262
267
  <text width={14} height={1} {...bgProp}>
263
- <span fg={tokenColor}>{formatTokensVal(animatedTokens).padStart(7)}</span>
264
- <span fg={colors.textMuted}>
265
- {hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
268
+ <span fg={tokenColor}>
269
+ {isEstimated ? "≈" : " "}
270
+ {formatTokensVal(animatedTokens).padStart(7)}
266
271
  </span>
272
+ <span fg={textMutedColor}>{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}</span>
267
273
  </text>
268
274
  <text width={8} height={1} fg={costColor} {...bgProp}>
269
275
  {costDisplay.padStart(7)}
@@ -315,8 +321,11 @@ const SessionRow = memo(function SessionRow({
315
321
  : modelWithCount.padEnd(narrowModelMaxChars)}
316
322
  </text>
317
323
  <text width={narrowTokenW} height={1} {...bgProp}>
318
- <span fg={tokenColor}>{formatTokensVal(animatedTokens).padStart(7)}</span>
319
- <span fg={colors.textMuted}>
324
+ <span fg={tokenColor}>
325
+ {isEstimated ? "≈" : " "}
326
+ {formatTokensVal(animatedTokens).padStart(7)}
327
+ </span>
328
+ <span fg={textMutedColor}>
320
329
  {hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
321
330
  </span>
322
331
  </text>
@@ -348,6 +357,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
348
357
  focusedPanel,
349
358
  windowLabel,
350
359
  getProviderColor,
360
+ sortField,
361
+ sortDirection,
351
362
  }: SessionsTableProps,
352
363
  ref: Ref<ScrollBoxRenderable>,
353
364
  ) {
@@ -384,16 +395,18 @@ export const SessionsTable = forwardRef(function SessionsTable(
384
395
  const { items: animatedSessions, isBulkChange } = useExitAnimation(sessions, {
385
396
  durationMs: 500,
386
397
  getKey: getSessionKey,
398
+ bulkThreshold: 100,
387
399
  });
388
400
 
389
- // Track bulk changes to force scrollbox remount (clears ghost text)
390
- const bulkCountRef = useRef(0);
391
- if (isBulkChange) bulkCountRef.current++;
392
-
393
401
  let activeIndex = 0;
394
402
  const selectedSession = sessions[selectedRow] ?? null;
395
403
  const inspector = selectedSession ? getInspectorData(selectedSession) : null;
396
404
 
405
+ // Sort indicator helpers for column headers
406
+ const sortIndicator = (field: string) =>
407
+ sortField === field ? (sortDirection === "desc" ? "▼" : "▲") : " ";
408
+ const sortColor = (field: string) => (sortField === field ? colors.primary : colors.textMuted);
409
+
397
410
  return (
398
411
  <box
399
412
  flexDirection="column"
@@ -411,9 +424,15 @@ export const SessionsTable = forwardRef(function SessionsTable(
411
424
  justifyContent="space-between"
412
425
  overflow="hidden"
413
426
  >
414
- <text height={1} fg={colors.textMuted}>
415
- SESSIONS{filterQuery ? ` [${isFiltering ? "Filter: " : ""}${filterQuery}]` : ""}
416
- {isLoading ? " ⟳" : " "}
427
+ <text height={1}>
428
+ <span fg={colors.textMuted}>SESSIONS </span>
429
+ <span fg={colors.primary}>
430
+ Sort: {getSortFieldLabel(sortField)} {getSortDirectionIndicator(sortDirection)}
431
+ </span>
432
+ <span fg={colors.textMuted}>
433
+ {filterQuery ? ` [${isFiltering ? "Filter: " : ""}${filterQuery}]` : ""}
434
+ {isLoading ? " ⟳" : " "}
435
+ </span>
417
436
  </text>
418
437
  <text height={1} fg={colors.textMuted}>
419
438
  [{windowLabel}] {sessions.length} sessions
@@ -428,36 +447,36 @@ export const SessionsTable = forwardRef(function SessionsTable(
428
447
  <text width={8} height={1} fg={colors.textMuted}>
429
448
  ID{" "}
430
449
  </text>
431
- <text width={12} height={1} fg={colors.textMuted}>
432
- AGENT{" "}
450
+ <text width={12} height={1} fg={sortColor("agent")}>
451
+ AGENT{sortIndicator("agent")}
433
452
  </text>
434
453
  <text width={18} height={1} fg={colors.textMuted}>
435
454
  MODEL{" "}
436
455
  </text>
437
- <text width={5} height={1} fg={colors.textMuted}>
438
- {" "}
456
+ <text width={5} height={1} fg={sortColor("requests")}>
457
+ {sortIndicator("requests")}
439
458
  REQ{" "}
440
459
  </text>
441
- <text width={14} height={1} fg={colors.textMuted}>
442
- {" "}
460
+ <text width={14} height={1} fg={sortColor("tokens")}>
461
+ {sortIndicator("tokens")}
443
462
  TOKENS{" "}
444
463
  </text>
445
- <text width={8} height={1} fg={colors.textMuted}>
446
- {" "}
464
+ <text width={8} height={1} fg={sortColor("cost")}>
465
+ {sortIndicator("cost")}
447
466
  COST{" "}
448
467
  </text>
449
- <text width={14} height={1} fg={colors.textMuted}>
450
- PROJECT{" "}
468
+ <text width={14} height={1} fg={sortColor("project")}>
469
+ PROJECT{sortIndicator("project")}
451
470
  </text>
452
471
  <text flexGrow={1} height={1} fg={colors.textMuted}>
453
472
  NAME
454
473
  </text>
455
- <text width={6} height={1} fg={colors.textMuted}>
456
- {" "}
474
+ <text width={6} height={1} fg={sortColor("duration")}>
475
+ {sortIndicator("duration")}
457
476
  DUR{" "}
458
477
  </text>
459
- <text width={5} height={1} fg={colors.textMuted}>
460
- LAST{" "}
478
+ <text width={5} height={1} fg={sortColor("time")}>
479
+ LAST{sortIndicator("time")}
461
480
  </text>
462
481
  <text width={2} height={1} fg={colors.textMuted}>
463
482
  {" "}
@@ -468,24 +487,30 @@ export const SessionsTable = forwardRef(function SessionsTable(
468
487
  <text width={2} height={1} fg={colors.textMuted}>
469
488
  {" "}
470
489
  </text>
471
- <text width={9} height={1} fg={colors.textMuted}>
472
- AGENT{" "}
490
+ <text width={9} height={1} fg={sortColor("agent")}>
491
+ AGENT{sortIndicator("agent")}
473
492
  </text>
474
493
  <text width={isTight ? 13 : 16} height={1} fg={colors.textMuted}>
475
494
  {isTight ? "MODEL " : "MODEL "}
476
495
  </text>
477
- <text width={isTight ? 9 : 14} height={1} fg={colors.textMuted}>
478
- {isTight ? " TOKENS " : " TOKENS "}
496
+ <text width={isTight ? 9 : 14} height={1} fg={sortColor("tokens")}>
497
+ {isTight
498
+ ? `${sortIndicator("tokens")} TOKENS `
499
+ : `${sortIndicator("tokens")}TOKENS `}
479
500
  </text>
480
- <text width={7} height={1} fg={colors.textMuted}>
481
- {" "}
501
+ <text width={7} height={1} fg={sortColor("cost")}>
502
+ {sortIndicator("cost")}
482
503
  COST{" "}
483
504
  </text>
484
- <text flexGrow={1} height={1} fg={colors.textMuted}>
485
- {nameMaxWidth >= 7 ? "PROJECT" : nameMaxWidth >= 4 ? "PROJ" : ""}
505
+ <text flexGrow={1} height={1} fg={sortColor("project")}>
506
+ {nameMaxWidth >= 7
507
+ ? `PROJECT${sortIndicator("project")}`
508
+ : nameMaxWidth >= 4
509
+ ? "PROJ"
510
+ : ""}
486
511
  </text>
487
- <text width={5} height={1} fg={colors.textMuted}>
488
- LAST{" "}
512
+ <text width={5} height={1} fg={sortColor("time")}>
513
+ LAST{sortIndicator("time")}
489
514
  </text>
490
515
  <text width={2} height={1} fg={colors.textMuted}>
491
516
  {" "}
@@ -493,7 +518,7 @@ export const SessionsTable = forwardRef(function SessionsTable(
493
518
  </box>
494
519
  )}
495
520
 
496
- <scrollbox key={`sessions-sb-${bulkCountRef.current}`} ref={ref} flexGrow={1}>
521
+ <scrollbox ref={ref} flexGrow={1}>
497
522
  <box flexDirection="column" flexGrow={1}>
498
523
  {sessions.length === 0 && animatedSessions.length === 0 && (
499
524
  <box paddingLeft={2}>
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
+ import type { ConfigField, PluginType } from "@tokentop/plugin-sdk";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
4
  import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
4
- import type { ConfigField, PluginType } from "@/plugins/types/base.ts";
5
5
  import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
6
6
  import { useConfig } from "../contexts/ConfigContext.tsx";
7
7
  import { useDemoMode } from "../contexts/DemoModeContext.tsx";
@@ -1,5 +1,6 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { useColors } from "../contexts/ThemeContext.tsx";
3
+ import { animationTick } from "../hooks/useAnimationTick.ts";
3
4
  import { interpolateColor } from "../hooks/useValueFlash.ts";
4
5
 
5
6
  const SHIMMER_INTERVAL = 50;
@@ -12,13 +13,15 @@ interface ShimmerSegment {
12
13
 
13
14
  function useShimmer(length: number, baseColor: string, highlightColor: string): ShimmerSegment[] {
14
15
  const [phase, setPhase] = useState(0);
16
+ const lastAdvanceRef = useRef(Date.now());
15
17
 
16
18
  useEffect(() => {
17
- const interval = setInterval(() => {
18
- setPhase((p) => (p + 1) % (length + SHIMMER_WAVE_LENGTH));
19
- }, SHIMMER_INTERVAL);
20
-
21
- return () => clearInterval(interval);
19
+ return animationTick.subscribe((now) => {
20
+ if (now - lastAdvanceRef.current >= SHIMMER_INTERVAL) {
21
+ lastAdvanceRef.current = now;
22
+ setPhase((p) => (p + 1) % (length + SHIMMER_WAVE_LENGTH));
23
+ }
24
+ });
22
25
  }, [length]);
23
26
 
24
27
  const segments: ShimmerSegment[] = [];
@@ -0,0 +1,167 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { useEffect, useState } from "react";
3
+ import { useInputFocus } from "../contexts/InputContext.tsx";
4
+ import { useColors } from "../contexts/ThemeContext.tsx";
5
+ import type { SortDirection, SortFieldDef } from "../types/sort.ts";
6
+ import { getSortDirectionIndicator } from "../types/sort.ts";
7
+ import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
8
+
9
+ interface SortOverlayProps<F extends string = string> {
10
+ fields: readonly SortFieldDef<F>[];
11
+ currentField: F;
12
+ currentDirection: SortDirection;
13
+ onSelect: (field: F, direction: SortDirection) => void;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export function SortOverlay<F extends string>({
18
+ fields,
19
+ currentField,
20
+ currentDirection,
21
+ onSelect,
22
+ onClose,
23
+ }: SortOverlayProps<F>) {
24
+ const colors = useColors();
25
+ const { setInputFocused } = useInputFocus();
26
+
27
+ const [highlightedIndex, setHighlightedIndex] = useState(() => {
28
+ const idx = fields.findIndex((f) => f.id === currentField);
29
+ return idx === -1 ? 0 : idx;
30
+ });
31
+
32
+ const [pendingDirection, setPendingDirection] = useState<SortDirection>(currentDirection);
33
+
34
+ // Capture input focus to block App.tsx and view-level keyboard handlers
35
+ useEffect(() => {
36
+ setInputFocused(true);
37
+ return () => setInputFocused(false);
38
+ }, [setInputFocused]);
39
+
40
+ useKeyboard((key) => {
41
+ // Navigation
42
+ if (key.name === "up" || key.name === "k") {
43
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : fields.length - 1));
44
+ return;
45
+ }
46
+
47
+ if (key.name === "down" || key.name === "j") {
48
+ setHighlightedIndex((prev) => (prev < fields.length - 1 ? prev + 1 : 0));
49
+ return;
50
+ }
51
+
52
+ // Toggle direction in-place
53
+ if (key.name === "space" || key.sequence === " ") {
54
+ setPendingDirection((prev) => (prev === "asc" ? "desc" : "asc"));
55
+ return;
56
+ }
57
+
58
+ // Apply highlighted selection
59
+ if (key.name === "return") {
60
+ const field = fields[highlightedIndex];
61
+ if (field) {
62
+ onSelect(field.id, pendingDirection);
63
+ }
64
+ return;
65
+ }
66
+
67
+ // Cancel
68
+ if (key.name === "escape") {
69
+ onClose();
70
+ return;
71
+ }
72
+
73
+ // Number quick-select: 1-9 selects field by position
74
+ // If already sorting by that field, flip direction instead
75
+ if (key.sequence && key.sequence.length === 1) {
76
+ const digit = key.sequence.charCodeAt(0) - 48; // '0' = 48
77
+ if (digit >= 1 && digit <= fields.length) {
78
+ const field = fields[digit - 1];
79
+ if (field) {
80
+ const direction =
81
+ field.id === currentField
82
+ ? currentDirection === "desc"
83
+ ? "asc"
84
+ : "desc"
85
+ : field.defaultDirection;
86
+ onSelect(field.id, direction);
87
+ }
88
+ return;
89
+ }
90
+ }
91
+ });
92
+
93
+ return (
94
+ <ModalBackdrop zIndex={Z_INDEX.MODAL} onBackdropClick={onClose}>
95
+ <box
96
+ width={34}
97
+ flexDirection="column"
98
+ border
99
+ borderStyle="double"
100
+ borderColor={colors.primary}
101
+ backgroundColor={colors.background}
102
+ overflow="hidden"
103
+ >
104
+ {/* Header */}
105
+ <box height={1} justifyContent="center">
106
+ <text fg={colors.primary} height={1}>
107
+ Sort By
108
+ </text>
109
+ </box>
110
+
111
+ {/* Sort Fields */}
112
+ <box flexDirection="column">
113
+ {fields.map((field, index) => {
114
+ const isHighlighted = index === highlightedIndex;
115
+ const isCurrent = field.id === currentField;
116
+
117
+ const textColor = isHighlighted ? colors.background : colors.text;
118
+ const numberColor = isHighlighted ? colors.background : colors.textMuted;
119
+ const markerColor = isHighlighted ? colors.background : colors.primary;
120
+
121
+ return (
122
+ <box
123
+ key={field.id}
124
+ flexDirection="row"
125
+ height={1}
126
+ paddingLeft={1}
127
+ paddingRight={1}
128
+ justifyContent="space-between"
129
+ {...(isHighlighted ? { backgroundColor: colors.primary } : {})}
130
+ >
131
+ {/* Left: number + marker + label + direction */}
132
+ <text height={1}>
133
+ <span fg={numberColor}>{index + 1} </span>
134
+ <span fg={markerColor}>{isCurrent ? "● " : " "}</span>
135
+ <span fg={textColor}>{field.label}</span>
136
+ {isHighlighted ? (
137
+ <span fg={colors.warning}> {getSortDirectionIndicator(pendingDirection)}</span>
138
+ ) : null}
139
+ </text>
140
+ </box>
141
+ );
142
+ })}
143
+ </box>
144
+
145
+ {/* Footer Hints */}
146
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} marginTop={1}>
147
+ <box flexDirection="row" justifyContent="space-between" height={1}>
148
+ <text fg={colors.textSubtle} height={1}>
149
+ ↑↓ navigate
150
+ </text>
151
+ <text fg={colors.textSubtle} height={1}>
152
+ Space toggle
153
+ </text>
154
+ </box>
155
+ <box flexDirection="row" justifyContent="space-between" height={1}>
156
+ <text fg={colors.textSubtle} height={1}>
157
+ Enter apply
158
+ </text>
159
+ <text fg={colors.textSubtle} height={1}>
160
+ Esc cancel
161
+ </text>
162
+ </box>
163
+ </box>
164
+ </box>
165
+ </ModalBackdrop>
166
+ );
167
+ }
@@ -1,4 +1,5 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "../hooks/useAnimationTick.ts";
2
3
 
3
4
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
5
  const FRAME_INTERVAL = 80;
@@ -9,13 +10,15 @@ interface SpinnerProps {
9
10
 
10
11
  export function Spinner({ color }: SpinnerProps) {
11
12
  const [frameIndex, setFrameIndex] = useState(0);
13
+ const lastAdvanceRef = useRef(Date.now());
12
14
 
13
15
  useEffect(() => {
14
- const interval = setInterval(() => {
15
- setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
16
- }, FRAME_INTERVAL);
17
-
18
- return () => clearInterval(interval);
16
+ return animationTick.subscribe((now) => {
17
+ if (now - lastAdvanceRef.current >= FRAME_INTERVAL) {
18
+ lastAdvanceRef.current = now;
19
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
20
+ }
21
+ });
19
22
  }, []);
20
23
 
21
24
  return <text {...(color ? { fg: color } : {})}>{SPINNER_FRAMES[frameIndex]}</text>;
@@ -23,13 +26,15 @@ export function Spinner({ color }: SpinnerProps) {
23
26
 
24
27
  export function useSpinner(): string {
25
28
  const [frameIndex, setFrameIndex] = useState(0);
29
+ const lastAdvanceRef = useRef(Date.now());
26
30
 
27
31
  useEffect(() => {
28
- const interval = setInterval(() => {
29
- setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
30
- }, FRAME_INTERVAL);
31
-
32
- return () => clearInterval(interval);
32
+ return animationTick.subscribe((now) => {
33
+ if (now - lastAdvanceRef.current >= FRAME_INTERVAL) {
34
+ lastAdvanceRef.current = now;
35
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
36
+ }
37
+ });
33
38
  }, []);
34
39
 
35
40
  return SPINNER_FRAMES[frameIndex] ?? "⠋";
@@ -1,6 +1,6 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
+ import type { ColorSchemePreference, ThemePlugin } from "@tokentop/plugin-sdk";
2
3
  import { useEffect, useMemo, useState } from "react";
3
- import type { ColorSchemePreference, ThemePlugin } from "@/plugins/types/theme.ts";
4
4
  import { useColors } from "../contexts/ThemeContext.tsx";
5
5
 
6
6
  interface ThemePickerProps {
@@ -1,3 +1,4 @@
1
+ import type { AgentPlugin, SessionParseOptions } from "@tokentop/plugin-sdk";
1
2
  import {
2
3
  createContext,
3
4
  type ReactNode,
@@ -7,13 +8,12 @@ import {
7
8
  useRef,
8
9
  useState,
9
10
  } from "react";
10
- import { aggregateSessionUsage } from "@/agents/aggregator.ts";
11
+ import { aggregateSessionUsage, deduplicateAggregates } from "@/agents/aggregator.ts";
11
12
  import { priceSessions } from "@/agents/costing.ts";
12
13
  import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
13
14
  import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
14
15
  import { pluginRegistry } from "@/plugins/registry.ts";
15
16
  import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
16
- import type { AgentPlugin, SessionParseOptions } from "@/plugins/types/agent.ts";
17
17
  import { persistSessions } from "@/storage/persistence-service.ts";
18
18
  import type { PricingSource } from "@/storage/types.ts";
19
19
  import { useDemoMode } from "./DemoModeContext.tsx";
@@ -21,6 +21,10 @@ import { useLogs } from "./LogContext.tsx";
21
21
  import { usePlugins } from "./PluginContext.tsx";
22
22
  import { useTimeWindow } from "./TimeWindowContext.tsx";
23
23
 
24
+ /** Polling interval when at least one session is active. */
25
+ const ACTIVE_INTERVAL_MS = 3_000;
26
+ /** Polling interval when all sessions are idle/historical. */
27
+ const IDLE_INTERVAL_MS = 15_000;
24
28
  interface AgentSessionContextValue {
25
29
  sessions: AgentSessionAggregate[];
26
30
  agents: AgentInfo[];
@@ -35,14 +39,24 @@ const AgentSessionContext = createContext<AgentSessionContextValue | null>(null)
35
39
  interface AgentSessionProviderProps {
36
40
  children: ReactNode;
37
41
  autoRefresh?: boolean;
38
- refreshInterval?: number;
39
42
  }
40
43
 
41
- export function AgentSessionProvider({
42
- children,
43
- autoRefresh = false,
44
- refreshInterval = 30000,
45
- }: AgentSessionProviderProps) {
44
+ /**
45
+ * Compute a lightweight fingerprint of session state.
46
+ * If the fingerprint hasn't changed, we skip setSessions() to avoid
47
+ * unnecessary React re-renders of the entire session list.
48
+ */
49
+ function computeSessionFingerprint(sessions: AgentSessionAggregate[]): string {
50
+ // Build a compact string from the fields that actually affect the UI.
51
+ // This is intentionally simple — no crypto hashing needed, just string comparison.
52
+ let fp = `${sessions.length}:`;
53
+ for (const s of sessions) {
54
+ fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount};`;
55
+ }
56
+ return fp;
57
+ }
58
+
59
+ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSessionProviderProps) {
46
60
  const [sessions, setSessions] = useState<AgentSessionAggregate[]>([]);
47
61
  const [agents, setAgents] = useState<AgentInfo[]>([]);
48
62
  const [isLoading, setIsLoading] = useState(false);
@@ -54,6 +68,7 @@ export function AgentSessionProvider({
54
68
  const { windowMs } = useTimeWindow();
55
69
  const hasBackfilled = useRef(false);
56
70
  const isRefreshingRef = useRef(false);
71
+ const lastFingerprintRef = useRef<string>("");
57
72
 
58
73
  const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
59
74
  const agentPlugins = pluginRegistry.getAll("agent");
@@ -178,10 +193,34 @@ export function AgentSessionProvider({
178
193
  }
179
194
  }
180
195
 
181
- const pricedSessions = await priceSessions(allAggregates);
196
+ // Deduplicate sessions claimed by multiple agents scanning the same directory.
197
+ const dedupedAggregates = deduplicateAggregates(allAggregates);
198
+ if (dedupedAggregates.length < allAggregates.length) {
199
+ debug(
200
+ `Deduplicated sessions: ${allAggregates.length} \u2192 ${dedupedAggregates.length}`,
201
+ undefined,
202
+ "agent-sessions",
203
+ );
204
+ }
205
+
206
+ const pricedSessions = await priceSessions(dedupedAggregates);
182
207
 
183
208
  pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
184
209
 
210
+ // --- Change detection: skip React update if nothing changed ---
211
+ const newFingerprint = computeSessionFingerprint(pricedSessions);
212
+ const changed = newFingerprint !== lastFingerprintRef.current;
213
+ lastFingerprintRef.current = newFingerprint;
214
+
215
+ if (!changed) {
216
+ debug(
217
+ "Session refresh: no changes detected, skipping render",
218
+ undefined,
219
+ "agent-sessions",
220
+ );
221
+ return;
222
+ }
223
+
185
224
  // Persist to storage — the persistence service handles throttling
186
225
  // internally (fingerprint gate + 5-minute per-session throttle).
187
226
  // Safe to call every tick; state lives on globalThis, survives HMR.
@@ -260,18 +299,32 @@ export function AgentSessionProvider({
260
299
  useEffect(() => {
261
300
  if (!autoRefresh) return;
262
301
 
263
- debug(`Setting up session refresh interval: ${refreshInterval}ms`, undefined, "agent-sessions");
302
+ // Adaptive polling: poll faster when sessions are active, slower when idle.
303
+ // The interval is re-evaluated after each tick based on current session state.
304
+ let timerId: ReturnType<typeof setTimeout> | null = null;
264
305
 
265
- const intervalId = setInterval(() => {
266
- debug("Interval tick - refreshing sessions", undefined, "agent-sessions");
267
- refreshSessionsRef.current();
268
- }, refreshInterval);
306
+ const scheduleNext = () => {
307
+ const hasActive = sessions.some((s) => s.status === "active");
308
+ const interval = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
309
+
310
+ timerId = setTimeout(() => {
311
+ debug(
312
+ `Adaptive tick (${hasActive ? "active" : "idle"}, ${interval}ms)`,
313
+ undefined,
314
+ "agent-sessions",
315
+ );
316
+ refreshSessionsRef.current().then(scheduleNext);
317
+ }, interval);
318
+ };
319
+
320
+ scheduleNext();
269
321
 
270
322
  return () => {
271
- debug("Clearing session refresh interval", undefined, "agent-sessions");
272
- clearInterval(intervalId);
323
+ if (timerId !== null) {
324
+ clearTimeout(timerId);
325
+ }
273
326
  };
274
- }, [autoRefresh, refreshInterval, debug]);
327
+ }, [autoRefresh, sessions, debug]);
275
328
 
276
329
  const value: AgentSessionContextValue = {
277
330
  sessions,