@tokentop/ttop 0.3.0 → 0.5.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 (83) hide show
  1. package/package.json +9 -4
  2. package/src/agents/aggregator.ts +8 -1
  3. package/src/agents/types.ts +3 -0
  4. package/src/cli.ts +2 -2
  5. package/src/demo/simulator.ts +1 -1
  6. package/src/plugins/agents/index.ts +2 -0
  7. package/src/plugins/auth-sources.ts +2 -3
  8. package/src/plugins/lifecycle.ts +17 -13
  9. package/src/plugins/loader.test.ts +1 -1
  10. package/src/plugins/loader.ts +15 -7
  11. package/src/plugins/notification-bus.ts +5 -2
  12. package/src/plugins/notifications/terminal-bell.ts +9 -2
  13. package/src/plugins/notifications/visual-flash.ts +13 -3
  14. package/src/plugins/plugin-context-factory.ts +4 -19
  15. package/src/plugins/providers/anthropic.ts +2 -2
  16. package/src/plugins/providers/antigravity.ts +1 -1
  17. package/src/plugins/providers/chutes.ts +189 -0
  18. package/src/plugins/providers/codex.ts +1 -1
  19. package/src/plugins/providers/gemini.ts +1 -1
  20. package/src/plugins/providers/github-copilot.ts +1 -1
  21. package/src/plugins/providers/index.ts +1 -0
  22. package/src/plugins/providers/minimax.ts +1 -1
  23. package/src/plugins/providers/openai-api.ts +1 -1
  24. package/src/plugins/providers/opencode-zen.ts +1 -1
  25. package/src/plugins/providers/perplexity.ts +1 -1
  26. package/src/plugins/providers/zai.ts +4 -2
  27. package/src/plugins/registry.ts +51 -46
  28. package/src/plugins/sandbox-guard.test.ts +1 -1
  29. package/src/plugins/sandbox-guard.ts +1 -1
  30. package/src/plugins/sandbox.ts +1 -1
  31. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  32. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  33. package/src/plugins/themes/claude-code.ts +1 -1
  34. package/src/plugins/themes/dracula.ts +1 -1
  35. package/src/plugins/themes/github-light.ts +1 -1
  36. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  37. package/src/plugins/themes/gruvbox-light.ts +1 -1
  38. package/src/plugins/themes/kanagawa.ts +1 -1
  39. package/src/plugins/themes/nord.ts +1 -1
  40. package/src/plugins/themes/one-dark.ts +1 -1
  41. package/src/plugins/themes/opencode.ts +1 -1
  42. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  43. package/src/plugins/themes/rose-pine.ts +1 -1
  44. package/src/plugins/themes/solarized-light.ts +1 -1
  45. package/src/plugins/themes/tokyo-night.ts +1 -1
  46. package/src/plugins/update-checker.ts +2 -2
  47. package/src/pricing/estimator.test.ts +1 -1
  48. package/src/pricing/estimator.ts +1 -1
  49. package/src/pricing/fallback.ts +1 -1
  50. package/src/pricing/index.ts +1 -1
  51. package/src/pricing/models-dev.ts +1 -1
  52. package/src/storage/index.ts +6 -0
  53. package/src/storage/migrations/index.ts +17 -0
  54. package/src/storage/persistence-service.ts +206 -0
  55. package/src/storage/repos/pluginStorage.ts +35 -0
  56. package/src/storage/retention.ts +146 -0
  57. package/src/tui/App.tsx +1 -1
  58. package/src/tui/components/DebugPanel.tsx +23 -10
  59. package/src/tui/components/ProviderCard.tsx +1 -1
  60. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  61. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  62. package/src/tui/components/SessionsTable.tsx +15 -4
  63. package/src/tui/components/SettingsModal.tsx +383 -76
  64. package/src/tui/components/ThemePicker.tsx +1 -1
  65. package/src/tui/contexts/AgentSessionContext.tsx +44 -80
  66. package/src/tui/contexts/PluginContext.tsx +13 -3
  67. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  68. package/src/tui/contexts/StorageContext.tsx +42 -130
  69. package/src/tui/contexts/ThemeContext.tsx +3 -3
  70. package/src/tui/hooks/useExitAnimation.ts +4 -0
  71. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  72. package/src/tui/views/Dashboard.tsx +40 -3
  73. package/src/tui/views/ProjectsView.tsx +29 -6
  74. package/src/tui/views/RealTimeDashboard.tsx +29 -8
  75. package/src/tui/views/SettingsView.tsx +9 -4
  76. package/src/version.ts +1 -1
  77. package/src/plugins/types/agent.ts +0 -108
  78. package/src/plugins/types/base.ts +0 -278
  79. package/src/plugins/types/index.ts +0 -19
  80. package/src/plugins/types/notification.ts +0 -51
  81. package/src/plugins/types/provider.ts +0 -237
  82. package/src/plugins/types/theme.ts +0 -78
  83. package/src/storage/database.ts +0 -623
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Data retention policy — prevents unbounded database growth.
3
+ *
4
+ * Detailed snapshot data (agent_session_snapshots, agent_session_stream_snapshots,
5
+ * provider_snapshots) is pruned after a retention window. Aggregated data in
6
+ * usage_events, hourly_aggregates, and daily_aggregates is kept longer since
7
+ * those tables grow much more slowly.
8
+ */
9
+
10
+ import { getDatabase, isDatabaseInitialized } from "./db.ts";
11
+
12
+ /** Keep detailed snapshots for 7 days */
13
+ const SNAPSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
14
+
15
+ /** Keep provider snapshots for 7 days */
16
+ const PROVIDER_SNAPSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
17
+
18
+ /** Keep usage events for 90 days */
19
+ const USAGE_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
20
+
21
+ /** Maximum rows to delete in a single batch (prevents long-running transactions) */
22
+ const PRUNE_BATCH_SIZE = 10_000;
23
+
24
+ export interface PruneResult {
25
+ agentSessionSnapshots: number;
26
+ providerSnapshots: number;
27
+ usageEvents: number;
28
+ durationMs: number;
29
+ }
30
+
31
+ /**
32
+ * Prune old data from the database. Deletes in batches to avoid
33
+ * holding a write lock for too long.
34
+ */
35
+ export function pruneOldData(nowMs: number = Date.now()): PruneResult {
36
+ if (!isDatabaseInitialized()) {
37
+ return { agentSessionSnapshots: 0, providerSnapshots: 0, usageEvents: 0, durationMs: 0 };
38
+ }
39
+
40
+ const db = getDatabase();
41
+ const start = performance.now();
42
+
43
+ let agentSessionSnapshots = 0;
44
+ let providerSnapshots = 0;
45
+ let usageEvents = 0;
46
+
47
+ const snapshotCutoff = nowMs - SNAPSHOT_RETENTION_MS;
48
+ const providerCutoff = nowMs - PROVIDER_SNAPSHOT_RETENTION_MS;
49
+ const usageEventCutoff = nowMs - USAGE_EVENT_RETENTION_MS;
50
+
51
+ // Prune agent_session_snapshots in batches.
52
+ // CASCADE delete handles agent_session_stream_snapshots automatically.
53
+ {
54
+ const stmt = db.prepare(`
55
+ DELETE FROM agent_session_snapshots
56
+ WHERE id IN (
57
+ SELECT id FROM agent_session_snapshots
58
+ WHERE timestamp < ?
59
+ LIMIT ?
60
+ )
61
+ `);
62
+
63
+ let deleted: number;
64
+ do {
65
+ const result = stmt.run(snapshotCutoff, PRUNE_BATCH_SIZE);
66
+ deleted = result.changes;
67
+ agentSessionSnapshots += deleted;
68
+ } while (deleted === PRUNE_BATCH_SIZE);
69
+ }
70
+
71
+ // Prune old provider_snapshots in batches
72
+ {
73
+ const stmt = db.prepare(`
74
+ DELETE FROM provider_snapshots
75
+ WHERE id IN (
76
+ SELECT id FROM provider_snapshots
77
+ WHERE timestamp < ?
78
+ LIMIT ?
79
+ )
80
+ `);
81
+
82
+ let deleted: number;
83
+ do {
84
+ const result = stmt.run(providerCutoff, PRUNE_BATCH_SIZE);
85
+ deleted = result.changes;
86
+ providerSnapshots += deleted;
87
+ } while (deleted === PRUNE_BATCH_SIZE);
88
+ }
89
+
90
+ // Prune old usage_events in batches
91
+ {
92
+ const stmt = db.prepare(`
93
+ DELETE FROM usage_events
94
+ WHERE id IN (
95
+ SELECT id FROM usage_events
96
+ WHERE timestamp < ?
97
+ LIMIT ?
98
+ )
99
+ `);
100
+
101
+ let deleted: number;
102
+ do {
103
+ const result = stmt.run(usageEventCutoff, PRUNE_BATCH_SIZE);
104
+ deleted = result.changes;
105
+ usageEvents += deleted;
106
+ } while (deleted === PRUNE_BATCH_SIZE);
107
+ }
108
+
109
+ const durationMs = Math.round(performance.now() - start);
110
+
111
+ return { agentSessionSnapshots, providerSnapshots, usageEvents, durationMs };
112
+ }
113
+
114
+ /**
115
+ * Run incremental auto_vacuum if supported (WAL mode).
116
+ * This reclaims free pages without blocking readers.
117
+ */
118
+ export function incrementalVacuum(pages = 1000): void {
119
+ if (!isDatabaseInitialized()) return;
120
+
121
+ const db = getDatabase();
122
+ try {
123
+ db.exec(`PRAGMA incremental_vacuum(${pages})`);
124
+ } catch {
125
+ // Not all builds support incremental vacuum — silently skip
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Get the database file size in bytes. Returns 0 if unable to determine.
131
+ */
132
+ export function getDatabaseSizeBytes(): number {
133
+ if (!isDatabaseInitialized()) return 0;
134
+
135
+ const db = getDatabase();
136
+ try {
137
+ const row = db.prepare("PRAGMA page_count").get() as { page_count: number } | null;
138
+ const sizeRow = db.prepare("PRAGMA page_size").get() as { page_size: number } | null;
139
+ if (row && sizeRow) {
140
+ return row.page_count * sizeRow.page_size;
141
+ }
142
+ } catch {
143
+ // ignore
144
+ }
145
+ return 0;
146
+ }
package/src/tui/App.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
4
  import * as builtinThemeExports from "@/plugins/themes/index.ts";
4
- import type { ThemePlugin } from "@/plugins/types/theme.ts";
5
5
  import { resolveTheme, ThemeProvider, useColors, useTheme } from "./contexts/ThemeContext.tsx";
6
6
  import {
7
7
  type BurstRecorder,
@@ -9,7 +9,7 @@ import { useColors } from "../contexts/ThemeContext.tsx";
9
9
  import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
10
10
 
11
11
  type DebugTab = "logs" | "inspector" | "plugins";
12
- type PluginSort = "name" | "type" | "official";
12
+ type PluginSort = "name" | "type" | "source";
13
13
 
14
14
  interface DebugInspectorData {
15
15
  sessions: Array<{
@@ -193,7 +193,7 @@ export function DebugPanel({ onClose, inspectorData }: DebugPanelProps) {
193
193
  if (activeTab === "plugins") {
194
194
  if (key.name === "s") {
195
195
  setPluginSort((prev) => {
196
- const order: PluginSort[] = ["name", "type", "official"];
196
+ const order: PluginSort[] = ["name", "type", "source"];
197
197
  return order[(order.indexOf(prev) + 1) % order.length] ?? "name";
198
198
  });
199
199
  return;
@@ -656,12 +656,12 @@ interface PluginsTabProps {
656
656
 
657
657
  const TYPE_ORDER: Record<string, number> = { provider: 0, agent: 1, theme: 2, notification: 3 };
658
658
 
659
- function isOfficialPlugin(pluginId: string, pluginType: string): boolean {
659
+ function getSourcePriority(pluginId: string, pluginType: string): number {
660
660
  const source = pluginRegistry.getSource(
661
661
  pluginType as "provider" | "agent" | "theme" | "notification",
662
662
  pluginId,
663
663
  );
664
- return source === "builtin";
664
+ return source === "local" ? 0 : source === "npm" ? 1 : 2;
665
665
  }
666
666
 
667
667
  function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
@@ -671,10 +671,10 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
671
671
  if (sortBy === "name") {
672
672
  return a.name.localeCompare(b.name);
673
673
  }
674
- if (sortBy === "official") {
675
- const aOfficial = isOfficialPlugin(a.id, a.type) ? 0 : 1;
676
- const bOfficial = isOfficialPlugin(b.id, b.type) ? 0 : 1;
677
- if (aOfficial !== bOfficial) return aOfficial - bOfficial;
674
+ if (sortBy === "source") {
675
+ const aPriority = getSourcePriority(a.id, a.type);
676
+ const bPriority = getSourcePriority(b.id, b.type);
677
+ if (aPriority !== bPriority) return aPriority - bPriority;
678
678
  return a.name.localeCompare(b.name);
679
679
  }
680
680
  const typeA = TYPE_ORDER[a.type] ?? 99;
@@ -733,6 +733,9 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
733
733
  <text width={14} fg={colors.textSubtle}>
734
734
  {padRight("TYPE", 14)}
735
735
  </text>
736
+ <text width={10} fg={colors.textSubtle}>
737
+ {padRight("SOURCE", 10)}
738
+ </text>
736
739
  <text width={14} fg={colors.textSubtle}>
737
740
  {padRight("VERSION", 14)}
738
741
  </text>
@@ -743,7 +746,14 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
743
746
  {allPlugins.map((plugin) => {
744
747
  const key = `${plugin.type}-${plugin.id}`;
745
748
  const updateInfo = updates.get(key);
746
- const official = isOfficialPlugin(plugin.id, plugin.type);
749
+ const source = pluginRegistry.getSource(plugin.type, plugin.id) ?? "builtin";
750
+ const official = pluginRegistry.isOfficial(plugin.type, plugin.id);
751
+
752
+ const sourceColors: Record<string, string> = {
753
+ builtin: colors.textSubtle,
754
+ local: colors.success,
755
+ npm: colors.info,
756
+ };
747
757
 
748
758
  let versionSuffix = "";
749
759
  let versionColor = colors.textMuted;
@@ -758,12 +768,15 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
758
768
  return (
759
769
  <box key={key} flexDirection="row" height={1}>
760
770
  <text width={22}>
761
- {official && <span fg={colors.success}>❖ </span>}
771
+ {official && <span fg={colors.warning}>✦ </span>}
762
772
  <span fg={colors.text}>{padRight(plugin.name, official ? 20 : 22)}</span>
763
773
  </text>
764
774
  <text width={14} fg={typeColors[plugin.type] ?? colors.textMuted}>
765
775
  {padRight(plugin.type, 14)}
766
776
  </text>
777
+ <text width={10} fg={sourceColors[source] ?? colors.textMuted}>
778
+ {padRight(source, 10)}
779
+ </text>
767
780
  <text width={14} fg={versionColor}>
768
781
  {padRight(plugin.version + versionSuffix, 14)}
769
782
  </text>
@@ -1,7 +1,7 @@
1
1
  import type { BoxRenderable } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
+ import type { ProviderUsageData } from "@tokentop/plugin-sdk";
3
4
  import { forwardRef, useCallback, useEffect, useState } from "react";
4
- import type { ProviderUsageData } from "@/plugins/types/provider.ts";
5
5
  import { useColors } from "../contexts/ThemeContext.tsx";
6
6
  import { SkeletonProviderContent } from "./Skeleton.tsx";
7
7
  import { useSpinner } from "./Spinner.tsx";
@@ -1,4 +1,4 @@
1
- import type { UsageLimit } from "@/plugins/types/provider.ts";
1
+ import type { UsageLimit } from "@tokentop/plugin-sdk";
2
2
  import type { ProviderState } from "../contexts/PluginContext.tsx";
3
3
  import { useColors } from "../contexts/ThemeContext.tsx";
4
4
  import { Sparkline } from "./Sparkline.tsx";
@@ -314,6 +314,16 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
314
314
  </text>
315
315
  </box>
316
316
 
317
+ {session.metadata?.isEstimated === true && (
318
+ <box flexDirection="row" height={1} marginBottom={0}>
319
+ <text height={1} overflow="hidden">
320
+ <span fg={colors.warning}>
321
+ ≈ Token data is estimated — server enrichment pending
322
+ </span>
323
+ </text>
324
+ </box>
325
+ )}
326
+
317
327
  {hasCacheData && (
318
328
  <box flexDirection="row" height={1} marginBottom={0}>
319
329
  <text height={1} overflow="hidden">
@@ -154,6 +154,7 @@ const SessionRow = memo(function SessionRow({
154
154
  const effectiveTokens = session.totals.input + session.totals.output;
155
155
  const cacheRead = session.totals.cacheRead ?? 0;
156
156
  const hasCacheData = cacheRead > 0;
157
+ const isEstimated = session.metadata?.isEstimated === true;
157
158
  const costUsd = session.totalCostUsd ?? 0;
158
159
 
159
160
  const animatedTokens = useAnimatedValue(effectiveTokens, { durationMs: 300, precision: 0 });
@@ -260,7 +261,10 @@ const SessionRow = memo(function SessionRow({
260
261
  {String(session.requestCount).padStart(4)}
261
262
  </text>
262
263
  <text width={14} height={1} {...bgProp}>
263
- <span fg={tokenColor}>{formatTokensVal(animatedTokens).padStart(7)}</span>
264
+ <span fg={tokenColor}>
265
+ {isEstimated ? "≈" : " "}
266
+ {formatTokensVal(animatedTokens).padStart(7)}
267
+ </span>
264
268
  <span fg={colors.textMuted}>
265
269
  {hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
266
270
  </span>
@@ -315,7 +319,10 @@ const SessionRow = memo(function SessionRow({
315
319
  : modelWithCount.padEnd(narrowModelMaxChars)}
316
320
  </text>
317
321
  <text width={narrowTokenW} height={1} {...bgProp}>
318
- <span fg={tokenColor}>{formatTokensVal(animatedTokens).padStart(7)}</span>
322
+ <span fg={tokenColor}>
323
+ {isEstimated ? "≈" : " "}
324
+ {formatTokensVal(animatedTokens).padStart(7)}
325
+ </span>
319
326
  <span fg={colors.textMuted}>
320
327
  {hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
321
328
  </span>
@@ -386,6 +393,10 @@ export const SessionsTable = forwardRef(function SessionsTable(
386
393
  getKey: getSessionKey,
387
394
  });
388
395
 
396
+ // Track bulk changes to force scrollbox remount (clears ghost text)
397
+ const bulkCountRef = useRef(0);
398
+ if (isBulkChange) bulkCountRef.current++;
399
+
389
400
  let activeIndex = 0;
390
401
  const selectedSession = sessions[selectedRow] ?? null;
391
402
  const inspector = selectedSession ? getInspectorData(selectedSession) : null;
@@ -489,8 +500,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
489
500
  </box>
490
501
  )}
491
502
 
492
- <scrollbox ref={ref} flexGrow={1}>
493
- <box flexDirection="column">
503
+ <scrollbox key={`sessions-sb-${bulkCountRef.current}`} ref={ref} flexGrow={1}>
504
+ <box flexDirection="column" flexGrow={1}>
494
505
  {sessions.length === 0 && animatedSessions.length === 0 && (
495
506
  <box paddingLeft={2}>
496
507
  <text fg={colors.textMuted}>