@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
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "./useAnimationTick.ts";
2
3
 
3
4
  export interface UseValueFlashOptions {
4
5
  durationMs?: number;
@@ -20,12 +21,9 @@ export function useValueFlash(
20
21
 
21
22
  const prevValueRef = useRef<number>(value);
22
23
  const [isFlashing, setIsFlashing] = useState(false);
23
- const [step, setStep] = useState(0);
24
- const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
- const animationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
26
-
27
- const steps = 18;
28
- const stepDuration = durationMs / steps;
24
+ const [intensity, setIntensity] = useState(0);
25
+ const flashStartRef = useRef(0);
26
+ const unsubRef = useRef<(() => void) | null>(null);
29
27
 
30
28
  useEffect(() => {
31
29
  const prevValue = prevValueRef.current;
@@ -34,45 +32,39 @@ export function useValueFlash(
34
32
  const shouldFlash = increaseOnly ? delta > threshold : Math.abs(delta) > threshold;
35
33
 
36
34
  if (shouldFlash && prevValue !== value) {
37
- if (flashTimeoutRef.current) {
38
- clearTimeout(flashTimeoutRef.current);
39
- }
40
- if (animationIntervalRef.current) {
41
- clearInterval(animationIntervalRef.current);
42
- }
35
+ // Clean up any existing subscription
36
+ unsubRef.current?.();
43
37
 
38
+ flashStartRef.current = Date.now();
44
39
  setIsFlashing(true);
45
- setStep(0);
46
-
47
- let currentStep = 0;
48
- animationIntervalRef.current = setInterval(() => {
49
- currentStep++;
50
- if (currentStep >= steps) {
51
- if (animationIntervalRef.current) {
52
- clearInterval(animationIntervalRef.current);
53
- animationIntervalRef.current = null;
54
- }
40
+
41
+ // Subscribe to global tick
42
+ unsubRef.current = animationTick.subscribe((now) => {
43
+ const elapsed = now - flashStartRef.current;
44
+ const progress = elapsed / durationMs;
45
+
46
+ if (progress >= 1) {
55
47
  setIsFlashing(false);
56
- setStep(0);
48
+ setIntensity(0);
49
+ unsubRef.current?.();
50
+ unsubRef.current = null;
57
51
  } else {
58
- setStep(currentStep);
52
+ const currentIntensity = Math.sin(progress * Math.PI);
53
+ setIntensity(currentIntensity);
59
54
  }
60
- }, stepDuration);
55
+ });
61
56
  }
62
57
 
63
58
  prevValueRef.current = value;
64
59
 
65
60
  return () => {
66
- if (flashTimeoutRef.current) {
67
- clearTimeout(flashTimeoutRef.current);
68
- }
69
- if (animationIntervalRef.current) {
70
- clearInterval(animationIntervalRef.current);
71
- }
61
+ unsubRef.current?.();
62
+ unsubRef.current = null;
72
63
  };
73
- }, [value, durationMs, increaseOnly, threshold, steps, stepDuration]);
64
+ }, [value, durationMs, increaseOnly, threshold]);
74
65
 
75
- const intensity = isFlashing ? Math.sin((step / steps) * Math.PI) : 0;
66
+ // step is kept for backward API compatibility
67
+ const step = isFlashing ? Math.round(intensity * 9) : 0;
76
68
 
77
69
  return { isFlashing, intensity, step };
78
70
  }
package/src/tui/index.tsx CHANGED
@@ -13,6 +13,7 @@ export interface TuiOptions {
13
13
  demoSeed?: number;
14
14
  demoPreset?: DemoPreset;
15
15
  cliPlugins?: string[];
16
+ cliTheme?: string;
16
17
  }
17
18
 
18
19
  const FORCE_EXIT_GRACE_MS = 1_000;
@@ -45,6 +46,9 @@ export async function startTui(options: TuiOptions = {}) {
45
46
  if (options.cliPlugins !== undefined) {
46
47
  appOptions.cliPlugins = options.cliPlugins;
47
48
  }
49
+ if (options.cliTheme !== undefined) {
50
+ appOptions.cliTheme = options.cliTheme;
51
+ }
48
52
 
49
53
  root.render(createAppElement(appOptions));
50
54
 
@@ -0,0 +1,35 @@
1
+ import type { SortFieldDef } from "./sort.ts";
2
+ import { buildShortcutMap } from "./sort.ts";
3
+
4
+ export type ProjectSortField =
5
+ | "project"
6
+ | "cost"
7
+ | "tokens"
8
+ | "sessions"
9
+ | "cache"
10
+ | "cpr"
11
+ | "activity";
12
+
13
+ export const PROJECT_SORT_FIELDS: readonly SortFieldDef<ProjectSortField>[] = [
14
+ { id: "project", label: "Project", shortcutKey: "P", defaultDirection: "asc" },
15
+ { id: "cost", label: "Cost", shortcutKey: "C", defaultDirection: "desc" },
16
+ { id: "tokens", label: "Tokens", shortcutKey: "K", defaultDirection: "desc" },
17
+ { id: "sessions", label: "Sessions", shortcutKey: "S", defaultDirection: "desc" },
18
+ { id: "cache", label: "Cache", shortcutKey: "H", defaultDirection: "desc" },
19
+ { id: "cpr", label: "$/Req", shortcutKey: "R", defaultDirection: "desc" },
20
+ { id: "activity", label: "Activity", shortcutKey: "A", defaultDirection: "desc" },
21
+ ] as const;
22
+
23
+ /** Map from lowercase shortcut key → ProjectSortField */
24
+ export const PROJECT_SHORTCUT_TO_FIELD: ReadonlyMap<string, ProjectSortField> =
25
+ buildShortcutMap(PROJECT_SORT_FIELDS);
26
+
27
+ export function getProjectSortFieldDef(field: ProjectSortField): SortFieldDef<ProjectSortField> {
28
+ const def = PROJECT_SORT_FIELDS.find((f) => f.id === field);
29
+ if (!def) throw new Error(`Unknown project sort field: ${field}`);
30
+ return def;
31
+ }
32
+
33
+ export function getProjectSortFieldLabel(field: ProjectSortField): string {
34
+ return getProjectSortFieldDef(field).label;
35
+ }
@@ -0,0 +1,61 @@
1
+ export type SortDirection = "asc" | "desc";
2
+
3
+ export interface SortFieldDef<F extends string = string> {
4
+ readonly id: F;
5
+ readonly label: string;
6
+ /** Uppercase letter shown in overlay + used as Shift+key shortcut */
7
+ readonly shortcutKey: string;
8
+ /** Default direction when switching to this field */
9
+ readonly defaultDirection: SortDirection;
10
+ }
11
+
12
+ /** Build a lowercase-key → field-id map from any fields array */
13
+ export function buildShortcutMap<F extends string>(
14
+ fields: readonly SortFieldDef<F>[],
15
+ ): ReadonlyMap<string, F> {
16
+ return new Map(fields.map((f) => [f.shortcutKey.toLowerCase(), f.id]));
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Dashboard sort fields
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type SortField =
24
+ | "cost"
25
+ | "tokens"
26
+ | "time"
27
+ | "requests"
28
+ | "duration"
29
+ | "project"
30
+ | "agent"
31
+ | "model"
32
+ | "name";
33
+
34
+ export const SORT_FIELDS: readonly SortFieldDef<SortField>[] = [
35
+ { id: "agent", label: "Agent", shortcutKey: "A", defaultDirection: "desc" },
36
+ { id: "model", label: "Model", shortcutKey: "M", defaultDirection: "desc" },
37
+ { id: "requests", label: "Requests", shortcutKey: "E", defaultDirection: "desc" },
38
+ { id: "tokens", label: "Tokens", shortcutKey: "K", defaultDirection: "desc" },
39
+ { id: "cost", label: "Cost", shortcutKey: "C", defaultDirection: "desc" },
40
+ { id: "project", label: "Project", shortcutKey: "O", defaultDirection: "desc" },
41
+ { id: "name", label: "Name", shortcutKey: "N", defaultDirection: "desc" },
42
+ { id: "duration", label: "Duration", shortcutKey: "U", defaultDirection: "desc" },
43
+ { id: "time", label: "Time", shortcutKey: "T", defaultDirection: "desc" },
44
+ ] as const;
45
+
46
+ /** Map from lowercase shortcut key → SortField (dashboard) */
47
+ export const SHORTCUT_TO_FIELD: ReadonlyMap<string, SortField> = buildShortcutMap(SORT_FIELDS);
48
+
49
+ export function getSortFieldDef(field: SortField): SortFieldDef<SortField> {
50
+ const def = SORT_FIELDS.find((f) => f.id === field);
51
+ if (!def) throw new Error(`Unknown sort field: ${field}`);
52
+ return def;
53
+ }
54
+
55
+ export function getSortFieldLabel(field: SortField): string {
56
+ return getSortFieldDef(field).label;
57
+ }
58
+
59
+ export function getSortDirectionIndicator(dir: SortDirection): string {
60
+ return dir === "desc" ? "▼" : "▲";
61
+ }
@@ -4,16 +4,21 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
4
  import type React from "react";
5
5
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
6
  import type { AgentSessionAggregate } from "../../agents/types.ts";
7
+ import { SortOverlay } from "../components/SortOverlay.tsx";
7
8
  import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
8
9
  import { useInputFocus } from "../contexts/InputContext.tsx";
9
10
  import { useColors } from "../contexts/ThemeContext.tsx";
10
11
  import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
12
+ import type { ProjectSortField } from "../types/projectSort.ts";
13
+ import { getProjectSortFieldLabel, PROJECT_SORT_FIELDS } from "../types/projectSort.ts";
14
+ import type { SortDirection } from "../types/sort.ts";
15
+ import { getSortDirectionIndicator } from "../types/sort.ts";
11
16
 
12
17
  // ---------------------------------------------------------------------------
13
18
  // Types
14
19
  // ---------------------------------------------------------------------------
15
20
 
16
- type SortField = "cost" | "tokens" | "sessions" | "cache" | "cpr" | "activity";
21
+ // ProjectSortField imported from ../types/projectSort.ts
17
22
 
18
23
  interface ModelStats {
19
24
  modelId: string;
@@ -264,50 +269,48 @@ function aggregateProjects(
264
269
  }
265
270
 
266
271
  // ---------------------------------------------------------------------------
267
- // Sort fields cycle
272
+ // Sorting
268
273
  // ---------------------------------------------------------------------------
269
274
 
270
- const SORT_FIELDS: SortField[] = ["cost", "tokens", "sessions", "cache", "cpr", "activity"];
271
-
272
- const SORT_LABELS: Record<SortField, string> = {
273
- cost: "COST",
274
- tokens: "TOKENS",
275
- sessions: "SESSIONS",
276
- cache: "CACHE",
277
- cpr: "$/REQ",
278
- activity: "ACTIVITY",
279
- };
280
-
281
275
  function sortProjects(
282
276
  projects: ProjectStats[],
283
- field: SortField,
284
- _totalCost?: number,
277
+ field: ProjectSortField,
278
+ direction: SortDirection,
285
279
  ): ProjectStats[] {
286
280
  return [...projects].sort((a, b) => {
281
+ let cmp = 0;
287
282
  switch (field) {
288
283
  case "cost":
289
- return b.cost - a.cost;
284
+ cmp = a.cost - b.cost;
285
+ break;
290
286
  case "tokens":
291
- return b.tokens - a.tokens;
287
+ cmp = a.tokens - b.tokens;
288
+ break;
292
289
  case "sessions":
293
- return b.sessionCount - a.sessionCount;
290
+ cmp = a.sessionCount - b.sessionCount;
291
+ break;
294
292
  case "cache": {
295
293
  const cacheA =
296
294
  a.inputTokens + a.cacheRead > 0 ? a.cacheRead / (a.inputTokens + a.cacheRead) : 0;
297
295
  const cacheB =
298
296
  b.inputTokens + b.cacheRead > 0 ? b.cacheRead / (b.inputTokens + b.cacheRead) : 0;
299
- return cacheB - cacheA;
297
+ cmp = cacheA - cacheB;
298
+ break;
300
299
  }
301
300
  case "cpr": {
302
301
  const cprA = a.requestCount > 0 ? a.cost / a.requestCount : 0;
303
302
  const cprB = b.requestCount > 0 ? b.cost / b.requestCount : 0;
304
- return cprB - cprA;
303
+ cmp = cprA - cprB;
304
+ break;
305
305
  }
306
306
  case "activity":
307
- return b.activeSessions - a.activeSessions;
308
- default:
309
- return 0;
307
+ cmp = a.activeSessions - b.activeSessions;
308
+ break;
309
+ case "project":
310
+ cmp = a.name.localeCompare(b.name);
311
+ break;
310
312
  }
313
+ return direction === "desc" ? -cmp : cmp;
311
314
  });
312
315
  }
313
316
 
@@ -360,7 +363,8 @@ interface AggregateHeaderProps {
360
363
  projectCount: number;
361
364
  sessionCount: number;
362
365
  windowLabel: string;
363
- sortField: SortField;
366
+ sortField: ProjectSortField;
367
+ sortDirection: SortDirection;
364
368
  }
365
369
 
366
370
  function AggregateHeader({
@@ -370,6 +374,7 @@ function AggregateHeader({
370
374
  sessionCount,
371
375
  windowLabel,
372
376
  sortField,
377
+ sortDirection,
373
378
  }: AggregateHeaderProps) {
374
379
  const colors = useColors();
375
380
  const avgPerProject = projectCount > 0 ? totalCost / projectCount : 0;
@@ -393,7 +398,10 @@ function AggregateHeader({
393
398
  <span fg={colors.text}>{formatCost(avgPerProject)}</span>
394
399
  </text>
395
400
  <text height={1} fg={colors.textMuted}>
396
- [{windowLabel}] Sort: <span fg={colors.primary}>{SORT_LABELS[sortField]}</span>
401
+ [{windowLabel}] Sort:{" "}
402
+ <span fg={colors.primary}>
403
+ {getProjectSortFieldLabel(sortField)} {getSortDirectionIndicator(sortDirection)}
404
+ </span>
397
405
  </text>
398
406
  </box>
399
407
  );
@@ -1195,7 +1203,9 @@ export function ProjectsView() {
1195
1203
  const { setInputFocused } = useInputFocus();
1196
1204
 
1197
1205
  const [selectedIndex, setSelectedIndex] = useState(0);
1198
- const [sortField, setSortField] = useState<SortField>("cost");
1206
+ const [sortField, setSortField] = useState<ProjectSortField>("cost");
1207
+ const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
1208
+ const [showSortOverlay, setShowSortOverlay] = useState(false);
1199
1209
  const [showInsights, setShowInsights] = useState(termHeight >= 35);
1200
1210
  const [drawerProject, setDrawerProject] = useState<ProjectStats | null>(null);
1201
1211
  const [filterQuery, setFilterQuery] = useState("");
@@ -1246,13 +1256,44 @@ export function ProjectsView() {
1246
1256
 
1247
1257
  // Sort
1248
1258
  const sortedProjects = useMemo(
1249
- () => sortProjects(filteredProjects, sortField, totalCost),
1250
- [filteredProjects, sortField, totalCost],
1259
+ () => sortProjects(filteredProjects, sortField, sortDirection),
1260
+ [filteredProjects, sortField, sortDirection],
1251
1261
  );
1252
1262
 
1253
1263
  // Visible columns
1254
1264
  const visibleColumns: ColumnSpec[] = useMemo(() => getVisibleColumns(termWidth), [termWidth]);
1255
1265
 
1266
+ // Map column keys → sort field IDs so we can hide sort fields for hidden columns.
1267
+ // "tokens" has no dedicated column but is always available (related to cost data).
1268
+ const COLUMN_TO_SORT_FIELD: Record<string, ProjectSortField> = {
1269
+ project: "project",
1270
+ cost: "cost",
1271
+ sess: "sessions",
1272
+ cache: "cache",
1273
+ cpr: "cpr",
1274
+ activity: "activity",
1275
+ };
1276
+
1277
+ // Sort indicator helpers for column headers (matches SessionsTable pattern)
1278
+ const sortIndicatorForCol = (colKey: string) => {
1279
+ const field = COLUMN_TO_SORT_FIELD[colKey];
1280
+ return field && sortField === field ? (sortDirection === "desc" ? "▼" : "▲") : " ";
1281
+ };
1282
+ const sortColorForCol = (colKey: string) => {
1283
+ const field = COLUMN_TO_SORT_FIELD[colKey];
1284
+ return field && sortField === field ? colors.primary : colors.textMuted;
1285
+ };
1286
+ const visibleSortFields = useMemo(() => {
1287
+ const visibleFieldIds = new Set<ProjectSortField>(
1288
+ visibleColumns
1289
+ .map((c) => COLUMN_TO_SORT_FIELD[c.key])
1290
+ .filter((id): id is ProjectSortField => id !== undefined),
1291
+ );
1292
+ // Always include tokens — no dedicated column but universally useful
1293
+ visibleFieldIds.add("tokens");
1294
+ return PROJECT_SORT_FIELDS.filter((f) => visibleFieldIds.has(f.id));
1295
+ }, [visibleColumns]);
1296
+
1256
1297
  // Stabilize selection across re-sorts: track selected project by path
1257
1298
  // so the highlight follows the same project when live data causes reordering.
1258
1299
  useLayoutEffect(() => {
@@ -1336,6 +1377,9 @@ export function ProjectsView() {
1336
1377
  useKeyboard(
1337
1378
  useCallback(
1338
1379
  (key: { name: string; shift?: boolean; ctrl?: boolean }) => {
1380
+ // Sort overlay is open — let SortOverlay handle keys
1381
+ if (showSortOverlay) return;
1382
+
1339
1383
  // Drawer open: only Esc closes
1340
1384
  if (drawerProject) {
1341
1385
  if (key.name === "escape") {
@@ -1355,19 +1399,18 @@ export function ProjectsView() {
1355
1399
  return;
1356
1400
  }
1357
1401
 
1402
+ // Open sort overlay menu
1403
+ if (key.name === "s") {
1404
+ setShowSortOverlay(true);
1405
+ return;
1406
+ }
1407
+
1358
1408
  // Navigation
1359
1409
  if (key.name === "down" || key.name === "j") {
1360
1410
  setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
1361
1411
  } else if (key.name === "up" || key.name === "k") {
1362
1412
  setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
1363
1413
  }
1364
- // Sort cycling
1365
- else if (key.name === "s") {
1366
- setSortField((prev: SortField) => {
1367
- const idx = SORT_FIELDS.indexOf(prev);
1368
- return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
1369
- });
1370
- }
1371
1414
  // Time window
1372
1415
  else if (key.name === "t") {
1373
1416
  cycleWindow();
@@ -1401,7 +1444,15 @@ export function ProjectsView() {
1401
1444
  scrollOffsetRef.current = 0;
1402
1445
  }
1403
1446
  },
1404
- [drawerProject, isFiltering, sortedProjects, selectedIndex, cycleWindow, filterQuery],
1447
+ [
1448
+ drawerProject,
1449
+ isFiltering,
1450
+ sortedProjects,
1451
+ selectedIndex,
1452
+ cycleWindow,
1453
+ filterQuery,
1454
+ showSortOverlay,
1455
+ ],
1405
1456
  ),
1406
1457
  );
1407
1458
 
@@ -1441,6 +1492,20 @@ export function ProjectsView() {
1441
1492
  />
1442
1493
  )}
1443
1494
 
1495
+ {/* Sort overlay */}
1496
+ {showSortOverlay && (
1497
+ <SortOverlay
1498
+ fields={visibleSortFields}
1499
+ currentField={sortField}
1500
+ currentDirection={sortDirection}
1501
+ onSelect={(field, direction) => {
1502
+ setSortField(() => field);
1503
+ setSortDirection(() => direction);
1504
+ setShowSortOverlay(false);
1505
+ }}
1506
+ onClose={() => setShowSortOverlay(false)}
1507
+ />
1508
+ )}
1444
1509
  {/* Aggregate header */}
1445
1510
  <AggregateHeader
1446
1511
  totalCost={visibleCost}
@@ -1449,6 +1514,7 @@ export function ProjectsView() {
1449
1514
  sessionCount={visibleSessions}
1450
1515
  windowLabel={windowLabel}
1451
1516
  sortField={sortField}
1517
+ sortDirection={sortDirection}
1452
1518
  />
1453
1519
 
1454
1520
  {/* Column headers */}
@@ -1458,8 +1524,8 @@ export function ProjectsView() {
1458
1524
  </text>
1459
1525
  {visibleColumns.map((col: ColumnSpec) => (
1460
1526
  <box key={col.key} marginRight={1}>
1461
- <text width={col.width} height={1} fg={colors.textMuted}>
1462
- {padRight(col.label, col.width)}
1527
+ <text width={col.width} height={1} fg={sortColorForCol(col.key)}>
1528
+ {padRight(`${col.label}${sortIndicatorForCol(col.key)}`, col.width)}
1463
1529
  </text>
1464
1530
  </box>
1465
1531
  ))}
@@ -2,11 +2,13 @@ import type { ScrollBoxRenderable } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { notificationBus } from "@/plugins/notification-bus.ts";
5
+ import { classifyProviderError } from "@/utils/error-category.ts";
5
6
  import { HelpOverlay } from "../components/HelpOverlay.tsx";
6
7
  import { KpiStrip } from "../components/KpiStrip.tsx";
7
8
  import { ProviderLimitsPanel } from "../components/ProviderLimitsPanel.tsx";
8
9
  import { SessionsTable } from "../components/SessionsTable.tsx";
9
10
  import { type DriverDimension, getSidebarMode, SmartSidebar } from "../components/SmartSidebar.tsx";
11
+ import { SortOverlay } from "../components/SortOverlay.tsx";
10
12
  import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
11
13
  import { useConfig } from "../contexts/ConfigContext.tsx";
12
14
  import { useDashboardRuntime } from "../contexts/DashboardRuntimeContext.tsx";
@@ -15,6 +17,8 @@ import { usePlugins } from "../contexts/PluginContext.tsx";
15
17
  import { useColors } from "../contexts/ThemeContext.tsx";
16
18
  import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
17
19
  import { useDashboardKeyboard } from "../hooks/useDashboardKeyboard.ts";
20
+ import type { SortDirection, SortField } from "../types/sort.ts";
21
+ import { SORT_FIELDS } from "../types/sort.ts";
18
22
  import { getProviderColor } from "../utils/providerColor.ts";
19
23
 
20
24
  export function RealTimeDashboard() {
@@ -30,6 +34,23 @@ export function RealTimeDashboard() {
30
34
  const showLargeHeader = terminalHeight >= 35;
31
35
  const showProviderLimitsPanel = terminalHeight >= 24;
32
36
 
37
+ // SessionsTable hides REQ, NAME, DUR, TIME columns in narrow mode (<140).
38
+ // Filter sort fields to match visible columns.
39
+ const SESSIONS_WIDE_THRESHOLD = 140;
40
+ const NARROW_HIDDEN_FIELDS: ReadonlySet<SortField> = new Set([
41
+ "requests",
42
+ "name",
43
+ "duration",
44
+ "time",
45
+ ]);
46
+ const visibleSortFields = useMemo(
47
+ () =>
48
+ terminalWidth >= SESSIONS_WIDE_THRESHOLD
49
+ ? SORT_FIELDS
50
+ : SORT_FIELDS.filter((f) => !NARROW_HIDDEN_FIELDS.has(f.id)),
51
+ [terminalWidth],
52
+ );
53
+
33
54
  // Visible-row count for scroll tracking — every non-session-data line must be counted.
34
55
  const visibleRows = (() => {
35
56
  const appChrome = (showLargeHeader ? 7 : 1) + 1; // App.tsx: Header + StatusBar
@@ -61,7 +82,9 @@ export function RealTimeDashboard() {
61
82
  const [sidebarCollapsed, setSidebarCollapsed] = useState(config.display.sidebarCollapsed);
62
83
  const [filterQuery, setFilterQuery] = useState("");
63
84
  const [isFiltering, setIsFiltering] = useState(false);
64
- const [sortField, setSortField] = useState<"cost" | "tokens" | "time">("cost");
85
+ const [sortField, setSortField] = useState<SortField>("time");
86
+ const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
87
+ const [showSortOverlay, setShowSortOverlay] = useState(false);
65
88
  const [pendingG, setPendingG] = useState(false);
66
89
  const scrollOffsetRef = useRef(0);
67
90
  const selectedSessionIdRef = useRef<string | null>(null);
@@ -184,14 +207,53 @@ export function RealTimeDashboard() {
184
207
  const bActive = b.status === "active" ? 1 : 0;
185
208
  if (bActive !== aActive) return bActive - aActive;
186
209
 
187
- if (sortField === "cost") return (b.totalCostUsd ?? 0) - (a.totalCostUsd ?? 0);
188
- if (sortField === "tokens")
189
- return b.totals.input + b.totals.output - (a.totals.input + a.totals.output);
190
- return b.lastActivityAt - a.lastActivityAt;
210
+ let cmp = 0;
211
+ switch (sortField) {
212
+ case "cost":
213
+ cmp = (a.totalCostUsd ?? 0) - (b.totalCostUsd ?? 0);
214
+ break;
215
+ case "tokens":
216
+ cmp = a.totals.input + a.totals.output - (b.totals.input + b.totals.output);
217
+ break;
218
+ case "time":
219
+ cmp = a.lastActivityAt - b.lastActivityAt;
220
+ break;
221
+ case "requests":
222
+ cmp = a.requestCount - b.requestCount;
223
+ break;
224
+ case "duration": {
225
+ const aDur = (a.endedAt ?? Date.now()) - a.startedAt;
226
+ const bDur = (b.endedAt ?? Date.now()) - b.startedAt;
227
+ cmp = aDur - bDur;
228
+ break;
229
+ }
230
+ case "project": {
231
+ const aProj = a.projectPath?.split("/").pop() ?? "";
232
+ const bProj = b.projectPath?.split("/").pop() ?? "";
233
+ cmp = aProj.localeCompare(bProj);
234
+ break;
235
+ }
236
+ case "agent":
237
+ cmp = a.agentName.localeCompare(b.agentName);
238
+ break;
239
+ case "model": {
240
+ const aModel = a.streams[0]?.modelId ?? "";
241
+ const bModel = b.streams[0]?.modelId ?? "";
242
+ cmp = aModel.localeCompare(bModel);
243
+ break;
244
+ }
245
+ case "name": {
246
+ const aName = a.sessionName ?? "";
247
+ const bName = b.sessionName ?? "";
248
+ cmp = aName.localeCompare(bName);
249
+ break;
250
+ }
251
+ }
252
+ return sortDirection === "desc" ? -cmp : cmp;
191
253
  });
192
254
 
193
255
  return result;
194
- }, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField]);
256
+ }, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField, sortDirection]);
195
257
 
196
258
  // Stabilize selection across re-sorts: track selected session by ID
197
259
  // so the highlight follows the same session when live data causes reordering.
@@ -261,6 +323,7 @@ export function RealTimeDashboard() {
261
323
  filterQuery,
262
324
  isFiltering,
263
325
  sortField,
326
+ sortDirection,
264
327
  pendingG,
265
328
  scrollOffset: scrollOffsetRef.current,
266
329
  limitSelectedIndex,
@@ -269,6 +332,7 @@ export function RealTimeDashboard() {
269
332
  selectedDriverIndex,
270
333
  activeDriverFilter,
271
334
  sidebarMode,
335
+ showSortOverlay,
272
336
  },
273
337
  actions: {
274
338
  setShowHelp,
@@ -280,12 +344,14 @@ export function RealTimeDashboard() {
280
344
  setFilterQuery,
281
345
  setIsFiltering,
282
346
  setSortField,
347
+ setSortDirection,
283
348
  setPendingG,
284
349
  setScrollOffset,
285
350
  setLimitSelectedIndex,
286
351
  setDriverDimension,
287
352
  setSelectedDriverIndex,
288
353
  setActiveDriverFilter,
354
+ setShowSortOverlay,
289
355
  },
290
356
  processedSessions,
291
357
  });
@@ -348,6 +414,19 @@ export function RealTimeDashboard() {
348
414
  return (
349
415
  <box flexDirection="column" flexGrow={1} padding={1} gap={1} overflow="hidden">
350
416
  {showHelp && <HelpOverlay />}
417
+ {showSortOverlay && (
418
+ <SortOverlay
419
+ fields={visibleSortFields}
420
+ currentField={sortField}
421
+ currentDirection={sortDirection}
422
+ onSelect={(field, direction) => {
423
+ setSortField(() => field);
424
+ setSortDirection(() => direction);
425
+ setShowSortOverlay(false);
426
+ }}
427
+ onClose={() => setShowSortOverlay(false)}
428
+ />
429
+ )}
351
430
 
352
431
  <KpiStrip
353
432
  totalCost={windowedKpis.cost}
@@ -382,7 +461,9 @@ export function RealTimeDashboard() {
382
461
  name: p.plugin.name,
383
462
  usedPercent: getMaxUsedPercent(p),
384
463
  color: providerColorOf(p.plugin.id),
385
- ...(p.usage?.error ? { error: p.usage.error } : {}),
464
+ ...(p.usage?.error
465
+ ? { error: p.usage.error, errorCategory: classifyProviderError(p.usage.error) }
466
+ : {}),
386
467
  }))}
387
468
  focused={focusedPanel === "limits"}
388
469
  selectedIndex={limitSelectedIndex}
@@ -416,6 +497,8 @@ export function RealTimeDashboard() {
416
497
  focusedPanel={focusedPanel}
417
498
  windowLabel={windowLabel}
418
499
  getProviderColor={providerColorOf}
500
+ sortField={sortField}
501
+ sortDirection={sortDirection}
419
502
  />
420
503
 
421
504
  {!effectiveSidebarCollapsed && (
@@ -440,7 +523,7 @@ export function RealTimeDashboard() {
440
523
  : activeDriverFilter
441
524
  ? `Filter: ${activeDriverFilter} Esc clear`
442
525
  : focusedPanel === "sessions"
443
- ? "/ filter ↑↓ navigate Enter details s sort l limits Tab next ⇧Tab prev"
526
+ ? "/ filter ↑↓ navigate Enter details s sort l limits Tab next"
444
527
  : focusedPanel === "limits"
445
528
  ? "←→ select provider Tab next ⇧Tab prev Esc back"
446
529
  : focusedPanel === "sidebar"
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
+ import type { ConfigField } from "@tokentop/plugin-sdk";
2
3
  import { useCallback, useMemo, useState } from "react";
3
4
  import type { AppConfig } from "@/config/schema.ts";
4
- import type { ConfigField } from "@/plugins/types/base.ts";
5
5
  import { useConfig } from "../contexts/ConfigContext.tsx";
6
6
  import { useDemoMode } from "../contexts/DemoModeContext.tsx";
7
7
  import { usePlugins } from "../contexts/PluginContext.tsx";