@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.
Files changed (160) hide show
  1. package/README.md +2 -5
  2. package/package.json +8 -5
  3. package/src/agents/aggregator.ts +39 -19
  4. package/src/agents/costing.ts +11 -9
  5. package/src/agents/index.ts +7 -9
  6. package/src/agents/types.ts +2 -2
  7. package/src/cli.ts +28 -23
  8. package/src/config/schema.test.ts +42 -40
  9. package/src/config/schema.ts +26 -26
  10. package/src/demo/simulator.ts +161 -122
  11. package/src/plugins/agents/index.ts +2 -2
  12. package/src/plugins/auth-sources.ts +42 -38
  13. package/src/plugins/lifecycle.ts +42 -38
  14. package/src/plugins/loader.test.ts +49 -49
  15. package/src/plugins/loader.ts +67 -63
  16. package/src/plugins/notification-bus.ts +21 -16
  17. package/src/plugins/notifications/index.ts +2 -2
  18. package/src/plugins/notifications/terminal-bell.ts +36 -23
  19. package/src/plugins/notifications/visual-flash.ts +31 -19
  20. package/src/plugins/npm-installer.ts +23 -25
  21. package/src/plugins/plugin-context-factory.ts +10 -10
  22. package/src/plugins/plugin-host.ts +12 -5
  23. package/src/plugins/providers/anthropic.ts +64 -57
  24. package/src/plugins/providers/antigravity.ts +81 -69
  25. package/src/plugins/providers/codex.ts +42 -42
  26. package/src/plugins/providers/gemini.ts +106 -87
  27. package/src/plugins/providers/github-copilot.ts +49 -45
  28. package/src/plugins/providers/index.ts +10 -12
  29. package/src/plugins/providers/minimax.ts +50 -44
  30. package/src/plugins/providers/openai-api.ts +82 -62
  31. package/src/plugins/providers/opencode-zen.ts +46 -39
  32. package/src/plugins/providers/perplexity.ts +55 -46
  33. package/src/plugins/providers/zai.ts +68 -57
  34. package/src/plugins/registry.ts +80 -71
  35. package/src/plugins/sandbox-guard.test.ts +45 -48
  36. package/src/plugins/sandbox-guard.ts +20 -25
  37. package/src/plugins/sandbox.ts +26 -48
  38. package/src/plugins/themes/catppuccin-latte.ts +29 -29
  39. package/src/plugins/themes/catppuccin-mocha.ts +29 -29
  40. package/src/plugins/themes/claude-code.ts +29 -29
  41. package/src/plugins/themes/dracula.ts +29 -29
  42. package/src/plugins/themes/github-light.ts +29 -29
  43. package/src/plugins/themes/gruvbox-dark.ts +29 -29
  44. package/src/plugins/themes/gruvbox-light.ts +29 -29
  45. package/src/plugins/themes/index.ts +15 -15
  46. package/src/plugins/themes/kanagawa.ts +29 -29
  47. package/src/plugins/themes/nord.ts +29 -29
  48. package/src/plugins/themes/one-dark.ts +29 -29
  49. package/src/plugins/themes/opencode.ts +31 -31
  50. package/src/plugins/themes/rose-pine-dawn.ts +29 -29
  51. package/src/plugins/themes/rose-pine.ts +29 -29
  52. package/src/plugins/themes/solarized-light.ts +29 -29
  53. package/src/plugins/themes/tokyo-night.ts +29 -29
  54. package/src/plugins/types/agent.ts +5 -5
  55. package/src/plugins/types/base.ts +35 -24
  56. package/src/plugins/types/index.ts +9 -9
  57. package/src/plugins/types/notification.ts +12 -12
  58. package/src/plugins/types/provider.ts +12 -8
  59. package/src/plugins/types/theme.ts +31 -23
  60. package/src/plugins/update-checker.ts +6 -6
  61. package/src/pricing/estimator.test.ts +39 -44
  62. package/src/pricing/estimator.ts +7 -13
  63. package/src/pricing/fallback.test.ts +30 -34
  64. package/src/pricing/fallback.ts +54 -27
  65. package/src/pricing/index.ts +18 -12
  66. package/src/pricing/models-dev.ts +6 -6
  67. package/src/storage/database.ts +56 -46
  68. package/src/storage/db.ts +12 -12
  69. package/src/storage/index.ts +44 -45
  70. package/src/storage/migrations/index.ts +2 -2
  71. package/src/storage/paths.ts +13 -13
  72. package/src/storage/persistence-service.ts +206 -0
  73. package/src/storage/repos/agentSessions.ts +33 -23
  74. package/src/storage/repos/providerSnapshots.ts +5 -5
  75. package/src/storage/repos/usageEvents.ts +40 -28
  76. package/src/storage/retention.ts +146 -0
  77. package/src/storage/types.ts +5 -5
  78. package/src/tui/App.tsx +180 -137
  79. package/src/tui/components/CommandPalette.tsx +46 -34
  80. package/src/tui/components/DebugConsole.tsx +39 -33
  81. package/src/tui/components/DebugPanel.tsx +314 -183
  82. package/src/tui/components/FooterHints.tsx +2 -2
  83. package/src/tui/components/GhostProviderCard.tsx +37 -35
  84. package/src/tui/components/Header.tsx +68 -35
  85. package/src/tui/components/HelpOverlay.tsx +99 -29
  86. package/src/tui/components/InlineGauge.tsx +10 -7
  87. package/src/tui/components/InlineSparkline.tsx +17 -11
  88. package/src/tui/components/KpiStrip.tsx +128 -98
  89. package/src/tui/components/LimitGauge.tsx +96 -59
  90. package/src/tui/components/ModalBackdrop.tsx +4 -4
  91. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  92. package/src/tui/components/ProviderCard.tsx +232 -226
  93. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  94. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  95. package/src/tui/components/ProvidersList.tsx +215 -126
  96. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  97. package/src/tui/components/SessionsTable.tsx +320 -169
  98. package/src/tui/components/SettingsModal.tsx +593 -269
  99. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  100. package/src/tui/components/Skeleton.tsx +24 -17
  101. package/src/tui/components/SmartSidebar.tsx +360 -258
  102. package/src/tui/components/Sparkline.tsx +64 -52
  103. package/src/tui/components/Spinner.tsx +3 -3
  104. package/src/tui/components/StatusBar.tsx +27 -27
  105. package/src/tui/components/ThemePicker.tsx +67 -66
  106. package/src/tui/components/Toast.tsx +9 -6
  107. package/src/tui/components/UsageGauge.tsx +10 -13
  108. package/src/tui/components/index.ts +5 -5
  109. package/src/tui/contexts/AgentSessionContext.tsx +164 -151
  110. package/src/tui/contexts/ConfigContext.tsx +15 -13
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  112. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  113. package/src/tui/contexts/DrawerContext.tsx +11 -9
  114. package/src/tui/contexts/InputContext.tsx +2 -2
  115. package/src/tui/contexts/LogContext.tsx +62 -47
  116. package/src/tui/contexts/PluginContext.tsx +238 -183
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  118. package/src/tui/contexts/StorageContext.tsx +95 -149
  119. package/src/tui/contexts/ThemeContext.tsx +21 -16
  120. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  121. package/src/tui/contexts/ToastContext.tsx +10 -7
  122. package/src/tui/contexts/index.ts +9 -5
  123. package/src/tui/createApp.tsx +8 -12
  124. package/src/tui/debug/captureFrame.ts +25 -28
  125. package/src/tui/debug/snapshot.tsx +179 -162
  126. package/src/tui/driver/assertions.ts +30 -22
  127. package/src/tui/driver/cli.ts +231 -221
  128. package/src/tui/driver/coverage.ts +27 -44
  129. package/src/tui/driver/demo-snapshot.ts +33 -33
  130. package/src/tui/driver/diff.ts +26 -32
  131. package/src/tui/driver/driver.ts +43 -48
  132. package/src/tui/driver/index.ts +36 -40
  133. package/src/tui/driver/recorder.ts +37 -48
  134. package/src/tui/driver/test-workflow.ts +51 -51
  135. package/src/tui/driver/test.ts +25 -25
  136. package/src/tui/hooks/index.ts +24 -20
  137. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  138. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  139. package/src/tui/hooks/useDashboardState.ts +26 -24
  140. package/src/tui/hooks/useEmaActivity.ts +82 -74
  141. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  142. package/src/tui/hooks/useExitAnimation.ts +7 -3
  143. package/src/tui/hooks/usePulse.ts +4 -4
  144. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  145. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  146. package/src/tui/hooks/useValueFlash.ts +10 -14
  147. package/src/tui/index.tsx +29 -6
  148. package/src/tui/shutdown.ts +19 -0
  149. package/src/tui/utils/providerColor.ts +1 -1
  150. package/src/tui/views/Dashboard.tsx +164 -102
  151. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  152. package/src/tui/views/ProjectsView.tsx +495 -243
  153. package/src/tui/views/RealTimeDashboard.tsx +176 -116
  154. package/src/tui/views/SettingsView.tsx +225 -189
  155. package/src/tui/views/index.ts +2 -2
  156. package/src/types/opentui-test-utils.d.ts +7 -7
  157. package/src/utils/clipboard.ts +29 -29
  158. package/src/utils/currency.test.ts +53 -53
  159. package/src/utils/currency.ts +35 -32
  160. package/src/version.ts +1 -1
