@tokentop/ttop 0.5.0 → 0.6.1

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 (60) hide show
  1. package/README.md +38 -15
  2. package/package.json +8 -6
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +40 -5
  5. package/src/cli.ts +14 -0
  6. package/src/config/schema.ts +1 -1
  7. package/src/demo/simulator.ts +1 -1
  8. package/src/plugins/agents/index.ts +1 -0
  9. package/src/plugins/lifecycle.ts +1 -1
  10. package/src/plugins/notification-bus.ts +27 -0
  11. package/src/plugins/notifications/terminal-bell.ts +80 -16
  12. package/src/plugins/notifications/visual-flash.ts +5 -18
  13. package/src/plugins/plugin-host.test.ts +197 -0
  14. package/src/plugins/plugin-host.ts +45 -1
  15. package/src/plugins/providers/anthropic.test.ts +202 -0
  16. package/src/plugins/providers/anthropic.ts +19 -0
  17. package/src/plugins/providers/antigravity.ts +16 -0
  18. package/src/plugins/providers/chutes.ts +16 -0
  19. package/src/plugins/providers/codex.ts +16 -0
  20. package/src/plugins/providers/gemini.ts +16 -0
  21. package/src/plugins/providers/github-copilot.ts +16 -0
  22. package/src/plugins/providers/minimax.ts +16 -0
  23. package/src/plugins/providers/openai-api.ts +52 -0
  24. package/src/plugins/providers/perplexity.ts +10 -1
  25. package/src/plugins/providers/zai.ts +16 -0
  26. package/src/pricing/fallback.ts +4 -0
  27. package/src/tui/App.tsx +22 -8
  28. package/src/tui/components/HelpOverlay.tsx +1 -1
  29. package/src/tui/components/LimitGauge.tsx +12 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/ProviderCard.tsx +38 -22
  32. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  33. package/src/tui/components/SessionsTable.tsx +54 -36
  34. package/src/tui/components/SettingsModal.tsx +20 -4
  35. package/src/tui/components/Skeleton.tsx +9 -6
  36. package/src/tui/components/SortOverlay.tsx +167 -0
  37. package/src/tui/components/Spinner.tsx +16 -11
  38. package/src/tui/components/Toast.tsx +1 -1
  39. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  40. package/src/tui/contexts/PluginContext.tsx +72 -10
  41. package/src/tui/contexts/ThemeContext.tsx +4 -1
  42. package/src/tui/createApp.tsx +2 -0
  43. package/src/tui/hooks/index.ts +1 -0
  44. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  45. package/src/tui/hooks/useAnimationTick.ts +93 -0
  46. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  47. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  48. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  49. package/src/tui/hooks/usePulse.ts +13 -7
  50. package/src/tui/hooks/useValueFlash.ts +25 -33
  51. package/src/tui/index.tsx +4 -0
  52. package/src/tui/types/projectSort.ts +35 -0
  53. package/src/tui/types/sort.ts +61 -0
  54. package/src/tui/views/ProjectsView.tsx +104 -38
  55. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  56. package/src/tui/views/SettingsView.tsx +26 -8
  57. package/src/utils/__tests__/error-category.test.ts +147 -0
  58. package/src/utils/currency.ts +4 -2
  59. package/src/utils/error-category.ts +91 -0
  60. package/src/version.ts +1 -1
@@ -1,10 +1,12 @@
1
1
  import type { BoxRenderable } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
3
  import type { ProviderUsageData } from "@tokentop/plugin-sdk";
4
- import { forwardRef, useCallback, useEffect, useState } from "react";
4
+ import { forwardRef, useCallback } from "react";
5
+ import { classifyProviderError, getErrorCategoryDisplay } from "@/utils/error-category.ts";
5
6
  import { useColors } from "../contexts/ThemeContext.tsx";
7
+ import { usePulse } from "../hooks/usePulse.ts";
6
8
  import { SkeletonProviderContent } from "./Skeleton.tsx";
7
- import { useSpinner } from "./Spinner.tsx";
9
+ import { Spinner } from "./Spinner.tsx";
8
10
  import { UsageGauge } from "./UsageGauge.tsx";
9
11
 
