@tokentop/ttop 0.5.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 (51) hide show
  1. package/README.md +7 -6
  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/demo/simulator.ts +1 -1
  7. package/src/plugins/agents/index.ts +1 -0
  8. package/src/plugins/lifecycle.ts +1 -1
  9. package/src/plugins/plugin-host.test.ts +197 -0
  10. package/src/plugins/plugin-host.ts +45 -1
  11. package/src/plugins/providers/anthropic.test.ts +202 -0
  12. package/src/plugins/providers/anthropic.ts +19 -0
  13. package/src/plugins/providers/antigravity.ts +16 -0
  14. package/src/plugins/providers/chutes.ts +16 -0
  15. package/src/plugins/providers/codex.ts +16 -0
  16. package/src/plugins/providers/gemini.ts +16 -0
  17. package/src/plugins/providers/github-copilot.ts +16 -0
  18. package/src/plugins/providers/minimax.ts +16 -0
  19. package/src/plugins/providers/openai-api.ts +52 -0
  20. package/src/plugins/providers/perplexity.ts +10 -1
  21. package/src/plugins/providers/zai.ts +16 -0
  22. package/src/pricing/fallback.ts +4 -0
  23. package/src/tui/App.tsx +16 -8
  24. package/src/tui/components/HelpOverlay.tsx +1 -1
  25. package/src/tui/components/LimitGauge.tsx +12 -4
  26. package/src/tui/components/ProviderCard.tsx +38 -22
  27. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  28. package/src/tui/components/SessionsTable.tsx +54 -36
  29. package/src/tui/components/Skeleton.tsx +9 -6
  30. package/src/tui/components/SortOverlay.tsx +167 -0
  31. package/src/tui/components/Spinner.tsx +16 -11
  32. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  33. package/src/tui/contexts/PluginContext.tsx +64 -10
  34. package/src/tui/contexts/ThemeContext.tsx +4 -1
  35. package/src/tui/createApp.tsx +2 -0
  36. package/src/tui/hooks/index.ts +1 -0
  37. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  38. package/src/tui/hooks/useAnimationTick.ts +93 -0
  39. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  40. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  41. package/src/tui/hooks/usePulse.ts +13 -7
  42. package/src/tui/hooks/useValueFlash.ts +25 -33
  43. package/src/tui/index.tsx +4 -0
  44. package/src/tui/types/projectSort.ts +35 -0
  45. package/src/tui/types/sort.ts +61 -0
  46. package/src/tui/views/ProjectsView.tsx +104 -38
  47. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  48. package/src/utils/__tests__/error-category.test.ts +147 -0
  49. package/src/utils/currency.ts +4 -2
  50. package/src/utils/error-category.ts +91 -0
  51. package/src/version.ts +1 -1
@@ -49,6 +49,15 @@ interface CostsResponse {
49
49
  has_more: boolean;
50
50
  }
51
51
 
52
+ class RateLimitError extends Error {
53
+ readonly retryAfterMs: number;
54
+ constructor(retryAfterMs: number) {
55
+ super(`Rate limited. Retry after ${retryAfterMs / 1000}s.`);
56
+ this.name = "RateLimitError";
57
+ this.retryAfterMs = retryAfterMs;
58
+ }
59
+ }
60
+
52
61
  export const openaiApiPlugin: ProviderPlugin = {
53
62
  apiVersion: 2,
54
63
  id: "openai-api",
@@ -145,6 +154,21 @@ export const openaiApiPlugin: ProviderPlugin = {
145
154
  fetchCosts(http, adminKey, oneDayAgo),
146
155
  ]);
147
156
 