@@ -1,23 +1,26 @@
1
- import { useState, useCallback, useEffect, useMemo } from 'react';
2
- import { useKeyboard, useTerminalDimensions } from '@opentui/react';
3
- import { useColors, useTheme } from '../contexts/ThemeContext.tsx';
4
- import { usePlugins } from '../contexts/PluginContext.tsx';
5
- import { useToastContext } from '../contexts/ToastContext.tsx';
6
- import { useConfig } from '../contexts/ConfigContext.tsx';
7
- import { useDemoMode } from '../contexts/DemoModeContext.tsx';
8
- import { useInputFocus } from '../contexts/InputContext.tsx';
9
- import { ModalBackdrop, Z_INDEX } from './ModalBackdrop.tsx';
10
- import { type AppConfig, type SparklineStyle, type SparklineOrientation } from '@/config/schema.ts';
11
- import { parseCurrencyInput, formatBudgetDisplay } from '@/utils/currency.ts';
12
- import { ThemePicker } from './ThemePicker.tsx';
13
-
14
- type SettingCategory = 'refresh' | 'display' | 'budgets' | 'alerts' | 'notifications';
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
- type: 'toggle' | 'select' | 'number';
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: 'intervalMs',
29
- label: 'Refresh Interval',
30
- category: 'refresh',
31
- type: 'select',
32
- options: ['30s', '1m', '2m', '5m'],
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 '30s';
36
- if (ms <= 60000) return '1m';
37
- if (ms <= 120000) return '2m';
38
- return '5m';
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> = { '30s': 30000, '1m': 60000, '2m': 120000, '5m': 300000 };
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: 'pauseAutoRefresh',
47
- label: 'Pause Auto-Refresh',
48
- category: 'refresh',
49
- type: 'toggle',
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: 'defaultTimeWindow',
55
- label: 'Default Time Window',
56
- category: 'display',
57
- type: 'select',
58
- options: ['5m', '15m', '1h', '24h', '7d', '30d', 'all'],
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) => ({ ...c, display: { ...c.display, defaultTimeWindow: v as AppConfig['display']['defaultTimeWindow'] } }),
63
+ setValue: (c, v) => ({
64
+ ...c,
65
+ display: { ...c.display, defaultTimeWindow: v as AppConfig["display"]["defaultTimeWindow"] },
66
+ }),
61
67
  },
