@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.
- package/README.md +7 -6
- package/package.json +12 -5
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +44 -2
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +16 -2
- package/src/demo/simulator.ts +2 -2
- package/src/plugins/agents/index.ts +3 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +2 -2
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +1 -1
- package/src/plugins/notifications/visual-flash.ts +1 -1
- package/src/plugins/plugin-context-factory.ts +4 -19
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +21 -2
- package/src/plugins/providers/antigravity.ts +17 -1
- package/src/plugins/providers/chutes.ts +205 -0
- package/src/plugins/providers/codex.ts +17 -1
- package/src/plugins/providers/gemini.ts +17 -1
- package/src/plugins/providers/github-copilot.ts +17 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +17 -1
- package/src/plugins/providers/openai-api.ts +53 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +11 -2
- package/src/plugins/providers/zai.ts +17 -1
- package/src/plugins/registry.ts +9 -9
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +5 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/tui/App.tsx +17 -9
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +39 -23
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +62 -37
- package/src/tui/components/SettingsModal.tsx +1 -1
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +70 -17
- package/src/tui/contexts/PluginContext.tsx +72 -14
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/ThemeContext.tsx +7 -4
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +1 -1
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- 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}>
|
|
264
|
-
|
|
265
|
-
{
|
|
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}>
|
|
319
|
-
|
|
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}
|
|
415
|
-
|
|
416
|
-
{
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
478
|
-
{isTight
|
|
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={
|
|
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={
|
|
485
|
-
{nameMaxWidth >= 7
|
|
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={
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
323
|
+
if (timerId !== null) {
|
|
324
|
+
clearTimeout(timerId);
|
|
325
|
+
}
|
|
273
326
|
};
|
|
274
|
-
}, [autoRefresh,
|
|
327
|
+
}, [autoRefresh, sessions, debug]);
|
|
275
328
|
|
|
276
329
|
const value: AgentSessionContextValue = {
|
|
277
330
|
sessions,
|