@tokentop/ttop 0.2.2 → 0.4.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.
- package/README.md +2 -5
- package/package.json +8 -5
- package/src/agents/aggregator.ts +39 -19
- package/src/agents/costing.ts +11 -9
- package/src/agents/index.ts +7 -9
- package/src/agents/types.ts +2 -2
- package/src/cli.ts +28 -23
- package/src/config/schema.test.ts +42 -40
- package/src/config/schema.ts +26 -26
- package/src/demo/simulator.ts +161 -122
- package/src/plugins/agents/index.ts +2 -2
- package/src/plugins/auth-sources.ts +42 -38
- package/src/plugins/lifecycle.ts +42 -38
- package/src/plugins/loader.test.ts +49 -49
- package/src/plugins/loader.ts +67 -63
- package/src/plugins/notification-bus.ts +21 -16
- package/src/plugins/notifications/index.ts +2 -2
- package/src/plugins/notifications/terminal-bell.ts +36 -23
- package/src/plugins/notifications/visual-flash.ts +31 -19
- package/src/plugins/npm-installer.ts +23 -25
- package/src/plugins/plugin-context-factory.ts +10 -10
- package/src/plugins/plugin-host.ts +12 -5
- package/src/plugins/providers/anthropic.ts +64 -57
- package/src/plugins/providers/antigravity.ts +81 -69
- package/src/plugins/providers/codex.ts +42 -42
- package/src/plugins/providers/gemini.ts +106 -87
- package/src/plugins/providers/github-copilot.ts +49 -45
- package/src/plugins/providers/index.ts +10 -12
- package/src/plugins/providers/minimax.ts +50 -44
- package/src/plugins/providers/openai-api.ts +82 -62
- package/src/plugins/providers/opencode-zen.ts +46 -39
- package/src/plugins/providers/perplexity.ts +55 -46
- package/src/plugins/providers/zai.ts +68 -57
- package/src/plugins/registry.ts +80 -71
- package/src/plugins/sandbox-guard.test.ts +45 -48
- package/src/plugins/sandbox-guard.ts +20 -25
- package/src/plugins/sandbox.ts +26 -48
- package/src/plugins/themes/catppuccin-latte.ts +29 -29
- package/src/plugins/themes/catppuccin-mocha.ts +29 -29
- package/src/plugins/themes/claude-code.ts +29 -29
- package/src/plugins/themes/dracula.ts +29 -29
- package/src/plugins/themes/github-light.ts +29 -29
- package/src/plugins/themes/gruvbox-dark.ts +29 -29
- package/src/plugins/themes/gruvbox-light.ts +29 -29
- package/src/plugins/themes/index.ts +15 -15
- package/src/plugins/themes/kanagawa.ts +29 -29
- package/src/plugins/themes/nord.ts +29 -29
- package/src/plugins/themes/one-dark.ts +29 -29
- package/src/plugins/themes/opencode.ts +31 -31
- package/src/plugins/themes/rose-pine-dawn.ts +29 -29
- package/src/plugins/themes/rose-pine.ts +29 -29
- package/src/plugins/themes/solarized-light.ts +29 -29
- package/src/plugins/themes/tokyo-night.ts +29 -29
- package/src/plugins/types/agent.ts +5 -5
- package/src/plugins/types/base.ts +35 -24
- package/src/plugins/types/index.ts +9 -9
- package/src/plugins/types/notification.ts +12 -12
- package/src/plugins/types/provider.ts +12 -8
- package/src/plugins/types/theme.ts +31 -23
- package/src/plugins/update-checker.ts +6 -6
- package/src/pricing/estimator.test.ts +39 -44
- package/src/pricing/estimator.ts +7 -13
- package/src/pricing/fallback.test.ts +30 -34
- package/src/pricing/fallback.ts +54 -27
- package/src/pricing/index.ts +18 -12
- package/src/pricing/models-dev.ts +6 -6
- package/src/storage/database.ts +56 -46
- package/src/storage/db.ts +12 -12
- package/src/storage/index.ts +44 -45
- package/src/storage/migrations/index.ts +2 -2
- package/src/storage/paths.ts +13 -13
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/repos/agentSessions.ts +33 -23
- package/src/storage/repos/providerSnapshots.ts +5 -5
- package/src/storage/repos/usageEvents.ts +40 -28
- package/src/storage/retention.ts +146 -0
- package/src/storage/types.ts +5 -5
- package/src/tui/App.tsx +180 -137
- package/src/tui/components/CommandPalette.tsx +46 -34
- package/src/tui/components/DebugConsole.tsx +39 -33
- package/src/tui/components/DebugPanel.tsx +314 -183
- package/src/tui/components/FooterHints.tsx +2 -2
- package/src/tui/components/GhostProviderCard.tsx +37 -35
- package/src/tui/components/Header.tsx +68 -35
- package/src/tui/components/HelpOverlay.tsx +99 -29
- package/src/tui/components/InlineGauge.tsx +10 -7
- package/src/tui/components/InlineSparkline.tsx +17 -11
- package/src/tui/components/KpiStrip.tsx +128 -98
- package/src/tui/components/LimitGauge.tsx +96 -59
- package/src/tui/components/ModalBackdrop.tsx +4 -4
- package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
- package/src/tui/components/ProviderCard.tsx +232 -226
- package/src/tui/components/ProviderDetailPanel.tsx +133 -58
- package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
- package/src/tui/components/ProvidersList.tsx +215 -126
- package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
- package/src/tui/components/SessionsTable.tsx +320 -169
- package/src/tui/components/SettingsModal.tsx +593 -269
- package/src/tui/components/SidebarBreakdown.tsx +30 -16
- package/src/tui/components/Skeleton.tsx +24 -17
- package/src/tui/components/SmartSidebar.tsx +360 -258
- package/src/tui/components/Sparkline.tsx +64 -52
- package/src/tui/components/Spinner.tsx +3 -3
- package/src/tui/components/StatusBar.tsx +27 -27
- package/src/tui/components/ThemePicker.tsx +67 -66
- package/src/tui/components/Toast.tsx +9 -6
- package/src/tui/components/UsageGauge.tsx +10 -13
- package/src/tui/components/index.ts +5 -5
- package/src/tui/contexts/AgentSessionContext.tsx +164 -151
- package/src/tui/contexts/ConfigContext.tsx +15 -13
- package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
- package/src/tui/contexts/DemoModeContext.tsx +22 -15
- package/src/tui/contexts/DrawerContext.tsx +11 -9
- package/src/tui/contexts/InputContext.tsx +2 -2
- package/src/tui/contexts/LogContext.tsx +62 -47
- package/src/tui/contexts/PluginContext.tsx +238 -183
- package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
- package/src/tui/contexts/StorageContext.tsx +95 -149
- package/src/tui/contexts/ThemeContext.tsx +21 -16
- package/src/tui/contexts/TimeWindowContext.tsx +72 -61
- package/src/tui/contexts/ToastContext.tsx +10 -7
- package/src/tui/contexts/index.ts +9 -5
- package/src/tui/createApp.tsx +8 -12
- package/src/tui/debug/captureFrame.ts +25 -28
- package/src/tui/debug/snapshot.tsx +179 -162
- package/src/tui/driver/assertions.ts +30 -22
- package/src/tui/driver/cli.ts +231 -221
- package/src/tui/driver/coverage.ts +27 -44
- package/src/tui/driver/demo-snapshot.ts +33 -33
- package/src/tui/driver/diff.ts +26 -32
- package/src/tui/driver/driver.ts +43 -48
- package/src/tui/driver/index.ts +36 -40
- package/src/tui/driver/recorder.ts +37 -48
- package/src/tui/driver/test-workflow.ts +51 -51
- package/src/tui/driver/test.ts +25 -25
- package/src/tui/hooks/index.ts +24 -20
- package/src/tui/hooks/useAnimatedValue.ts +13 -18
- package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
- package/src/tui/hooks/useDashboardState.ts +26 -24
- package/src/tui/hooks/useEmaActivity.ts +82 -74
- package/src/tui/hooks/useEntranceAnimation.ts +11 -11
- package/src/tui/hooks/useExitAnimation.ts +7 -3
- package/src/tui/hooks/usePulse.ts +4 -4
- package/src/tui/hooks/useSafeRenderer.ts +5 -5
- package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
- package/src/tui/hooks/useValueFlash.ts +10 -14
- package/src/tui/index.tsx +29 -6
- package/src/tui/shutdown.ts +19 -0
- package/src/tui/utils/providerColor.ts +1 -1
- package/src/tui/views/Dashboard.tsx +164 -102
- package/src/tui/views/HistoricalTrendsView.tsx +579 -250
- package/src/tui/views/ProjectsView.tsx +495 -243
- package/src/tui/views/RealTimeDashboard.tsx +176 -116
- package/src/tui/views/SettingsView.tsx +225 -189
- package/src/tui/views/index.ts +2 -2
- package/src/types/opentui-test-utils.d.ts +7 -7
- package/src/utils/clipboard.ts +29 -29
- package/src/utils/currency.test.ts +53 -53
- package/src/utils/currency.ts +35 -32
- package/src/version.ts +1 -1
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { useConfig } from
|
|
7
|
-
import { useDemoMode } from
|
|
8
|
-
import { useInputFocus } from
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
|
|
4
|
+
import type { ConfigField, PluginType } from "@/plugins/types/base.ts";
|
|
5
|
+
import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
|
|
6
|
+
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
7
|
+
import { useDemoMode } from "../contexts/DemoModeContext.tsx";
|
|
8
|
+
import { useInputFocus } from "../contexts/InputContext.tsx";
|
|
9
|
+
import { usePlugins } from "../contexts/PluginContext.tsx";
|
|
10
|
+
import { useColors, useTheme } from "../contexts/ThemeContext.tsx";
|
|
11
|
+
import { useToastContext } from "../contexts/ToastContext.tsx";
|
|
12
|
+
import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
|
|
13
|
+
import { ThemePicker } from "./ThemePicker.tsx";
|
|
14
|
+
|
|
15
|
+
type SettingCategory = "refresh" | "display" | "budgets" | "alerts" | "notifications" | "plugins";
|
|
15
16
|
|
|
16
17
|
interface SettingItem {
|
|
17
18
|
key: string;
|
|
18
19
|
label: string;
|
|
20
|
+
description?: string;
|
|
19
21
|
category: SettingCategory;
|
|
20
|
-
|
|
22
|
+
pluginId?: string;
|
|
23
|
+
type: "toggle" | "select" | "number";
|
|
21
24
|
options?: string[];
|
|
22
25
|
getValue: (config: AppConfig) => string | number | boolean | null;
|
|
23
26
|
setValue: (config: AppConfig, value: string | number | boolean | null) => AppConfig;
|
|
@@ -25,184 +28,273 @@ interface SettingItem {
|
|
|
25
28
|
|
|
26
29
|
const BASE_SETTINGS: SettingItem[] = [
|
|
27
30
|
{
|
|
28
|
-
key:
|
|
29
|
-
label:
|
|
30
|
-
category:
|
|
31
|
-
type:
|
|
32
|
-
options: [
|
|
31
|
+
key: "intervalMs",
|
|
32
|
+
label: "Refresh Interval",
|
|
33
|
+
category: "refresh",
|
|
34
|
+
type: "select",
|
|
35
|
+
options: ["30s", "1m", "2m", "5m"],
|
|
33
36
|
getValue: (c) => {
|
|
34
37
|
const ms = c.refresh.intervalMs;
|
|
35
|
-
if (ms <= 30000) return
|
|
36
|
-
if (ms <= 60000) return
|
|
37
|
-
if (ms <= 120000) return
|
|
38
|
-
return
|
|
38
|
+
if (ms <= 30000) return "30s";
|
|
39
|
+
if (ms <= 60000) return "1m";
|
|
40
|
+
if (ms <= 120000) return "2m";
|
|
41
|
+
return "5m";
|
|
39
42
|
},
|
|
40
43
|
setValue: (c, v) => {
|
|
41
|
-
const map: Record<string, number> = {
|
|
44
|
+
const map: Record<string, number> = { "30s": 30000, "1m": 60000, "2m": 120000, "5m": 300000 };
|
|
42
45
|
return { ...c, refresh: { ...c.refresh, intervalMs: map[v as string] ?? 60000 } };
|
|
43
46
|
},
|
|
44
47
|
},
|
|
45
48
|
{
|
|
46
|
-
key:
|
|
47
|
-
label:
|
|
48
|
-
category:
|
|
49
|
-
type:
|
|
49
|
+
key: "pauseAutoRefresh",
|
|
50
|
+
label: "Pause Auto-Refresh",
|
|
51
|
+
category: "refresh",
|
|
52
|
+
type: "toggle",
|
|
50
53
|
getValue: (c) => c.refresh.pauseAutoRefresh,
|
|
51
54
|
setValue: (c, v) => ({ ...c, refresh: { ...c.refresh, pauseAutoRefresh: v as boolean } }),
|
|
52
55
|
},
|
|
53
56
|
{
|
|
54
|
-
key:
|
|
55
|
-
label:
|
|
56
|
-
category:
|
|
57
|
-
type:
|
|
58
|
-
options: [
|
|
57
|
+
key: "defaultTimeWindow",
|
|
58
|
+
label: "Default Time Window",
|
|
59
|
+
category: "display",
|
|
60
|
+
type: "select",
|
|
61
|
+
options: ["5m", "15m", "1h", "24h", "7d", "30d", "all"],
|
|
59
62
|
getValue: (c) => c.display.defaultTimeWindow,
|
|
60
|
-
setValue: (c, v) => ({
|
|
63
|
+
setValue: (c, v) => ({
|
|
64
|
+
...c,
|
|
65
|
+
display: { ...c.display, defaultTimeWindow: v as AppConfig["display"]["defaultTimeWindow"] },
|
|
66
|
+
}),
|
|
61
67
|
},
|
|
62
68
|
{
|
|
63
|
-
key:
|
|
64
|
-
label:
|
|
65
|
-
category:
|
|
66
|
-
type:
|
|
69
|
+
key: "sidebarCollapsed",
|
|
70
|
+
label: "Sidebar Collapsed",
|
|
71
|
+
category: "display",
|
|
72
|
+
type: "toggle",
|
|
67
73
|
getValue: (c) => c.display.sidebarCollapsed,
|
|
68
74
|
setValue: (c, v) => ({ ...c, display: { ...c.display, sidebarCollapsed: v as boolean } }),
|
|
69
75
|
},
|
|
70
76
|
{
|
|
71
|
-
key:
|
|
72
|
-
label:
|
|
73
|
-
category:
|
|
74
|
-
type:
|
|
75
|
-
options: [
|
|
77
|
+
key: "timeFormat",
|
|
78
|
+
label: "Time Format",
|
|
79
|
+
category: "display",
|
|
80
|
+
type: "select",
|
|
81
|
+
options: ["12h", "24h"],
|
|
76
82
|
getValue: (c) => c.display.timeFormat,
|
|
77
|
-
setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as
|
|
83
|
+
setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as "12h" | "24h" } }),
|
|
78
84
|
},
|
|
79
85
|
{
|
|
80
|
-
key:
|
|
81
|
-
label:
|
|
82
|
-
category:
|
|
83
|
-
type:
|
|
84
|
-
options: [
|
|
86
|
+
key: "sparklineStyle",
|
|
87
|
+
label: "Sparkline Style",
|
|
88
|
+
category: "display",
|
|
89
|
+
type: "select",
|
|
90
|
+
options: ["braille", "block"],
|
|
85
91
|
getValue: (c) => c.display.sparkline.style,
|
|
86
|
-
setValue: (c, v) => ({
|
|
87
|
-
...c,
|
|
88
|
-
display: {
|
|
89
|
-
...c.display,
|
|
90
|
-
sparkline: { ...c.display.sparkline, style: v as SparklineStyle }
|
|
91
|
-
}
|
|
92
|
+
setValue: (c, v) => ({
|
|
93
|
+
...c,
|
|
94
|
+
display: {
|
|
95
|
+
...c.display,
|
|
96
|
+
sparkline: { ...c.display.sparkline, style: v as SparklineStyle },
|
|
97
|
+
},
|
|
92
98
|
}),
|
|
93
99
|
},
|
|
94
100
|
{
|
|
95
|
-
key:
|
|
96
|
-
label:
|
|
97
|
-
category:
|
|
98
|
-
type:
|
|
99
|
-
options: [
|
|
101
|
+
key: "sparklineOrientation",
|
|
102
|
+
label: "Sparkline Direction",
|
|
103
|
+
category: "display",
|
|
104
|
+
type: "select",
|
|
105
|
+
options: ["up", "down"],
|
|
100
106
|
getValue: (c) => c.display.sparkline.orientation,
|
|
101
|
-
setValue: (c, v) => ({
|
|
102
|
-
...c,
|
|
103
|
-
display: {
|
|
104
|
-
...c.display,
|
|
105
|
-
sparkline: { ...c.display.sparkline, orientation: v as SparklineOrientation }
|
|
106
|
-
}
|
|
107
|
+
setValue: (c, v) => ({
|
|
108
|
+
...c,
|
|
109
|
+
display: {
|
|
110
|
+
...c.display,
|
|
111
|
+
sparkline: { ...c.display.sparkline, orientation: v as SparklineOrientation },
|
|
112
|
+
},
|
|
107
113
|
}),
|
|
108
114
|
},
|
|
109
115
|
{
|
|
110
|
-
key:
|
|
111
|
-
label:
|
|
112
|
-
category:
|
|
113
|
-
type:
|
|
116
|
+
key: "sparklineBaseline",
|
|
117
|
+
label: "Sparkline Baseline",
|
|
118
|
+
category: "display",
|
|
119
|
+
type: "toggle",
|
|
114
120
|
getValue: (c) => c.display.sparkline.showBaseline,
|
|
115
|
-
setValue: (c, v) => ({
|
|
116
|
-
...c,
|
|
117
|
-
display: {
|
|
118
|
-
...c.display,
|
|
119
|
-
sparkline: { ...c.display.sparkline, showBaseline: v as boolean }
|
|
120
|
-
}
|
|
121
|
+
setValue: (c, v) => ({
|
|
122
|
+
...c,
|
|
123
|
+
display: {
|
|
124
|
+
...c.display,
|
|
125
|
+
sparkline: { ...c.display.sparkline, showBaseline: v as boolean },
|
|
126
|
+
},
|
|
121
127
|
}),
|
|
122
128
|
},
|
|
123
129
|
{
|
|
124
|
-
key:
|
|
125
|
-
label:
|
|
126
|
-
category:
|
|
127
|
-
type:
|
|
130
|
+
key: "theme",
|
|
131
|
+
label: "Theme",
|
|
132
|
+
category: "display",
|
|
133
|
+
type: "select",
|
|
128
134
|
options: [], // Handled by ThemePicker
|
|
129
135
|
getValue: (c) => c.display.theme,
|
|
130
136
|
setValue: (c) => c, // Handled by ThemePicker
|
|
131
137
|
},
|
|
132
138
|
{
|
|
133
|
-
key:
|
|
134
|
-
label:
|
|
135
|
-
category:
|
|
136
|
-
type:
|
|
139
|
+
key: "dailyBudget",
|
|
140
|
+
label: "Daily Budget ($)",
|
|
141
|
+
category: "budgets",
|
|
142
|
+
type: "number",
|
|
137
143
|
getValue: (c) => c.budgets.daily,
|
|
138
144
|
setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, daily: v as number | null } }),
|
|
139
145
|
},
|
|
140
146
|
{
|
|
141
|
-
key:
|
|
142
|
-
label:
|
|
143
|
-
category:
|
|
144
|
-
type:
|
|
147
|
+
key: "weeklyBudget",
|
|
148
|
+
label: "Weekly Budget ($)",
|
|
149
|
+
category: "budgets",
|
|
150
|
+
type: "number",
|
|
145
151
|
getValue: (c) => c.budgets.weekly,
|
|
146
152
|
setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, weekly: v as number | null } }),
|
|
147
153
|
},
|
|
148
154
|
{
|
|
149
|
-
key:
|
|
150
|
-
label:
|
|
151
|
-
category:
|
|
152
|
-
type:
|
|
155
|
+
key: "monthlyBudget",
|
|
156
|
+
label: "Monthly Budget ($)",
|
|
157
|
+
category: "budgets",
|
|
158
|
+
type: "number",
|
|
153
159
|
getValue: (c) => c.budgets.monthly,
|
|
154
160
|
setValue: (c, v) => ({ ...c, budgets: { ...c.budgets, monthly: v as number | null } }),
|
|
155
161
|
},
|
|
156
162
|
{
|
|
157
|
-
key:
|
|
158
|
-
label:
|
|
159
|
-
category:
|
|
160
|
-
type:
|
|
161
|
-
options: [
|
|
163
|
+
key: "warningPercent",
|
|
164
|
+
label: "Warning Threshold (%)",
|
|
165
|
+
category: "alerts",
|
|
166
|
+
type: "select",
|
|
167
|
+
options: ["55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%"],
|
|
162
168
|
getValue: (c) => `${c.alerts.warningPercent}%`,
|
|
163
169
|
setValue: (c, v) => {
|
|
164
|
-
const percent = parseInt((v as string).replace(
|
|
170
|
+
const percent = parseInt((v as string).replace("%", ""), 10);
|
|
165
171
|
return { ...c, alerts: { ...c.alerts, warningPercent: percent } };
|
|
166
172
|
},
|
|
167
173
|
},
|
|
168
174
|
{
|
|
169
|
-
key:
|
|
170
|
-
label:
|
|
171
|
-
category:
|
|
172
|
-
type:
|
|
173
|
-
options: [
|
|
175
|
+
key: "criticalPercent",
|
|
176
|
+
label: "Critical Threshold (%)",
|
|
177
|
+
category: "alerts",
|
|
178
|
+
type: "select",
|
|
179
|
+
options: ["75%", "80%", "85%", "90%", "95%", "98%", "100%"],
|
|
174
180
|
getValue: (c) => `${c.alerts.criticalPercent}%`,
|
|
175
181
|
setValue: (c, v) => {
|
|
176
|
-
const percent = parseInt((v as string).replace(
|
|
182
|
+
const percent = parseInt((v as string).replace("%", ""), 10);
|
|
177
183
|
return { ...c, alerts: { ...c.alerts, criticalPercent: percent } };
|
|
178
184
|
},
|
|
179
185
|
},
|
|
180
186
|
{
|
|
181
|
-
key:
|
|
182
|
-
label:
|
|
183
|
-
category:
|
|
184
|
-
type:
|
|
187
|
+
key: "toastsEnabled",
|
|
188
|
+
label: "Show Toast Notifications",
|
|
189
|
+
category: "notifications",
|
|
190
|
+
type: "toggle",
|
|
185
191
|
getValue: (c) => c.notifications.toastsEnabled,
|
|
186
|
-
setValue: (c, v) => ({
|
|
192
|
+
setValue: (c, v) => ({
|
|
193
|
+
...c,
|
|
194
|
+
notifications: { ...c.notifications, toastsEnabled: v as boolean },
|
|
195
|
+
}),
|
|
187
196
|
},
|
|
188
197
|
{
|
|
189
|
-
key:
|
|
190
|
-
label:
|
|
191
|
-
category:
|
|
192
|
-
type:
|
|
198
|
+
key: "soundEnabled",
|
|
199
|
+
label: "Sound Alerts",
|
|
200
|
+
category: "notifications",
|
|
201
|
+
type: "toggle",
|
|
193
202
|
getValue: (c) => c.notifications.soundEnabled,
|
|
194
|
-
setValue: (c, v) => ({
|
|
203
|
+
setValue: (c, v) => ({
|
|
204
|
+
...c,
|
|
205
|
+
notifications: { ...c.notifications, soundEnabled: v as boolean },
|
|
206
|
+
}),
|
|
195
207
|
},
|
|
196
208
|
];
|
|
197
209
|
|
|
198
210
|
const CATEGORIES: { id: SettingCategory; label: string }[] = [
|
|
199
|
-
{ id:
|
|
200
|
-
{ id:
|
|
201
|
-
{ id:
|
|
202
|
-
{ id:
|
|
203
|
-
{ id:
|
|
211
|
+
{ id: "refresh", label: "Refresh" },
|
|
212
|
+
{ id: "display", label: "Display" },
|
|
213
|
+
{ id: "budgets", label: "Budgets" },
|
|
214
|
+
{ id: "alerts", label: "Alerts" },
|
|
215
|
+
{ id: "notifications", label: "Notifications" },
|
|
216
|
+
{ id: "plugins", label: "Plugins" },
|
|
204
217
|
];
|
|
205
218
|
|
|
219
|
+
function buildPluginSettings(
|
|
220
|
+
plugins: Array<{ id: string; name: string; configSchema?: Record<string, ConfigField> }>,
|
|
221
|
+
): SettingItem[] {
|
|
222
|
+
const items: SettingItem[] = [];
|
|
223
|
+
for (const plugin of plugins) {
|
|
224
|
+
if (!plugin.configSchema) continue;
|
|
225
|
+
for (const [fieldKey, field] of Object.entries(plugin.configSchema)) {
|
|
226
|
+
const settingKey = `${plugin.id}.${fieldKey}`;
|
|
227
|
+
// Short label for the setting row; long description shown in help area
|
|
228
|
+
const label = field.label ?? fieldKey;
|
|
229
|
+
const description = field.description;
|
|
230
|
+
|
|
231
|
+
const getPluginValue = (c: AppConfig): unknown => {
|
|
232
|
+
const pluginCfg = c.pluginConfig[plugin.id];
|
|
233
|
+
const raw = pluginCfg?.[fieldKey];
|
|
234
|
+
return raw ?? field.default;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const setPluginValue = (c: AppConfig, value: unknown): AppConfig => ({
|
|
238
|
+
...c,
|
|
239
|
+
pluginConfig: {
|
|
240
|
+
...c.pluginConfig,
|
|
241
|
+
[plugin.id]: {
|
|
242
|
+
...(c.pluginConfig[plugin.id] ?? {}),
|
|
243
|
+
[fieldKey]: value,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const baseItem: Omit<SettingItem, "type" | "getValue" | "setValue"> = {
|
|
249
|
+
key: settingKey,
|
|
250
|
+
label,
|
|
251
|
+
...(description != null ? { description } : {}),
|
|
252
|
+
category: "plugins" as SettingCategory,
|
|
253
|
+
pluginId: plugin.id,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (field.type === "boolean") {
|
|
257
|
+
items.push({
|
|
258
|
+
...baseItem,
|
|
259
|
+
type: "toggle",
|
|
260
|
+
getValue: (c) => getPluginValue(c) as boolean,
|
|
261
|
+
setValue: (c, v) => setPluginValue(c, v),
|
|
262
|
+
});
|
|
263
|
+
} else if (field.type === "select" && field.options) {
|
|
264
|
+
items.push({
|
|
265
|
+
...baseItem,
|
|
266
|
+
type: "select",
|
|
267
|
+
options: field.options.map((o) => o.label),
|
|
268
|
+
getValue: (c) => {
|
|
269
|
+
const val = getPluginValue(c) as string;
|
|
270
|
+
const opt = field.options?.find((o) => o.value === val);
|
|
271
|
+
return opt?.label ?? val ?? "";
|
|
272
|
+
},
|
|
273
|
+
setValue: (c, v) => {
|
|
274
|
+
const opt = field.options?.find((o) => o.label === v);
|
|
275
|
+
return setPluginValue(c, opt?.value ?? v);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
} else if (field.type === "number") {
|
|
279
|
+
items.push({
|
|
280
|
+
...baseItem,
|
|
281
|
+
type: "number",
|
|
282
|
+
getValue: (c) => (getPluginValue(c) as number) ?? 0,
|
|
283
|
+
setValue: (c, v) => setPluginValue(c, v),
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
items.push({
|
|
287
|
+
...baseItem,
|
|
288
|
+
type: "select",
|
|
289
|
+
getValue: (c) => String(getPluginValue(c) ?? ""),
|
|
290
|
+
setValue: (c, v) => setPluginValue(c, v),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return items;
|
|
296
|
+
}
|
|
297
|
+
|
|
206
298
|
interface SettingsModalProps {
|
|
207
299
|
onClose: () => void;
|
|
208
300
|
}
|
|
@@ -210,34 +302,104 @@ interface SettingsModalProps {
|
|
|
210
302
|
export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
211
303
|
const colors = useColors();
|
|
212
304
|
const { setPreviewTheme } = useTheme();
|
|
213
|
-
const { themes } = usePlugins();
|
|
305
|
+
const { providers, agents, themes, notifications } = usePlugins();
|
|
214
306
|
const { showToast } = useToastContext();
|
|
215
307
|
const { config, updateConfig, resetToDefaults, saveNow } = useConfig();
|
|
216
308
|
const { demoMode, seed, preset } = useDemoMode();
|
|
217
309
|
const { setInputFocused } = useInputFocus();
|
|
218
310
|
const { width: termWidth, height: termHeight } = useTerminalDimensions();
|
|
219
311
|
|
|
220
|
-
const [selectedCategory, setSelectedCategory] = useState<SettingCategory>(
|
|
312
|
+
const [selectedCategory, setSelectedCategory] = useState<SettingCategory>("refresh");
|
|
221
313
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
222
|
-
const [
|
|
314
|
+
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
|
|
315
|
+
const [isPluginsExpanded, setIsPluginsExpanded] = useState(false);
|
|
316
|
+
const [focusedPane, setFocusedPane] = useState<"categories" | "settings">("categories");
|
|
223
317
|
const [showThemePicker, setShowThemePicker] = useState(false);
|
|
224
|
-
|
|
318
|
+
|
|
225
319
|
const [editingSettingKey, setEditingSettingKey] = useState<string | null>(null);
|
|
226
|
-
const [inputValue, setInputValue] = useState(
|
|
320
|
+
const [inputValue, setInputValue] = useState("");
|
|
227
321
|
|
|
228
322
|
const width = Math.min(termWidth - 4, 100);
|
|
229
323
|
const height = Math.min(termHeight - 4, 28);
|
|
230
|
-
|
|
324
|
+
|
|
231
325
|
// Calculate available height for settings list
|
|
232
|
-
// Modal height - header(1) - footer(1) - outer padding(2) - inner borders(4) - title row with margin(2)
|
|
233
|
-
const
|
|
326
|
+
// Modal height - header(1) - footer(1) - outer padding(2) - inner borders(4) - title row with margin(2) - help area(4)
|
|
327
|
+
const helpAreaHeight = 4; // separator(1) + 3 lines of description text
|
|
328
|
+
const settingsAreaHeight = height - 1 - 1 - 2 - 4 - 2 - helpAreaHeight - (demoMode ? 1 : 0);
|
|
234
329
|
// Each setting takes 2 rows (height=1 + marginBottom=1)
|
|
235
330
|
const visibleSettingsCount = Math.max(1, Math.floor(settingsAreaHeight / 2));
|
|
236
331
|
|
|
237
|
-
const
|
|
332
|
+
const pluginsByType = useMemo(() => {
|
|
333
|
+
const allPlugins = [
|
|
334
|
+
...Array.from(providers.values()).map((p) => p.plugin),
|
|
335
|
+
...agents,
|
|
336
|
+
...themes,
|
|
337
|
+
...notifications,
|
|
338
|
+
].filter((p) => p.configSchema && Object.keys(p.configSchema).length > 0);
|
|
339
|
+
|
|
340
|
+
const grouped = new Map<PluginType, typeof allPlugins>();
|
|
341
|
+
for (const p of allPlugins) {
|
|
342
|
+
const list = grouped.get(p.type) ?? [];
|
|
343
|
+
list.push(p);
|
|
344
|
+
grouped.set(p.type, list);
|
|
345
|
+
}
|
|
346
|
+
// Sort plugins within each group
|
|
347
|
+
for (const list of grouped.values()) {
|
|
348
|
+
list.sort((a, b) => a.name.localeCompare(b.name));
|
|
349
|
+
}
|
|
350
|
+
return grouped;
|
|
351
|
+
}, [providers, agents, themes, notifications]);
|
|
352
|
+
|
|
353
|
+
const availablePlugins = useMemo(() => {
|
|
354
|
+
return [...pluginsByType.values()].flat();
|
|
355
|
+
}, [pluginsByType]);
|
|
356
|
+
|
|
357
|
+
const pluginSettings = useMemo(() => {
|
|
358
|
+
return buildPluginSettings(availablePlugins);
|
|
359
|
+
}, [availablePlugins]);
|
|
360
|
+
|
|
361
|
+
const TYPE_LABELS: Record<PluginType, string> = {
|
|
362
|
+
provider: "Providers",
|
|
363
|
+
agent: "Agents",
|
|
364
|
+
theme: "Themes",
|
|
365
|
+
notification: "Delivery",
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Stable ordering for plugin type groups in sidebar
|
|
369
|
+
const TYPE_ORDER: PluginType[] = ["provider", "agent", "theme", "notification"];
|
|
370
|
+
|
|
371
|
+
type NavItem =
|
|
372
|
+
| { type: "category"; id: SettingCategory; label: string }
|
|
373
|
+
| { type: "pluginTypeHeader"; pluginType: PluginType; label: string }
|
|
374
|
+
| { type: "plugin"; id: string; label: string };
|
|
375
|
+
|
|
376
|
+
const navItems = useMemo<NavItem[]>(() => {
|
|
377
|
+
const items: NavItem[] = [];
|
|
378
|
+
for (const cat of CATEGORIES) {
|
|
379
|
+
items.push({ type: "category", id: cat.id, label: cat.label });
|
|
380
|
+
if (cat.id === "plugins" && isPluginsExpanded) {
|
|
381
|
+
for (const pt of TYPE_ORDER) {
|
|
382
|
+
const pluginsOfType = pluginsByType.get(pt);
|
|
383
|
+
if (!pluginsOfType || pluginsOfType.length === 0) continue;
|
|
384
|
+
items.push({ type: "pluginTypeHeader", pluginType: pt, label: TYPE_LABELS[pt] });
|
|
385
|
+
for (const p of pluginsOfType) {
|
|
386
|
+
items.push({ type: "plugin", id: p.id, label: p.name });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return items;
|
|
392
|
+
}, [isPluginsExpanded, pluginsByType]);
|
|
393
|
+
|
|
394
|
+
const settings = useMemo(() => [...BASE_SETTINGS, ...pluginSettings], [pluginSettings]);
|
|
395
|
+
|
|
396
|
+
const categorySettings = useMemo(() => {
|
|
397
|
+
if (selectedCategory === "plugins" && selectedPluginId) {
|
|
398
|
+
return settings.filter((s) => s.category === "plugins" && s.pluginId === selectedPluginId);
|
|
399
|
+
}
|
|
400
|
+
return settings.filter((s) => s.category === selectedCategory && !s.pluginId);
|
|
401
|
+
}, [settings, selectedCategory, selectedPluginId]);
|
|
238
402
|
|
|
239
|
-
const categorySettings = settings.filter(s => s.category === selectedCategory);
|
|
240
|
-
|
|
241
403
|
// Calculate scroll offset to keep selected item visible
|
|
242
404
|
const scrollOffset = useMemo(() => {
|
|
243
405
|
if (categorySettings.length <= visibleSettingsCount) return 0;
|
|
@@ -245,10 +407,23 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
245
407
|
if (selectedIndex < visibleSettingsCount - 1) return 0;
|
|
246
408
|
return Math.min(selectedIndex - visibleSettingsCount + 2, maxOffset);
|
|
247
409
|
}, [selectedIndex, categorySettings.length, visibleSettingsCount]);
|
|
248
|
-
|
|
410
|
+
|
|
249
411
|
const visibleSettings = categorySettings.slice(scrollOffset, scrollOffset + visibleSettingsCount);
|
|
250
412
|
const hasMoreAbove = scrollOffset > 0;
|
|
251
413
|
const hasMoreBelow = scrollOffset + visibleSettingsCount < categorySettings.length;
|
|
414
|
+
const focusedDescription = useMemo(() => {
|
|
415
|
+
if (focusedPane !== "settings") return null;
|
|
416
|
+
const setting = categorySettings[selectedIndex];
|
|
417
|
+
const desc = setting?.description ?? null;
|
|
418
|
+
if (!desc) return null;
|
|
419
|
+
|
|
420
|
+
// Truncate to fit 3 lines with ellipsis if needed
|
|
421
|
+
// Help area usable width: total width - sidebar(20) - borders(2) - outer padding(2) - help paddingX(2) - inner padding(2)
|
|
422
|
+
const lineWidth = Math.max(20, width - 20 - 2 - 2 - 2 - 2);
|
|
423
|
+
const maxChars = lineWidth * 3;
|
|
424
|
+
if (desc.length <= maxChars) return desc;
|
|
425
|
+
return desc.slice(0, maxChars - 1) + "\u2026";
|
|
426
|
+
}, [focusedPane, categorySettings, selectedIndex, width]);
|
|
252
427
|
|
|
253
428
|
useEffect(() => {
|
|
254
429
|
setInputFocused(true);
|
|
@@ -258,45 +433,45 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
258
433
|
const handleSave = useCallback(async () => {
|
|
259
434
|
try {
|
|
260
435
|
await saveNow();
|
|
261
|
-
showToast(
|
|
436
|
+
showToast("Settings saved");
|
|
262
437
|
} catch {
|
|
263
|
-
showToast(
|
|
438
|
+
showToast("Failed to save settings", "error");
|
|
264
439
|
}
|
|
265
440
|
}, [saveNow, showToast]);
|
|
266
441
|
|
|
267
442
|
const handleReset = useCallback(() => {
|
|
268
443
|
resetToDefaults();
|
|
269
|
-
showToast(
|
|
444
|
+
showToast("Reset to defaults");
|
|
270
445
|
}, [resetToDefaults, showToast]);
|
|
271
446
|
|
|
272
447
|
const startEditingNumber = useCallback(() => {
|
|
273
448
|
const setting = categorySettings[selectedIndex];
|
|
274
|
-
if (!setting || setting.type !==
|
|
275
|
-
|
|
449
|
+
if (!setting || setting.type !== "number") return;
|
|
450
|
+
|
|
276
451
|
const currentValue = setting.getValue(config);
|
|
277
|
-
setInputValue(currentValue === null ?
|
|
452
|
+
setInputValue(currentValue === null ? "" : String(currentValue));
|
|
278
453
|
setEditingSettingKey(setting.key);
|
|
279
454
|
}, [categorySettings, selectedIndex, config]);
|
|
280
455
|
|
|
281
456
|
const commitNumberEdit = useCallback(() => {
|
|
282
457
|
if (editingSettingKey === null) return;
|
|
283
|
-
|
|
284
|
-
const setting = categorySettings.find(s => s.key === editingSettingKey);
|
|
285
|
-
if (!setting || setting.type !==
|
|
458
|
+
|
|
459
|
+
const setting = categorySettings.find((s) => s.key === editingSettingKey);
|
|
460
|
+
if (!setting || setting.type !== "number") {
|
|
286
461
|
setEditingSettingKey(null);
|
|
287
|
-
setInputValue(
|
|
462
|
+
setInputValue("");
|
|
288
463
|
return;
|
|
289
464
|
}
|
|
290
|
-
|
|
291
|
-
const parsed = inputValue.trim() ===
|
|
465
|
+
|
|
466
|
+
const parsed = inputValue.trim() === "" ? null : parseCurrencyInput(inputValue);
|
|
292
467
|
updateConfig(setting.setValue(config, parsed));
|
|
293
468
|
setEditingSettingKey(null);
|
|
294
|
-
setInputValue(
|
|
469
|
+
setInputValue("");
|
|
295
470
|
}, [editingSettingKey, categorySettings, inputValue, config, updateConfig]);
|
|
296
471
|
|
|
297
472
|
const cancelNumberEdit = useCallback(() => {
|
|
298
473
|
setEditingSettingKey(null);
|
|
299
|
-
setInputValue(
|
|
474
|
+
setInputValue("");
|
|
300
475
|
}, []);
|
|
301
476
|
|
|
302
477
|
const toggleCurrentSetting = useCallback(() => {
|
|
@@ -306,9 +481,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
306
481
|
const currentValue = setting.getValue(config);
|
|
307
482
|
let newConfig: AppConfig;
|
|
308
483
|
|
|
309
|
-
if (setting.type ===
|
|
484
|
+
if (setting.type === "toggle") {
|
|
310
485
|
newConfig = setting.setValue(config, !currentValue);
|
|
311
|
-
} else if (setting.type ===
|
|
486
|
+
} else if (setting.type === "select" && setting.options && setting.options.length > 0) {
|
|
312
487
|
const currentIdx = setting.options.indexOf(currentValue as string);
|
|
313
488
|
const nextIdx = (currentIdx + 1) % setting.options.length;
|
|
314
489
|
const newValue = setting.options[nextIdx]!;
|
|
@@ -324,90 +499,147 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
324
499
|
if (showThemePicker) return;
|
|
325
500
|
|
|
326
501
|
if (editingSettingKey !== null) {
|
|
327
|
-
if (key.ctrl && (key.name ===
|
|
502
|
+
if (key.ctrl && (key.name === "p" || key.name === "s")) {
|
|
328
503
|
return;
|
|
329
504
|
}
|
|
330
|
-
if (key.name ===
|
|
505
|
+
if (key.name === "escape") {
|
|
331
506
|
cancelNumberEdit();
|
|
332
507
|
return;
|
|
333
508
|
}
|
|
334
|
-
if (key.name ===
|
|
509
|
+
if (key.name === "return") {
|
|
335
510
|
commitNumberEdit();
|
|
336
511
|
return;
|
|
337
512
|
}
|
|
338
|
-
if (key.name ===
|
|
339
|
-
setInputValue(prev => prev.slice(0, -1));
|
|
513
|
+
if (key.name === "backspace") {
|
|
514
|
+
setInputValue((prev) => prev.slice(0, -1));
|
|
340
515
|
return;
|
|
341
516
|
}
|
|
342
517
|
if (key.sequence && /^[0-9.]$/.test(key.sequence)) {
|
|
343
|
-
setInputValue(prev => prev + key.sequence);
|
|
518
|
+
setInputValue((prev) => prev + key.sequence);
|
|
344
519
|
return;
|
|
345
520
|
}
|
|
346
521
|
return;
|
|
347
522
|
}
|
|
348
523
|
|
|
349
|
-
if (key.name ===
|
|
524
|
+
if (key.name === "escape") {
|
|
350
525
|
onClose();
|
|
351
526
|
return;
|
|
352
527
|
}
|
|
353
528
|
|
|
354
|
-
if (key.ctrl && key.name ===
|
|
529
|
+
if (key.ctrl && key.name === "s") {
|
|
355
530
|
handleSave();
|
|
356
531
|
return;
|
|
357
532
|
}
|
|
358
533
|
|
|
359
|
-
if (key.shift && key.name ===
|
|
534
|
+
if (key.shift && key.name === "r") {
|
|
360
535
|
handleReset();
|
|
361
536
|
return;
|
|
362
537
|
}
|
|
363
538
|
|
|
364
|
-
if (key.name ===
|
|
365
|
-
setFocusedPane(p => p ===
|
|
539
|
+
if (key.name === "tab") {
|
|
540
|
+
setFocusedPane((p) => (p === "categories" ? "settings" : "categories"));
|
|
366
541
|
return;
|
|
367
542
|
}
|
|
368
543
|
|
|
369
|
-
if (focusedPane ===
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
544
|
+
if (focusedPane === "categories") {
|
|
545
|
+
// Find current position — skip headers when matching
|
|
546
|
+
const currentIdx = navItems.findIndex((item) =>
|
|
547
|
+
item.type === "category"
|
|
548
|
+
? item.id === selectedCategory && (item.id !== "plugins" || !selectedPluginId)
|
|
549
|
+
: item.type === "plugin"
|
|
550
|
+
? item.id === selectedPluginId
|
|
551
|
+
: false,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// Helper: find next focusable item (skip pluginTypeHeader)
|
|
555
|
+
const findNext = (from: number, dir: 1 | -1): number => {
|
|
556
|
+
let idx = from + dir;
|
|
557
|
+
while (idx >= 0 && idx < navItems.length) {
|
|
558
|
+
if (navItems[idx]!.type !== "pluginTypeHeader") return idx;
|
|
559
|
+
idx += dir;
|
|
560
|
+
}
|
|
561
|
+
return from; // no focusable item found, stay put
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
if (key.name === "down" || key.name === "j") {
|
|
565
|
+
const nextIdx = findNext(currentIdx, 1);
|
|
566
|
+
const nextItem = navItems[nextIdx];
|
|
567
|
+
if (nextItem && nextIdx !== currentIdx) {
|
|
568
|
+
if (nextItem.type === "category") {
|
|
569
|
+
setSelectedCategory(nextItem.id);
|
|
570
|
+
setSelectedPluginId(null);
|
|
571
|
+
if (nextItem.id === "plugins") {
|
|
572
|
+
setIsPluginsExpanded(true);
|
|
573
|
+
}
|
|
574
|
+
} else if (nextItem.type === "plugin") {
|
|
575
|
+
setSelectedPluginId(nextItem.id);
|
|
576
|
+
}
|
|
577
|
+
setSelectedIndex(0);
|
|
578
|
+
}
|
|
579
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
580
|
+
const prevIdx = findNext(currentIdx, -1);
|
|
581
|
+
const prevItem = navItems[prevIdx];
|
|
582
|
+
if (prevItem && prevIdx !== currentIdx) {
|
|
583
|
+
if (prevItem.type === "category") {
|
|
584
|
+
setSelectedCategory(prevItem.id);
|
|
585
|
+
setSelectedPluginId(null);
|
|
586
|
+
// Collapse plugins if navigating up from first plugin child to Plugins parent
|
|
587
|
+
if (prevItem.id === "plugins" && navItems[currentIdx]?.type === "plugin") {
|
|
588
|
+
setIsPluginsExpanded(false);
|
|
589
|
+
}
|
|
590
|
+
} else if (prevItem.type === "plugin") {
|
|
591
|
+
setSelectedPluginId(prevItem.id);
|
|
592
|
+
}
|
|
593
|
+
setSelectedIndex(0);
|
|
594
|
+
}
|
|
595
|
+
} else if (key.name === "return" || key.name === "right" || key.name === "l") {
|
|
596
|
+
const currentItem = navItems[currentIdx];
|
|
597
|
+
if (currentItem?.type === "category" && currentItem.id === "plugins") {
|
|
598
|
+
if (!isPluginsExpanded) {
|
|
599
|
+
setIsPluginsExpanded(true);
|
|
600
|
+
} else if (key.name === "return") {
|
|
601
|
+
setIsPluginsExpanded((p) => !p);
|
|
602
|
+
}
|
|
603
|
+
} else if (selectedPluginId || selectedCategory !== "plugins") {
|
|
604
|
+
setFocusedPane("settings");
|
|
605
|
+
}
|
|
606
|
+
} else if (key.name === "left" || key.name === "h") {
|
|
607
|
+
if (selectedPluginId) {
|
|
608
|
+
setSelectedPluginId(null);
|
|
609
|
+
setSelectedIndex(0);
|
|
610
|
+
} else if (selectedCategory === "plugins" && isPluginsExpanded) {
|
|
611
|
+
setIsPluginsExpanded(false);
|
|
612
|
+
}
|
|
381
613
|
}
|
|
382
614
|
return;
|
|
383
615
|
}
|
|
384
616
|
|
|
385
|
-
if (focusedPane ===
|
|
386
|
-
if (key.name ===
|
|
387
|
-
setSelectedIndex(i => Math.min(i + 1, categorySettings.length - 1));
|
|
388
|
-
} else if (key.name ===
|
|
389
|
-
setSelectedIndex(i => Math.max(i - 1, 0));
|
|
390
|
-
} else if (key.name ===
|
|
617
|
+
if (focusedPane === "settings") {
|
|
618
|
+
if (key.name === "down" || key.name === "j") {
|
|
619
|
+
setSelectedIndex((i) => Math.min(i + 1, categorySettings.length - 1));
|
|
620
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
621
|
+
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
622
|
+
} else if (key.name === "return" || key.name === "space") {
|
|
391
623
|
const setting = categorySettings[selectedIndex];
|
|
392
|
-
if (setting?.key ===
|
|
624
|
+
if (setting?.key === "theme") {
|
|
393
625
|
setShowThemePicker(true);
|
|
394
|
-
} else if (setting?.type ===
|
|
626
|
+
} else if (setting?.type === "number") {
|
|
395
627
|
startEditingNumber();
|
|
396
628
|
} else {
|
|
397
629
|
toggleCurrentSetting();
|
|
398
630
|
}
|
|
399
|
-
} else if (key.name ===
|
|
631
|
+
} else if (key.name === "left" || key.name === "h") {
|
|
400
632
|
const setting = categorySettings[selectedIndex];
|
|
401
|
-
if (setting?.type ===
|
|
633
|
+
if (setting?.type === "select" && setting.options && setting.options.length > 0) {
|
|
402
634
|
const currentValue = setting.getValue(config) as string;
|
|
403
635
|
const currentIdx = setting.options.indexOf(currentValue);
|
|
404
636
|
const prevIdx = (currentIdx - 1 + setting.options.length) % setting.options.length;
|
|
405
637
|
const newValue = setting.options[prevIdx]!;
|
|
406
638
|
updateConfig(setting.setValue(config, newValue));
|
|
407
639
|
}
|
|
408
|
-
} else if (key.name ===
|
|
640
|
+
} else if (key.name === "right" || key.name === "l") {
|
|
409
641
|
const setting = categorySettings[selectedIndex];
|
|
410
|
-
if (setting?.type ===
|
|
642
|
+
if (setting?.type === "select" && setting.options && setting.options.length > 0) {
|
|
411
643
|
const currentValue = setting.getValue(config) as string;
|
|
412
644
|
const currentIdx = setting.options.indexOf(currentValue);
|
|
413
645
|
const nextIdx = (currentIdx + 1) % setting.options.length;
|
|
@@ -430,23 +662,25 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
430
662
|
backgroundColor={colors.background}
|
|
431
663
|
overflow="hidden"
|
|
432
664
|
>
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
665
|
+
<box
|
|
666
|
+
flexDirection="row"
|
|
667
|
+
justifyContent="space-between"
|
|
668
|
+
paddingX={1}
|
|
669
|
+
backgroundColor={colors.foreground}
|
|
670
|
+
height={1}
|
|
671
|
+
flexShrink={0}
|
|
672
|
+
>
|
|
673
|
+
<text fg={colors.primary}>
|
|
674
|
+
<strong>SETTINGS</strong>
|
|
675
|
+
</text>
|
|
676
|
+
<text fg={colors.textSubtle}>Esc:close ^S:save Shift+R:reset</text>
|
|
677
|
+
</box>
|
|
444
678
|
|
|
445
679
|
{demoMode && (
|
|
446
680
|
<box flexDirection="row" height={1} paddingLeft={1} gap={2}>
|
|
447
681
|
<text fg={colors.warning}>DEMO MODE</text>
|
|
448
682
|
<text fg={colors.textMuted}>
|
|
449
|
-
Seed: {seed ??
|
|
683
|
+
Seed: {seed ?? "default"} | Preset: {preset ?? "normal"}
|
|
450
684
|
</text>
|
|
451
685
|
</box>
|
|
452
686
|
)}
|
|
@@ -456,20 +690,71 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
456
690
|
flexDirection="column"
|
|
457
691
|
width={20}
|
|
458
692
|
border
|
|
459
|
-
borderStyle={focusedPane ===
|
|
460
|
-
borderColor={focusedPane ===
|
|
693
|
+
borderStyle={focusedPane === "categories" ? "double" : "single"}
|
|
694
|
+
borderColor={focusedPane === "categories" ? colors.primary : colors.border}
|
|
461
695
|
padding={1}
|
|
462
696
|
>
|
|
463
|
-
{
|
|
464
|
-
|
|
465
|
-
|
|
697
|
+
{navItems.map((item) => {
|
|
698
|
+
if (item.type === "pluginTypeHeader") {
|
|
699
|
+
// Non-interactive type group header with · prefix
|
|
700
|
+
return (
|
|
701
|
+
<box key={`type-${item.pluginType}`} height={1}>
|
|
702
|
+
<text fg={colors.textSubtle}>{` · ${item.label}`}</text>
|
|
703
|
+
</box>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const isCategory = item.type === "category";
|
|
708
|
+
const isPlugin = item.type === "plugin";
|
|
709
|
+
const isActive = isCategory
|
|
710
|
+
? item.id === selectedCategory && (!selectedPluginId || item.id !== "plugins")
|
|
711
|
+
: isPlugin
|
|
712
|
+
? item.id === selectedPluginId
|
|
713
|
+
: false;
|
|
714
|
+
const isFocusedActive = isActive && focusedPane === "categories";
|
|
715
|
+
|
|
716
|
+
let prefix = " ";
|
|
717
|
+
if (isCategory) {
|
|
718
|
+
if (item.id === "plugins") {
|
|
719
|
+
prefix = isPluginsExpanded ? "▾ " : isActive ? "▸ " : " ";
|
|
720
|
+
} else {
|
|
721
|
+
prefix = isActive ? "> " : " ";
|
|
722
|
+
}
|
|
723
|
+
} else if (isPlugin) {
|
|
724
|
+
prefix = isActive ? " > " : " ";
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Smart truncation: preserve last word when possible
|
|
728
|
+
const maxLabelWidth = 18 - prefix.length;
|
|
729
|
+
let label = item.label;
|
|
730
|
+
if (label.length > maxLabelWidth) {
|
|
731
|
+
const words = label.split(" ");
|
|
732
|
+
if (words.length > 1) {
|
|
733
|
+
const lastWord = words[words.length - 1];
|
|
734
|
+
// Try to fit "X… LastWord" format
|
|
735
|
+
const ellipsisAndLast = `… ${lastWord}`;
|
|
736
|
+
if (ellipsisAndLast.length < maxLabelWidth) {
|
|
737
|
+
const firstPartLen = maxLabelWidth - ellipsisAndLast.length;
|
|
738
|
+
label = label.slice(0, firstPartLen) + ellipsisAndLast;
|
|
739
|
+
} else {
|
|
740
|
+
// Last word alone is too long, just truncate
|
|
741
|
+
label = label.slice(0, maxLabelWidth - 1) + "…";
|
|
742
|
+
}
|
|
743
|
+
} else {
|
|
744
|
+
label = label.slice(0, maxLabelWidth - 1) + "…";
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
466
748
|
return (
|
|
467
|
-
<box key={
|
|
749
|
+
<box key={`${item.type}-${item.id}`} height={1}>
|
|
468
750
|
<text
|
|
469
|
-
fg={
|
|
751
|
+
fg={
|
|
752
|
+
isFocusedActive ? colors.background : isActive ? colors.primary : colors.text
|
|
753
|
+
}
|
|
470
754
|
{...(isFocusedActive ? { bg: colors.primary } : {})}
|
|
471
755
|
>
|
|
472
|
-
{
|
|
756
|
+
{prefix}
|
|
757
|
+
{label}
|
|
473
758
|
</text>
|
|
474
759
|
</box>
|
|
475
760
|
);
|
|
@@ -480,8 +765,8 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
480
765
|
flexDirection="column"
|
|
481
766
|
flexGrow={1}
|
|
482
767
|
border
|
|
483
|
-
borderStyle={focusedPane ===
|
|
484
|
-
borderColor={focusedPane ===
|
|
768
|
+
borderStyle={focusedPane === "settings" ? "double" : "single"}
|
|
769
|
+
borderColor={focusedPane === "settings" ? colors.primary : colors.border}
|
|
485
770
|
padding={0}
|
|
486
771
|
overflow="hidden"
|
|
487
772
|
>
|
|
@@ -508,79 +793,118 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
508
793
|
}}
|
|
509
794
|
/>
|
|
510
795
|
) : (
|
|
511
|
-
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
796
|
+
<box flexDirection="column" flexGrow={1} padding={1} justifyContent="space-between">
|
|
797
|
+
{/* Settings list area */}
|
|
798
|
+
<box flexDirection="column" flexGrow={1}>
|
|
799
|
+
<box
|
|
800
|
+
flexDirection="row"
|
|
801
|
+
justifyContent="space-between"
|
|
802
|
+
marginBottom={1}
|
|
803
|
+
height={1}
|
|
804
|
+
>
|
|
805
|
+
<text fg={colors.textMuted}>
|
|
806
|
+
{selectedCategory === "plugins" && selectedPluginId
|
|
807
|
+
? availablePlugins
|
|
808
|
+
.find((p) => p.id === selectedPluginId)
|
|
809
|
+
?.name.toUpperCase()
|
|
810
|
+
: CATEGORIES.find((c) => c.id === selectedCategory)?.label.toUpperCase()}
|
|
811
|
+
</text>
|
|
812
|
+
{(hasMoreAbove || hasMoreBelow) && (
|
|
813
|
+
<text fg={colors.textSubtle}>
|
|
814
|
+
{hasMoreAbove ? "▲" : " "}
|
|
815
|
+
{hasMoreBelow ? "▼" : " "}
|
|
816
|
+
</text>
|
|
817
|
+
)}
|
|
818
|
+
</box>
|
|
819
|
+
|
|
820
|
+
{visibleSettings.length === 0 && (
|
|
821
|
+
<text fg={colors.textMuted}>
|
|
822
|
+
{selectedCategory === "plugins" && !selectedPluginId
|
|
823
|
+
? "Select a plugin to configure"
|
|
824
|
+
: "No settings available"}
|
|
519
825
|
</text>
|
|
520
826
|
)}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
827
|
+
{visibleSettings.map((setting) => {
|
|
828
|
+
const realIdx = categorySettings.findIndex((s) => s.key === setting.key);
|
|
829
|
+
const isSelected = realIdx === selectedIndex && focusedPane === "settings";
|
|
830
|
+
const value = setting.getValue(config);
|
|
831
|
+
const isEditingThis =
|
|
832
|
+
setting.type === "number" && editingSettingKey === setting.key;
|
|
833
|
+
|
|
834
|
+
let displayValue: string;
|
|
835
|
+
if (setting.key === "theme") {
|
|
836
|
+
const themeName = themes.find((t) => t.id === value)?.name ?? value;
|
|
837
|
+
displayValue = `${themeName} ▸`;
|
|
838
|
+
} else if (setting.type === "toggle") {
|
|
839
|
+
displayValue = value ? "● ON" : "○ OFF";
|
|
840
|
+
} else if (setting.type === "select") {
|
|
841
|
+
displayValue = `◂ ${value} ▸`;
|
|
842
|
+
} else if (setting.type === "number" && !isEditingThis) {
|
|
843
|
+
displayValue =
|
|
844
|
+
setting.category === "budgets"
|
|
845
|
+
? formatBudgetDisplay(value as number | null)
|
|
846
|
+
: value === null
|
|
847
|
+
? "—"
|
|
848
|
+
: String(value);
|
|
849
|
+
} else {
|
|
850
|
+
displayValue = String(value);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return (
|
|
854
|
+
<box
|
|
855
|
+
key={setting.key}
|
|
856
|
+
flexDirection="row"
|
|
857
|
+
height={1}
|
|
858
|
+
marginBottom={1}
|
|
859
|
+
paddingX={1}
|
|
860
|
+
{...(isSelected && !isEditingThis
|
|
861
|
+
? { backgroundColor: colors.primary }
|
|
862
|
+
: {})}
|
|
555
863
|
>
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
864
|
+
<text
|
|
865
|
+
flexGrow={1}
|
|
866
|
+
fg={isSelected && !isEditingThis ? colors.background : colors.text}
|
|
867
|
+
>
|
|
868
|
+
{setting.label}
|
|
561
869
|
</text>
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
870
|
+
{isEditingThis ? (
|
|
871
|
+
<text fg={colors.text}>
|
|
872
|
+
${inputValue}
|
|
873
|
+
<span fg={colors.primary}>█</span>
|
|
874
|
+
</text>
|
|
875
|
+
) : (
|
|
876
|
+
<text fg={isSelected ? colors.background : colors.textMuted}>
|
|
877
|
+
{displayValue}
|
|
878
|
+
</text>
|
|
879
|
+
)}
|
|
880
|
+
</box>
|
|
881
|
+
);
|
|
882
|
+
})}
|
|
883
|
+
</box>
|
|
884
|
+
|
|
885
|
+
{/* Help area — shows focused setting's description */}
|
|
886
|
+
<box height={helpAreaHeight} flexDirection="column" paddingX={1}>
|
|
887
|
+
<box height={1}>
|
|
888
|
+
<text fg={colors.border}>{"─".repeat(40)}</text>
|
|
889
|
+
</box>
|
|
890
|
+
<text fg={colors.textSubtle} height={3}>
|
|
891
|
+
{focusedDescription ?? " "}
|
|
892
|
+
</text>
|
|
893
|
+
</box>
|
|
570
894
|
</box>
|
|
571
895
|
)}
|
|
572
896
|
</box>
|
|
573
897
|
</box>
|
|
574
898
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
899
|
+
<box flexDirection="row" height={1} paddingX={1} backgroundColor={colors.foreground}>
|
|
900
|
+
<text fg={colors.textSubtle}>
|
|
901
|
+
{editingSettingKey !== null
|
|
902
|
+
? "Type value Enter:save Esc:cancel"
|
|
903
|
+
: showThemePicker
|
|
904
|
+
? "↑↓:navigate ←→:scheme Tab:switch Enter:apply Esc:cancel"
|
|
905
|
+
: "Tab:switch ↑↓:navigate ←→:adjust Enter:edit"}
|
|
906
|
+
</text>
|
|
907
|
+
</box>
|
|
584
908
|
</box>
|
|
585
909
|
</ModalBackdrop>
|
|
586
910
|
);
|