@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.
- package/README.md +38 -15
- package/package.json +8 -6
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +40 -5
- package/src/cli.ts +14 -0
- package/src/config/schema.ts +1 -1
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/lifecycle.ts +1 -1
- package/src/plugins/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +19 -0
- package/src/plugins/providers/antigravity.ts +16 -0
- package/src/plugins/providers/chutes.ts +16 -0
- package/src/plugins/providers/codex.ts +16 -0
- package/src/plugins/providers/gemini.ts +16 -0
- package/src/plugins/providers/github-copilot.ts +16 -0
- package/src/plugins/providers/minimax.ts +16 -0
- package/src/plugins/providers/openai-api.ts +52 -0
- package/src/plugins/providers/perplexity.ts +10 -1
- package/src/plugins/providers/zai.ts +16 -0
- package/src/pricing/fallback.ts +4 -0
- package/src/tui/App.tsx +22 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/ProviderCard.tsx +38 -22
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionsTable.tsx +54 -36
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +72 -10
- package/src/tui/contexts/ThemeContext.tsx +4 -1
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
|
@@ -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
|
|
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 {
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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={
|
|
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={
|
|
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}
|
|
422
|
-
|
|
423
|
-
{
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
485
|
-
{isTight
|
|
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={
|
|
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={
|
|
492
|
-
{nameMaxWidth >= 7
|
|
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={
|
|
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
|
|
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: "
|
|
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?.
|
|
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.
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
return animationTick.subscribe((now) => {
|
|
20
|
+
if (now - lastAdvanceRef.current >= SHIMMER_INTERVAL) {
|
|
21
|
+
lastAdvanceRef.current = now;
|
|
22
|
+
setPhase((p) => (p + 1) % (length + SHIMMER_WAVE_LENGTH));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
22
25
|
}, [length]);
|
|
23
26
|
|
|
24
27
|
const segments: ShimmerSegment[] = [];
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { useInputFocus } from "../contexts/InputContext.tsx";
|
|
4
|
+
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
5
|
+
import type { SortDirection, SortFieldDef } from "../types/sort.ts";
|
|
6
|
+
import { getSortDirectionIndicator } from "../types/sort.ts";
|
|
7
|
+
import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
|
|
8
|
+
|
|
9
|
+
interface SortOverlayProps<F extends string = string> {
|
|
10
|
+
fields: readonly SortFieldDef<F>[];
|
|
11
|
+
currentField: F;
|
|
12
|
+
currentDirection: SortDirection;
|
|
13
|
+
onSelect: (field: F, direction: SortDirection) => void;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SortOverlay<F extends string>({
|
|
18
|
+
fields,
|
|
19
|
+
currentField,
|
|
20
|
+
currentDirection,
|
|
21
|
+
onSelect,
|
|
22
|
+
onClose,
|
|
23
|
+
}: SortOverlayProps<F>) {
|
|
24
|
+
const colors = useColors();
|
|
25
|
+
const { setInputFocused } = useInputFocus();
|
|
26
|
+
|
|
27
|
+
const [highlightedIndex, setHighlightedIndex] = useState(() => {
|
|
28
|
+
const idx = fields.findIndex((f) => f.id === currentField);
|
|
29
|
+
return idx === -1 ? 0 : idx;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const [pendingDirection, setPendingDirection] = useState<SortDirection>(currentDirection);
|
|
33
|
+
|
|
34
|
+
// Capture input focus to block App.tsx and view-level keyboard handlers
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setInputFocused(true);
|
|
37
|
+
return () => setInputFocused(false);
|
|
38
|
+
}, [setInputFocused]);
|
|
39
|
+
|
|
40
|
+
useKeyboard((key) => {
|
|
41
|
+
// Navigation
|
|
42
|
+
if (key.name === "up" || key.name === "k") {
|
|
43
|
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : fields.length - 1));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (key.name === "down" || key.name === "j") {
|
|
48
|
+
setHighlightedIndex((prev) => (prev < fields.length - 1 ? prev + 1 : 0));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Toggle direction in-place
|
|
53
|
+
if (key.name === "space" || key.sequence === " ") {
|
|
54
|
+
setPendingDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Apply highlighted selection
|
|
59
|
+
if (key.name === "return") {
|
|
60
|
+
const field = fields[highlightedIndex];
|
|
61
|
+
if (field) {
|
|
62
|
+
onSelect(field.id, pendingDirection);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cancel
|
|
68
|
+
if (key.name === "escape") {
|
|
69
|
+
onClose();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Number quick-select: 1-9 selects field by position
|
|
74
|
+
// If already sorting by that field, flip direction instead
|
|
75
|
+
if (key.sequence && key.sequence.length === 1) {
|
|
76
|
+
const digit = key.sequence.charCodeAt(0) - 48; // '0' = 48
|
|
77
|
+
if (digit >= 1 && digit <= fields.length) {
|
|
78
|
+
const field = fields[digit - 1];
|
|
79
|
+
if (field) {
|
|
80
|
+
const direction =
|
|
81
|
+
field.id === currentField
|
|
82
|
+
? currentDirection === "desc"
|
|
83
|
+
? "asc"
|
|
84
|
+
: "desc"
|
|
85
|
+
: field.defaultDirection;
|
|
86
|
+
onSelect(field.id, direction);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<ModalBackdrop zIndex={Z_INDEX.MODAL} onBackdropClick={onClose}>
|
|
95
|
+
<box
|
|
96
|
+
width={34}
|
|
97
|
+
flexDirection="column"
|
|
98
|
+
border
|
|
99
|
+
borderStyle="double"
|
|
100
|
+
borderColor={colors.primary}
|
|
101
|
+
backgroundColor={colors.background}
|
|
102
|
+
overflow="hidden"
|
|
103
|
+
>
|
|
104
|
+
{/* Header */}
|
|
105
|
+
<box height={1} justifyContent="center">
|
|
106
|
+
<text fg={colors.primary} height={1}>
|
|
107
|
+
Sort By
|
|
108
|
+
</text>
|
|
109
|
+
</box>
|
|
110
|
+
|
|
111
|
+
{/* Sort Fields */}
|
|
112
|
+
<box flexDirection="column">
|
|
113
|
+
{fields.map((field, index) => {
|
|
114
|
+
const isHighlighted = index === highlightedIndex;
|
|
115
|
+
const isCurrent = field.id === currentField;
|
|
116
|
+
|
|
117
|
+
const textColor = isHighlighted ? colors.background : colors.text;
|
|
118
|
+
const numberColor = isHighlighted ? colors.background : colors.textMuted;
|
|
119
|
+
const markerColor = isHighlighted ? colors.background : colors.primary;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<box
|
|
123
|
+
key={field.id}
|
|
124
|
+
flexDirection="row"
|
|
125
|
+
height={1}
|
|
126
|
+
paddingLeft={1}
|
|
127
|
+
paddingRight={1}
|
|
128
|
+
justifyContent="space-between"
|
|
129
|
+
{...(isHighlighted ? { backgroundColor: colors.primary } : {})}
|
|
130
|
+
>
|
|
131
|
+
{/* Left: number + marker + label + direction */}
|
|
132
|
+
<text height={1}>
|
|
133
|
+
<span fg={numberColor}>{index + 1} </span>
|
|
134
|
+
<span fg={markerColor}>{isCurrent ? "● " : " "}</span>
|
|
135
|
+
<span fg={textColor}>{field.label}</span>
|
|
136
|
+
{isHighlighted ? (
|
|
137
|
+
<span fg={colors.warning}> {getSortDirectionIndicator(pendingDirection)}</span>
|
|
138
|
+
) : null}
|
|
139
|
+
</text>
|
|
140
|
+
</box>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</box>
|
|
144
|
+
|
|
145
|
+
{/* Footer Hints */}
|
|
146
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} marginTop={1}>
|
|
147
|
+
<box flexDirection="row" justifyContent="space-between" height={1}>
|
|
148
|
+
<text fg={colors.textSubtle} height={1}>
|
|
149
|
+
↑↓ navigate
|
|
150
|
+
</text>
|
|
151
|
+
<text fg={colors.textSubtle} height={1}>
|
|
152
|
+
Space toggle
|
|
153
|
+
</text>
|
|
154
|
+
</box>
|
|
155
|
+
<box flexDirection="row" justifyContent="space-between" height={1}>
|
|
156
|
+
<text fg={colors.textSubtle} height={1}>
|
|
157
|
+
Enter apply
|
|
158
|
+
</text>
|
|
159
|
+
<text fg={colors.textSubtle} height={1}>
|
|
160
|
+
Esc cancel
|
|
161
|
+
</text>
|
|
162
|
+
</box>
|
|
163
|
+
</box>
|
|
164
|
+
</box>
|
|
165
|
+
</ModalBackdrop>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animationTick } from "../hooks/useAnimationTick.ts";
|
|
2
3
|
|
|
3
4
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
5
|
const FRAME_INTERVAL = 80;
|
|
@@ -9,13 +10,15 @@ interface SpinnerProps {
|
|
|
9
10
|
|
|
10
11
|
export function Spinner({ color }: SpinnerProps) {
|
|
11
12
|
const [frameIndex, setFrameIndex] = useState(0);
|
|
13
|
+
const lastAdvanceRef = useRef(Date.now());
|
|
12
14
|
|
|
13
15
|
useEffect(() => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
return animationTick.subscribe((now) => {
|
|
17
|
+
if (now - lastAdvanceRef.current >= FRAME_INTERVAL) {
|
|
18
|
+
lastAdvanceRef.current = now;
|
|
19
|
+
setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
19
22
|
}, []);
|
|
20
23
|
|
|
21
24
|
return <text {...(color ? { fg: color } : {})}>{SPINNER_FRAMES[frameIndex]}</text>;
|
|
@@ -23,13 +26,15 @@ export function Spinner({ color }: SpinnerProps) {
|
|
|
23
26
|
|
|
24
27
|
export function useSpinner(): string {
|
|
25
28
|
const [frameIndex, setFrameIndex] = useState(0);
|
|
29
|
+
const lastAdvanceRef = useRef(Date.now());
|
|
26
30
|
|
|
27
31
|
useEffect(() => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
return animationTick.subscribe((now) => {
|
|
33
|
+
if (now - lastAdvanceRef.current >= FRAME_INTERVAL) {
|
|
34
|
+
lastAdvanceRef.current = now;
|
|
35
|
+
setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
33
38
|
}, []);
|
|
34
39
|
|
|
35
40
|
return SPINNER_FRAMES[frameIndex] ?? "⠋";
|
|
@@ -8,7 +8,7 @@ interface ToastProps {
|
|
|
8
8
|
type?: "info" | "success" | "warning" | "error";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function Toast({ message, duration =
|
|
11
|
+
export function Toast({ message, duration = 4000, onDismiss, type = "success" }: ToastProps) {
|
|
12
12
|
const colors = useColors();
|
|
13
13
|
|
|
14
14
|
useEffect(() => {
|