62
68
  {
63
- key: 'sidebarCollapsed',
64
- label: 'Sidebar Collapsed',
65
- category: 'display',
66
- type: 'toggle',
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: 'timeFormat',
72
- label: 'Time Format',
73
- category: 'display',
74
- type: 'select',
75
- options: ['12h', '24h'],
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 '12h' | '24h' } }),
83
+ setValue: (c, v) => ({ ...c, display: { ...c.display, timeFormat: v as "12h" | "24h" } }),
78
84
  },
79
85
  {
80
- key: 'sparklineStyle',
81
- label: 'Sparkline Style',
82
- category: 'display',
83
- type: 'select',
84
- options: ['braille', 'block'],
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: 'sparklineOrientation',
96
- label: 'Sparkline Direction',
97
- category: 'display',
98
- type: 'select',
99
- options: ['up', 'down'],
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: 'sparklineBaseline',
111
- label: 'Sparkline Baseline',
112
- category: 'display',
113
- type: 'toggle',
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: 'theme',
125
- label: 'Theme',
126
- category: 'display',
127
- type: 'select',
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: 'dailyBudget',
134
- label: 'Daily Budget ($)',
135
- category: 'budgets',
136
- type: 'number',
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: 'weeklyBudget',
142
- label: 'Weekly Budget ($)',
143
- category: 'budgets',
144
- type: 'number',
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: 'monthlyBudget',
150
- label: 'Monthly Budget ($)',
151
- category: 'budgets',
152
- type: 'number',
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: 'warningPercent',
158
- label: 'Warning Threshold (%)',
159
- category: 'alerts',
160
- type: 'select',
161
- options: ['55%', '60%', '65%', '70%', '75%', '80%', '85%', '90%', '95%'],
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('%', ''), 10);
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: 'criticalPercent',
170
- label: 'Critical Threshold (%)',
171
- category: 'alerts',
172
- type: 'select',
173
- options: ['75%', '80%', '85%', '90%', '95%', '98%', '100%'],
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('%', ''), 10);
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: 'toastsEnabled',
182
- label: 'Show Toast Notifications',
183
- category: 'notifications',
184
- type: 'toggle',
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) => ({ ...c, notifications: { ...c.notifications, toastsEnabled: v as boolean } }),
192
+ setValue: (c, v) => ({
193
+ ...c,
194
+ notifications: { ...c.notifications, toastsEnabled: v as boolean },
195
+ }),
187
196
  },