157
+ // If either helper hit a 429, surface rate-limit info immediately
158
+ for (const settled of [usageResult, costsResult]) {
159
+ if (settled.status === "rejected" && settled.reason instanceof RateLimitError) {
160
+ log.warn("Rate limited by OpenAI API", { retryAfterMs: settled.reason.retryAfterMs });
161
+ return {
162
+ planType: "API",
163
+ allowed: true,
164
+ fetchedAt: Date.now(),
165
+ error: settled.reason.message,
166
+ rateLimited: true,
167
+ retryAfterMs: settled.reason.retryAfterMs,
168
+ };
169
+ }
170
+ }
171
+
148
172
  const result: ProviderUsageData = {
149
173
  planType: "API",
150
174
  allowed: true,
@@ -208,6 +232,16 @@ export const openaiApiPlugin: ProviderPlugin = {
208
232
 
209
233
  return result;
210
234
  } catch (err) {
235
+ if (err instanceof RateLimitError) {
236
+ return {
237
+ planType: "API",
238
+ allowed: true,
239
+ fetchedAt: Date.now(),
240
+ error: err.message,
241
+ rateLimited: true,
242
+ retryAfterMs: err.retryAfterMs,
243
+ };
244
+ }
211
245
  const msg = err instanceof Error ? err.message : String(err);
212
246
  log.error("OpenAI fetch error", { error: msg });
213
247
 
@@ -236,6 +270,15 @@ async function fetchCompletionsUsage(
236
270
  });
237
271
 
238
272
  if (!response.ok) {
273
+ if (response.status === 429) {
274
+ const retryAfterHeader = response.headers.get("retry-after");
275
+ const parsedRetryAfter = retryAfterHeader
276
+ ? Number.parseInt(retryAfterHeader, 10)
277
+ : Number.NaN;
278
+ const retrySec =
279
+ Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
280
+ throw new RateLimitError(retrySec * 1000);
281
+ }
239
282
  if (response.status === 403) {
240
283
  return null;
241
284
  }
@@ -260,6 +303,15 @@ async function fetchCosts(
260
303
  });
261
304
 
262
305
  if (!response.ok) {
306
+ if (response.status === 429) {
307
+ const retryAfterHeader = response.headers.get("retry-after");
308
+ const parsedRetryAfter = retryAfterHeader
309
+ ? Number.parseInt(retryAfterHeader, 10)
310
+ : Number.NaN;
311
+ const retrySec =
312
+ Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
313
+ throw new RateLimitError(retrySec * 1000);
314
+ }
263
315
  if (response.status === 403) {
264
316
  return null;
265
317
  }
@@ -135,11 +135,20 @@ export const perplexityPlugin: ProviderPlugin = {
135
135
  }
136
136
 
137
137
  if (response.status === 429) {
138
+ const retryAfterHeader = response.headers.get("retry-after");
139
+ const parsedRetryAfter = retryAfterHeader
140
+ ? Number.parseInt(retryAfterHeader, 10)
141
+ : Number.NaN;
142
+ const retrySec =
143
+ Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
144
+ log.warn("Rate limited by Perplexity API", { retryAfterHeader, retryAfterSec: retrySec });
138
145
  return {
139
146
  planType: "API",
140
147
  allowed: true,
141
148
  fetchedAt: Date.now(),
142
- error: "Rate limited",
149
+ error: `Rate limited. Retry after ${retrySec}s.`,
150
+ rateLimited: true,
151
+ retryAfterMs: retrySec * 1000,
143
152
  };
144
153
  }
145
154
 
@@ -124,6 +124,22 @@ export const zaiCodingPlanPlugin: ProviderPlugin = {
124
124
  if (!response.ok) {
125
125
  log.warn("Failed to fetch Z.ai usage", { status: response.status });
126
126
 
127
+ if (response.status === 429) {
128
+ const retryAfterHeader = response.headers.get("retry-after");
129
+ const parsedRetryAfter = retryAfterHeader
130
+ ? Number.parseInt(retryAfterHeader, 10)
131
+ : Number.NaN;
132
+ const retrySec =
133
+ Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
134
+ log.warn("Rate limited by Z.ai API", { retryAfterHeader, retryAfterSec: retrySec });
135
+ return {
136
+ fetchedAt: Date.now(),
137
+ error: `Rate limited. Retry after ${retrySec}s.`,
138
+ rateLimited: true,
139
+ retryAfterMs: retrySec * 1000,
140
+ };
141
+ }
142
+
127
143
  if (response.status === 401 || response.status === 403) {
128
144
  return {
129
145
  fetchedAt: Date.now(),
@@ -57,6 +57,10 @@ export const FALLBACK_PRICING: Record<string, Record<string, ModelPricing>> = {
57
57
  "gemini-2.0-flash-lite": { input: 0.075, output: 0.3, source: "fallback" },
58
58
  "gemini-2.5-pro": { input: 1.25, output: 10, source: "fallback" },
59
59
  "gemini-2.5-flash": { input: 0.15, output: 0.6, source: "fallback" },
60
+ "gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05, source: "fallback" },
61
+ "gemini-3-pro-preview": { input: 2, output: 12, cacheRead: 0.2, source: "fallback" },
62
+ "gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, source: "fallback" },
63
+ "gemini-3.1-flash-lite-preview": { input: 0.25, output: 1.5, source: "fallback" },
60
64
  },
61
65
 
62
66
  openrouter: {
package/src/tui/App.tsx CHANGED
@@ -48,6 +48,7 @@ interface AppProps {
48
48
  demoSeed?: number;
49
49
  demoPreset?: DemoPreset;
50
50
  cliPlugins?: string[];
51
+ cliTheme?: string;
51
52
  }
52
53
 
53
54
  type View = "dashboard" | "providers" | "trends" | "projects";
@@ -55,7 +56,7 @@ type View = "dashboard" | "providers" | "trends" | "projects";
55
56
  function AppContent() {
56
57
  const renderer = useSafeRenderer();
57
58
  const colors = useColors();
58
- const { theme, setTheme } = useTheme();
59
+ const { theme, setTheme, cliTheme } = useTheme();
59
60
  const { refreshAllProviders, isInitialized, themes } = usePlugins();
60
61
  const { info } = useLogs();
61
62
  const { toast, showToast, dismissToast } = useToastContext();
@@ -292,8 +293,10 @@ function AppContent() {
292
293
 
293
294
  useEffect(() => {
294
295
  if (themes.length === 0) return;
296
+ // CLI --theme flag takes priority over config
297
+ const themeId = cliTheme ?? config.display.theme;
295
298
  const resolved = resolveTheme(
296
- config.display.theme,
299
+ themeId,
297
300
  config.display.colorScheme,
298
301
  themes,
299
302
  renderer?.themeMode ?? null,
@@ -301,7 +304,7 @@ function AppContent() {
301
304
  if (resolved.id !== theme.id) {
302
305
  setTheme(resolved);
303
306
  }
304
- }, [themes, config.display.theme, config.display.colorScheme, renderer]);
307
+ }, [themes, cliTheme, config.display.theme, config.display.colorScheme, renderer]);
305
308
 
306
309
  useEffect(() => {
307
310
  if (isInitialized) {
@@ -364,23 +367,24 @@ function AppContent() {
364
367
  );
365
368
  }
366
369
 
367
- function ConfiguredApp({ cliPlugins }: { cliPlugins?: string[] }) {
370
+ function ConfiguredApp({ cliPlugins, cliTheme }: { cliPlugins?: string[]; cliTheme?: string }) {
368
371
  const { config, isLoading } = useConfig();
369
372
 
370
373
  if (isLoading) {
371
374
  return null;
372
375
  }
373
376
 
374
- const initialTheme = builtinThemeList.find((t) => t.id === config.display.theme);
377
+ const themeId = cliTheme ?? config.display.theme;
378
+ const initialTheme = builtinThemeList.find((t) => t.id === themeId);
375
379
  const themeProps = initialTheme ? { initialTheme } : {};
376
380
 
377
381
  return (
378
- <ThemeProvider {...themeProps}>
382
+ <ThemeProvider {...themeProps} {...(cliTheme ? { cliTheme } : {})}>
379
383
  <TimeWindowProvider defaultWindow={config.display.defaultTimeWindow}>
380
384
  <ToastProvider>
381
385
  <PluginProvider {...(cliPlugins ? { cliPlugins } : {})}>
382
386
  <RealTimeActivityProvider>
383
- <AgentSessionProvider autoRefresh={true} refreshInterval={1000}>
387
+ <AgentSessionProvider autoRefresh={true}>
384
388
  <DashboardRuntimeProvider>
385
389
  <DrawerProvider>
386
390
  <AppContent />
@@ -401,6 +405,7 @@ export function App({
401
405
  demoSeed,
402
406
  demoPreset,
403
407
  cliPlugins,
408
+ cliTheme,
404
409
  }: AppProps) {
405
410
  const demoProviderProps: { demoMode: boolean; demoSeed?: number; demoPreset?: DemoPreset } = {
406
411
  demoMode,
@@ -414,7 +419,10 @@ export function App({
414
419
  <InputProvider>
415
420
  <StorageProvider>
416
421
  <ConfigProvider>
417
- <ConfiguredApp {...(cliPlugins ? { cliPlugins } : {})} />
422
+ <ConfiguredApp
423
+ {...(cliPlugins ? { cliPlugins } : {})}
424
+ {...(cliTheme ? { cliTheme } : {})}
425
+ />
418
426
  </ConfigProvider>
419
427
  </StorageProvider>
420
428
  </InputProvider>
@@ -59,7 +59,7 @@ export function HelpOverlay() {
59
59
  <text width={10} height={1} fg={colors.textMuted}>
60
60
  s
61
61
  </text>
62
- <text height={1}>Sort</text>
62
+ <text height={1}>Sort menu</text>
63
63
  </box>
64
64
  <box flexDirection="row" height={1}>
65
65
  <text width={10} height={1} fg={colors.textMuted}>
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { getErrorCategoryDisplay, type ProviderErrorCategory } from "@/utils/error-category.ts";
2
3
  import { useColors } from "../contexts/ThemeContext.tsx";
3
4
  import { usePulse } from "../hooks/usePulse.ts";
4
5
 
@@ -8,6 +9,7 @@ interface LimitGaugeProps {
8
9
  color: string;
9
10
  ghost?: boolean;
10
11
  error?: string;
12
+ errorCategory?: ProviderErrorCategory;
11
13
  resetTime?: string;
12
14
  compact?: boolean;
13
15
  labelWidth?: number;
@@ -89,6 +91,7 @@ export function LimitGauge({
89
91
  color,
90
92
  ghost = false,
91
93
  error,
94
+ errorCategory,
92
95
  resetTime,
93
96
  compact = false,
94
97
  labelWidth = 12,
@@ -152,7 +155,12 @@ export function LimitGauge({
152
155
 
153
156
  if (error) {
154
157
  const displayLabel = truncateLabel(label, labelWidth);
155
- const errorIcon = selected ? "▌" : "✗";
158
+
159
+ const display = errorCategory ? getErrorCategoryDisplay(errorCategory) : undefined;
160
+ const errColor = display?.isWarning ? colors.warning : colors.error;
161
+ const errLabel = display?.label ?? "ERR";
162
+ const errorIcon = selected ? "\u258C" : (display?.icon ?? "\u2717");
163
+
156
164
  return (
157
165
  <box
158
166
  height={1}
@@ -160,10 +168,10 @@ export function LimitGauge({
160
168
  {...(selected ? { backgroundColor: colors.borderMuted } : {})}
161
169
  >
162
170
  <text height={1}>
163
- <span fg={selected ? colors.primary : colors.error}>{errorIcon} </span>
171
+ <span fg={selected ? colors.primary : errColor}>{errorIcon} </span>
164
172
  <span fg={selected ? colors.text : colors.text}>{displayLabel} </span>
165
- <span fg={colors.error}>{"─".repeat(barWidth)}</span>
166
- <span fg={colors.error}> ERR</span>
173
+ <span fg={errColor}>{"\u2500".repeat(barWidth)}</span>
174
+ <span fg={errColor}> {errLabel}</span>
167
175
  </text>
168
176
  </box>
169
177
  );
@@ -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}>
@@ -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[] = [];