10
12
  interface CompactGaugeProps {
@@ -60,9 +62,11 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
60
62
  const colors = useColors();
61
63
  const { width: termWidth } = useTerminalDimensions();
62
64
  const providerColor = color ?? colors.primary;
63
- const spinnerFrame = useSpinner();
64
-
65
- const [pulseStep, setPulseStep] = useState(0);
65
+ const pulseStep = usePulse({
66
+ enabled: usage?.limitReached ?? false,
67
+ intervalMs: 150,
68
+ steps: 12,
69
+ });
66
70
 
67
71
  const rawItems = usage?.limits?.items;
68
72
  const hasItems = rawItems && rawItems.length > 0;
@@ -78,36 +82,34 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
78
82
  sortedItems.length > maxVisibleGauges ? sortedItems.slice(0, maxVisibleGauges) : sortedItems;
79
83
  const useCompactMode = visibleItems.length > 3;
80
84
 
81
- useEffect(() => {
82
- if (!usage?.limitReached) {
83
- setPulseStep(0);
84
- return;
85
- }
86
-
87
- const timer = setInterval(() => {
88
- setPulseStep((p) => (p + 1) % 12);
89
- }, 150);
90
-
91
- return () => clearInterval(timer);
92
- }, [usage?.limitReached]);
93
-
94
85
  const handleClick = useCallback(() => {
95
86
  if (onFocus) {
96
87
  onFocus();
97
88
  }
98
89
  }, [onFocus]);
99
90
 
91
+ const errorCategory = usage?.error ? classifyProviderError(usage.error) : undefined;
92
+ const errorDisplay = errorCategory ? getErrorCategoryDisplay(errorCategory) : undefined;
93
+
100
94
  const statusColor = !configured
101
95
  ? colors.textSubtle
102
96
  : loading
103
97
  ? colors.info
104
98
  : usage?.error
105
- ? colors.error
99
+ ? errorDisplay?.isWarning
100
+ ? colors.warning
101
+ : colors.error
106
102
  : usage?.limitReached
107
103
  ? colors.warning
108
104
  : colors.success;
109
105
 
110
- const statusIcon = !configured ? "○" : usage?.error ? "✗" : usage?.limitReached ? "!" : "●";
106
+ const statusIcon = !configured
107
+ ? "○"
108
+ : usage?.error
109
+ ? (errorDisplay?.icon ?? "✗")
110
+ : usage?.limitReached
111
+ ? "!"
112
+ : "●";
111
113
 
112
114
  const isInitialLoad = loading && !usage;
113
115
 
@@ -154,7 +156,11 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
154
156
  <strong>{name}</strong>
155
157
  </span>
156
158
  </text>
157
- <text fg={statusColor}>{configured && loading ? spinnerFrame : statusIcon}</text>
159
+ {configured && loading ? (
160
+ <Spinner color={statusColor} />
161
+ ) : (
162
+ <text fg={statusColor}>{statusIcon}</text>
163
+ )}
158
164
  </box>
159
165
 
160
166
  {!configured && <text fg={colors.textSubtle}>Not configured</text>}
@@ -162,7 +168,17 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
162
168
  {configured && isInitialLoad && <SkeletonProviderContent />}
163
169
 
164
170
  {configured && !isInitialLoad && usage?.error && (
165
- <text fg={colors.error}>{usage.error}</text>
171
+ <box flexDirection="column" gap={0}>
172
+ <text fg={errorDisplay?.isWarning ? colors.warning : colors.error}>
173
+ <span>
174
+ <bold>{errorDisplay?.label ?? "ERR"}</bold>
175
+ </span>
176
+ <span fg={colors.textMuted}> — </span>
177
+ <span fg={errorDisplay?.isWarning ? colors.warning : colors.error}>
178
+ {usage.error}
179
+ </span>
180
+ </text>
181
+ </box>
166
182
  )}
167
183
 
168
184
  {configured && !isInitialLoad && usage && !usage.error && (
@@ -1,5 +1,6 @@
1
1
  import { useTerminalDimensions } from "@opentui/react";
2
2
  import { useMemo } from "react";
3
+ import type { ProviderErrorCategory } from "@/utils/error-category.ts";
3
4
  import { useConfig } from "../contexts/ConfigContext.tsx";
4
5
  import { useColors } from "../contexts/ThemeContext.tsx";
5
6
  import { usePulse } from "../hooks/usePulse.ts";
@@ -34,6 +35,7 @@ interface ProviderData {
34
35
  usedPercent: number;
35
36
  color: string;
36
37
  error?: string;
38
+ errorCategory?: ProviderErrorCategory;
37
39
  resetTime?: string;
38
40
  used?: number;
39
41
  limit?: number;
@@ -186,6 +188,7 @@ export function ProviderLimitsPanel({
186
188
  usedPercent={p.usedPercent}
187
189
  color={p.color}
188
190
  {...(p.error ? { error: p.error } : {})}
191
+ {...(p.errorCategory ? { errorCategory: p.errorCategory } : {})}
189
192
  compact={true}
190
193
  selected={focused && startIndex + idx === safeSelectedIndex}
191
194
  warningThreshold={warningThreshold}
@@ -269,6 +272,7 @@ export function ProviderLimitsPanel({
269
272
  usedPercent={p.usedPercent}
270
273
  color={p.color}
271
274
  {...(p.error ? { error: p.error } : {})}
275
+ {...(p.errorCategory ? { errorCategory: p.errorCategory } : {})}
272
276
  {...(p.resetTime ? { resetTime: p.resetTime } : {})}
273
277
  labelWidth={14}
274
278
  barWidth={12}
@@ -326,6 +330,7 @@ export function ProviderLimitsPanel({
326
330
  usedPercent={p.usedPercent}
327
331
  color={p.color}
328
332
  {...(p.error ? { error: p.error } : {})}
333
+ {...(p.errorCategory ? { errorCategory: p.errorCategory } : {})}
329
334
  {...(p.resetTime ? { resetTime: p.resetTime } : {})}
330
335
  labelWidth={12}
331
336
  barWidth={10}
@@ -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 {
@@ -265,9 +269,7 @@ const SessionRow = memo(function SessionRow({
265
269
  {isEstimated ? "≈" : " "}
266
270
  {formatTokensVal(animatedTokens).padStart(7)}
267
271
  </span>
268
- <span fg={colors.textMuted}>
269
- {hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
270
- </span>
272
+ <span fg={textMutedColor}>{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}</span>
271
273
  </text>
272
274
  <text width={8} height={1} fg={costColor} {...bgProp}>
273
275
  {costDisplay.padStart(7)}
@@ -323,7 +325,7 @@ const SessionRow = memo(function SessionRow({
323
325
  {isEstimated ? "≈" : " "}
324
326
  {formatTokensVal(animatedTokens).padStart(7)}
325
327
  </span>
326
- <span fg={colors.textMuted}>
328
+ <span fg={textMutedColor}>
327
329
  {hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
328
330
  </span>
329
331
  </text>
@@ -355,6 +357,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
355
357
  focusedPanel,
356
358
  windowLabel,
357
359
  getProviderColor,
360
+ sortField,
361
+ sortDirection,
358
362
  }: SessionsTableProps,
359
363
  ref: Ref<ScrollBoxRenderable>,
360
364
  ) {
@@ -391,16 +395,18 @@ export const SessionsTable = forwardRef(function SessionsTable(
391
395
  const { items: animatedSessions, isBulkChange } = useExitAnimation(sessions, {
392
396
  durationMs: 500,
393
397
  getKey: getSessionKey,
398
+ bulkThreshold: 100,
394
399
  });
395
400
 
396
- // Track bulk changes to force scrollbox remount (clears ghost text)
397
- const bulkCountRef = useRef(0);
398
- if (isBulkChange) bulkCountRef.current++;
399
-
400
401
  let activeIndex = 0;
401
402
  const selectedSession = sessions[selectedRow] ?? null;
402
403
  const inspector = selectedSession ? getInspectorData(selectedSession) : null;
403
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
+
404
410
  return (
405
411
  <box
406
412
  flexDirection="column"
@@ -418,9 +424,15 @@ export const SessionsTable = forwardRef(function SessionsTable(
418
424
  justifyContent="space-between"
419
425
  overflow="hidden"
420
426
  >
421
- <text height={1} fg={colors.textMuted}>
422
- SESSIONS{filterQuery ? ` [${isFiltering ? "Filter: " : ""}${filterQuery}]` : ""}
423
- {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>
424
436
  </text>
425
437
  <text height={1} fg={colors.textMuted}>
426
438
  [{windowLabel}] {sessions.length} sessions
@@ -435,36 +447,36 @@ export const SessionsTable = forwardRef(function SessionsTable(
435
447
  <text width={8} height={1} fg={colors.textMuted}>
436
448
  ID{" "}
437
449
  </text>
438
- <text width={12} height={1} fg={colors.textMuted}>
439
- AGENT{" "}
450
+ <text width={12} height={1} fg={sortColor("agent")}>
451
+ AGENT{sortIndicator("agent")}
440
452
  </text>
441
453
  <text width={18} height={1} fg={colors.textMuted}>
442
454
  MODEL{" "}
443
455
  </text>
444
- <text width={5} height={1} fg={colors.textMuted}>
445
- {" "}
456
+ <text width={5} height={1} fg={sortColor("requests")}>
457
+ {sortIndicator("requests")}
446
458
  REQ{" "}
447
459
  </text>
448
- <text width={14} height={1} fg={colors.textMuted}>
449
- {" "}
460
+ <text width={14} height={1} fg={sortColor("tokens")}>
461
+ {sortIndicator("tokens")}
450
462
  TOKENS{" "}
451
463
  </text>
452
- <text width={8} height={1} fg={colors.textMuted}>
453
- {" "}
464
+ <text width={8} height={1} fg={sortColor("cost")}>
465
+ {sortIndicator("cost")}
454
466
  COST{" "}
455
467
  </text>
456
- <text width={14} height={1} fg={colors.textMuted}>
457
- PROJECT{" "}
468
+ <text width={14} height={1} fg={sortColor("project")}>
469
+ PROJECT{sortIndicator("project")}
458
470
  </text>
459
471
  <text flexGrow={1} height={1} fg={colors.textMuted}>
460
472
  NAME
461
473
  </text>
462
- <text width={6} height={1} fg={colors.textMuted}>
463
- {" "}
474
+ <text width={6} height={1} fg={sortColor("duration")}>
475
+ {sortIndicator("duration")}
464
476
  DUR{" "}
465
477
  </text>
466
- <text width={5} height={1} fg={colors.textMuted}>
467
- LAST{" "}
478
+ <text width={5} height={1} fg={sortColor("time")}>
479
+ LAST{sortIndicator("time")}
468
480
  </text>
469
481
  <text width={2} height={1} fg={colors.textMuted}>
470
482
  {" "}
@@ -475,24 +487,30 @@ export const SessionsTable = forwardRef(function SessionsTable(
475
487
  <text width={2} height={1} fg={colors.textMuted}>
476
488
  {" "}
477
489
  </text>
478
- <text width={9} height={1} fg={colors.textMuted}>
479
- AGENT{" "}
490
+ <text width={9} height={1} fg={sortColor("agent")}>
491
+ AGENT{sortIndicator("agent")}
480
492
  </text>
481
493
  <text width={isTight ? 13 : 16} height={1} fg={colors.textMuted}>
482
494
  {isTight ? "MODEL " : "MODEL "}
483
495
  </text>
484
- <text width={isTight ? 9 : 14} height={1} fg={colors.textMuted}>
485
- {isTight ? " TOKENS " : " TOKENS "}
496
+ <text width={isTight ? 9 : 14} height={1} fg={sortColor("tokens")}>
497
+ {isTight
498
+ ? `${sortIndicator("tokens")} TOKENS `
499
+ : `${sortIndicator("tokens")}TOKENS `}
486
500
  </text>
487
- <text width={7} height={1} fg={colors.textMuted}>
488
- {" "}
501
+ <text width={7} height={1} fg={sortColor("cost")}>
502
+ {sortIndicator("cost")}
489
503
  COST{" "}
490
504
  </text>
491
- <text flexGrow={1} height={1} fg={colors.textMuted}>
492
- {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
+ : ""}
493
511
  </text>
494
- <text width={5} height={1} fg={colors.textMuted}>
495
- LAST{" "}
512
+ <text width={5} height={1} fg={sortColor("time")}>
513
+ LAST{sortIndicator("time")}
496
514
  </text>
497
515
  <text width={2} height={1} fg={colors.textMuted}>
498
516
  {" "}
@@ -500,7 +518,7 @@ export const SessionsTable = forwardRef(function SessionsTable(
500
518
  </box>
501
519
  )}
502
520
 
503
- <scrollbox key={`sessions-sb-${bulkCountRef.current}`} ref={ref} flexGrow={1}>
521
+ <scrollbox ref={ref} flexGrow={1}>
504
522
  <box flexDirection="column" flexGrow={1}>
505
523
  {sessions.length === 0 && animatedSessions.length === 0 && (
506
524
  <box paddingLeft={2}>
@@ -2,6 +2,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
2
  import type { ConfigField, PluginType } from "@tokentop/plugin-sdk";
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
5
+ import { notificationBus } from "@/plugins/notification-bus.ts";
5
6
  import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
6
7
  import { useConfig } from "../contexts/ConfigContext.tsx";
7
8
  import { useDemoMode } from "../contexts/DemoModeContext.tsx";
@@ -20,10 +21,11 @@ interface SettingItem {
20
21
  description?: string;
21
22
  category: SettingCategory;
22
23
  pluginId?: string;
23
- type: "toggle" | "select" | "number";
24
+ type: "toggle" | "select" | "number" | "action";
24
25
  options?: string[];
25
26
  getValue: (config: AppConfig) => string | number | boolean | null;
26
27
  setValue: (config: AppConfig, value: string | number | boolean | null) => AppConfig;
28
+ onAction?: () => void;
27
29
  }
28
30
 
29
31
  const BASE_SETTINGS: SettingItem[] = [
@@ -205,6 +207,16 @@ const BASE_SETTINGS: SettingItem[] = [
205
207
  notifications: { ...c.notifications, soundEnabled: v as boolean },
206
208
  }),
207
209
  },
210
+ {
211
+ key: "testNotification",
212
+ label: "Test Notifications",
213
+ description: "Send a test notification to verify toast, flash, and sound alerts are working.",
214
+ category: "notifications",
215
+ type: "action",
216
+ onAction: () => void notificationBus.testNotification(),
217
+ getValue: () => "▸ Run",
218
+ setValue: (c) => c,
219
+ },
208
220
  ];
209
221
 
210
222
  const CATEGORIES: { id: SettingCategory; label: string }[] = [
@@ -362,7 +374,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
362
374
  provider: "Providers",
363
375
  agent: "Agents",
364
376
  theme: "Themes",
365
- notification: "Delivery",
377
+ notification: "Notifications",
366
378
  };
367
379
 
368
380
  // Stable ordering for plugin type groups in sidebar
@@ -621,7 +633,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
621
633
  setSelectedIndex((i) => Math.max(i - 1, 0));
622
634
  } else if (key.name === "return" || key.name === "space") {
623
635
  const setting = categorySettings[selectedIndex];
624
- if (setting?.key === "theme") {
636
+ if (setting?.type === "action" && setting.onAction) {
637
+ setting.onAction();
638
+ } else if (setting?.key === "theme") {
625
639
  setShowThemePicker(true);
626
640
  } else if (setting?.type === "number") {
627
641
  startEditingNumber();
@@ -832,7 +846,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
832
846
  setting.type === "number" && editingSettingKey === setting.key;
833
847
 
834
848
  let displayValue: string;
835
- if (setting.key === "theme") {
849
+ if (setting.type === "action") {
850
+ displayValue = "▸ Run";
851
+ } else if (setting.key === "theme") {
836
852
  const themeName = themes.find((t) => t.id === value)?.name ?? value;
837
853
  displayValue = `${themeName} ▸`;
838
854
  } else if (setting.type === "toggle") {
@@ -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] ?? "⠋";
@@ -8,7 +8,7 @@ interface ToastProps {
8
8
  type?: "info" | "success" | "warning" | "error";
9
9
  }
10
10
 
11
- export function Toast({ message, duration = 2000, onDismiss, type = "success" }: ToastProps) {
11
+ export function Toast({ message, duration = 4000, onDismiss, type = "success" }: ToastProps) {
12
12
  const colors = useColors();
13
13
 
14
14
  useEffect(() => {