188
197
  {
189
- key: 'soundEnabled',
190
- label: 'Sound Alerts',
191
- category: 'notifications',
192
- type: 'toggle',
198
+ key: "soundEnabled",
199
+ label: "Sound Alerts",
200
+ category: "notifications",
201
+ type: "toggle",
193
202
  getValue: (c) => c.notifications.soundEnabled,
194
- setValue: (c, v) => ({ ...c, notifications: { ...c.notifications, soundEnabled: v as boolean } }),
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: 'refresh', label: 'Refresh' },
200
- { id: 'display', label: 'Display' },
201
- { id: 'budgets', label: 'Budgets' },
202
- { id: 'alerts', label: 'Alerts' },
203
- { id: 'notifications', label: 'Notifications' },
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>('refresh');
312
+ const [selectedCategory, setSelectedCategory] = useState<SettingCategory>("refresh");
221
313
  const [selectedIndex, setSelectedIndex] = useState(0);
222
- const [focusedPane, setFocusedPane] = useState<'categories' | 'settings'>('categories');
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 settingsAreaHeight = height - 1 - 1 - 2 - 4 - 2 - (demoMode ? 1 : 0);
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 settings = BASE_SETTINGS;
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('Settings saved');
436
+ showToast("Settings saved");
262
437
  } catch {
263
- showToast('Failed to save settings', 'error');
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('Reset to defaults');
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 !== 'number') return;
275
-
449
+ if (!setting || setting.type !== "number") return;
450
+
276
451
  const currentValue = setting.getValue(config);
277
- setInputValue(currentValue === null ? '' : String(currentValue));
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 !== 'number') {
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() === '' ? null : parseCurrencyInput(inputValue);
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 === 'toggle') {
484
+ if (setting.type === "toggle") {
310
485
  newConfig = setting.setValue(config, !currentValue);
311
- } else if (setting.type === 'select' && setting.options && setting.options.length > 0) {
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 === 'p' || key.name === 's')) {
502
+ if (key.ctrl && (key.name === "p" || key.name === "s")) {
328
503
  return;
329
504
  }
330
- if (key.name === 'escape') {
505
+ if (key.name === "escape") {
331
506
  cancelNumberEdit();
332
507
  return;
333
508
  }
334
- if (key.name === 'return') {
509
+ if (key.name === "return") {
335
510
  commitNumberEdit();
336
511
  return;
337
512
  }
338
- if (key.name === 'backspace') {
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 === 'escape') {
524
+ if (key.name === "escape") {
350
525
  onClose();
351
526
  return;
352
527
  }
353
528
 
354
- if (key.ctrl && key.name === 's') {
529
+ if (key.ctrl && key.name === "s") {
355
530
  handleSave();
356
531
  return;
357
532
  }
358
533
 
359
- if (key.shift && key.name === 'r') {
534
+ if (key.shift && key.name === "r") {
360
535
  handleReset();
361
536
  return;
362
537
  }
363
538
 
364
- if (key.name === 'tab') {
365
- setFocusedPane(p => p === 'categories' ? 'settings' : 'categories');
539
+ if (key.name === "tab") {
540
+ setFocusedPane((p) => (p === "categories" ? "settings" : "categories"));
366
541
  return;
367
542
  }
368
543
 
369
- if (focusedPane === 'categories') {
370
- const catIdx = CATEGORIES.findIndex(c => c.id === selectedCategory);
371
- if (key.name === 'down' || key.name === 'j') {
372
- const nextIdx = Math.min(catIdx + 1, CATEGORIES.length - 1);
373
- setSelectedCategory(CATEGORIES[nextIdx]!.id);
374
- setSelectedIndex(0);
375
- } else if (key.name === 'up' || key.name === 'k') {
376
- const prevIdx = Math.max(catIdx - 1, 0);
377
- setSelectedCategory(CATEGORIES[prevIdx]!.id);
378
- setSelectedIndex(0);
379
- } else if (key.name === 'return' || key.name === 'right' || key.name === 'l') {
380
- setFocusedPane('settings');
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 === 'settings') {
386
- if (key.name === 'down' || key.name === 'j') {
387
- setSelectedIndex(i => Math.min(i + 1, categorySettings.length - 1));
388
- } else if (key.name === 'up' || key.name === 'k') {
389
- setSelectedIndex(i => Math.max(i - 1, 0));
390
- } else if (key.name === 'return' || key.name === 'space') {
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 === 'theme') {
624
+ if (setting?.key === "theme") {
393
625
  setShowThemePicker(true);
394
- } else if (setting?.type === 'number') {
626
+ } else if (setting?.type === "number") {
395
627
  startEditingNumber();
396
628
  } else {
397
629
  toggleCurrentSetting();
398
630
  }
399
- } else if (key.name === 'left' || key.name === 'h') {
631
+ } else if (key.name === "left" || key.name === "h") {
400
632
  const setting = categorySettings[selectedIndex];
401
- if (setting?.type === 'select' && setting.options && setting.options.length > 0) {
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 === 'right' || key.name === 'l') {
640
+ } else if (key.name === "right" || key.name === "l") {
409
641
  const setting = categorySettings[selectedIndex];
410
- if (setting?.type === 'select' && setting.options && setting.options.length > 0) {
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
- <box
434
- flexDirection="row"
435
- justifyContent="space-between"
436
- paddingX={1}
437
- backgroundColor={colors.foreground}
438
- height={1}
439
- flexShrink={0}
440
- >
441
- <text fg={colors.primary}><strong>SETTINGS</strong></text>
442
- <text fg={colors.textSubtle}>Esc:close ^S:save Shift+R:reset</text>
443
- </box>
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 ?? 'default'} | Preset: {preset ?? 'normal'}
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 === 'categories' ? 'double' : 'single'}
460
- borderColor={focusedPane === 'categories' ? colors.primary : colors.border}
693
+ borderStyle={focusedPane === "categories" ? "double" : "single"}
694
+ borderColor={focusedPane === "categories" ? colors.primary : colors.border}
461
695
  padding={1}
462
696
  >
463
- {CATEGORIES.map((cat) => {
464
- const isActive = cat.id === selectedCategory;
465
- const isFocusedActive = isActive && focusedPane === 'categories';
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={cat.id} height={1}>
749
+ <box key={`${item.type}-${item.id}`} height={1}>
468
750
  <text
469
- fg={isFocusedActive ? colors.background : isActive ? colors.primary : colors.text}
751
+ fg={
752
+ isFocusedActive ? colors.background : isActive ? colors.primary : colors.text
753
+ }
470
754
  {...(isFocusedActive ? { bg: colors.primary } : {})}
471
755
  >
472
- {isActive ? '▸ ' : ' '}{cat.label}
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 === 'settings' ? 'double' : 'single'}
484
- borderColor={focusedPane === 'settings' ? colors.primary : colors.border}
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
- <box flexDirection="row" justifyContent="space-between" marginBottom={1} height={1}>
513
- <text fg={colors.textMuted}>
514
- {CATEGORIES.find(c => c.id === selectedCategory)?.label.toUpperCase()}
515
- </text>
516
- {(hasMoreAbove || hasMoreBelow) && (
517
- <text fg={colors.textSubtle}>
518
- {hasMoreAbove ? '▲' : ' '}{hasMoreBelow ? '▼' : ' '}
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
- </box>
522
-
523
- {visibleSettings.map((setting) => {
524
- const realIdx = categorySettings.findIndex(s => s.key === setting.key);
525
- const isSelected = realIdx === selectedIndex && focusedPane === 'settings';
526
- const value = setting.getValue(config);
527
- const isEditingThis = setting.type === 'number' && editingSettingKey === setting.key;
528
-
529
- let displayValue: string;
530
- if (setting.key === 'theme') {
531
- const themeName = themes.find(t => t.id === value)?.name ?? value;
532
- displayValue = `${themeName} ▸`;
533
- } else if (setting.type === 'toggle') {
534
- displayValue = value ? '● ON' : '○ OFF';
535
- } else if (setting.type === 'select') {
536
- displayValue = `◂ ${value} ▸`;
537
- } else if (setting.type === 'number' && !isEditingThis) {
538
- displayValue = formatBudgetDisplay(value as number | null);
539
- } else {
540
- displayValue = String(value);
541
- }
542
-
543
- return (
544
- <box
545
- key={setting.key}
546
- flexDirection="row"
547
- height={1}
548
- marginBottom={1}
549
- paddingX={1}
550
- {...(isSelected && !isEditingThis ? { backgroundColor: colors.primary } : {})}
551
- >
552
- <text
553
- flexGrow={1}
554
- fg={isSelected && !isEditingThis ? colors.background : colors.text}
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
- {setting.label}
557
- </text>
558
- {isEditingThis ? (
559
- <text fg={colors.text}>
560
- ${inputValue}<span fg={colors.primary}>█</span>
864
+ <text
865
+ flexGrow={1}
866
+ fg={isSelected && !isEditingThis ? colors.background : colors.text}
867
+ >
868
+ {setting.label}
561
869
  </text>
562
- ) : (
563
- <text fg={isSelected ? colors.background : colors.textMuted}>
564
- {displayValue}
565
- </text>
566
- )}
567
- </box>
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
- <box flexDirection="row" height={1} paddingX={1} backgroundColor={colors.foreground}>
576
- <text fg={colors.textSubtle}>
577
- {editingSettingKey !== null
578
- ? 'Type value Enter:save Esc:cancel'
579
- : showThemePicker
580
- ? '↑↓:navigate ←→:scheme Tab:switch Enter:apply Esc:cancel'
581
- : 'Tab:switch ↑↓:navigate ←→:adjust Enter:edit'}
582
- </text>
583
- </box>
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
  );