@tokentop/ttop 0.3.0 → 0.5.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 (83) hide show
  1. package/package.json +9 -4
  2. package/src/agents/aggregator.ts +8 -1
  3. package/src/agents/types.ts +3 -0
  4. package/src/cli.ts +2 -2
  5. package/src/demo/simulator.ts +1 -1
  6. package/src/plugins/agents/index.ts +2 -0
  7. package/src/plugins/auth-sources.ts +2 -3
  8. package/src/plugins/lifecycle.ts +17 -13
  9. package/src/plugins/loader.test.ts +1 -1
  10. package/src/plugins/loader.ts +15 -7
  11. package/src/plugins/notification-bus.ts +5 -2
  12. package/src/plugins/notifications/terminal-bell.ts +9 -2
  13. package/src/plugins/notifications/visual-flash.ts +13 -3
  14. package/src/plugins/plugin-context-factory.ts +4 -19
  15. package/src/plugins/providers/anthropic.ts +2 -2
  16. package/src/plugins/providers/antigravity.ts +1 -1
  17. package/src/plugins/providers/chutes.ts +189 -0
  18. package/src/plugins/providers/codex.ts +1 -1
  19. package/src/plugins/providers/gemini.ts +1 -1
  20. package/src/plugins/providers/github-copilot.ts +1 -1
  21. package/src/plugins/providers/index.ts +1 -0
  22. package/src/plugins/providers/minimax.ts +1 -1
  23. package/src/plugins/providers/openai-api.ts +1 -1
  24. package/src/plugins/providers/opencode-zen.ts +1 -1
  25. package/src/plugins/providers/perplexity.ts +1 -1
  26. package/src/plugins/providers/zai.ts +4 -2
  27. package/src/plugins/registry.ts +51 -46
  28. package/src/plugins/sandbox-guard.test.ts +1 -1
  29. package/src/plugins/sandbox-guard.ts +1 -1
  30. package/src/plugins/sandbox.ts +1 -1
  31. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  32. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  33. package/src/plugins/themes/claude-code.ts +1 -1
  34. package/src/plugins/themes/dracula.ts +1 -1
  35. package/src/plugins/themes/github-light.ts +1 -1
  36. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  37. package/src/plugins/themes/gruvbox-light.ts +1 -1
  38. package/src/plugins/themes/kanagawa.ts +1 -1
  39. package/src/plugins/themes/nord.ts +1 -1
  40. package/src/plugins/themes/one-dark.ts +1 -1
  41. package/src/plugins/themes/opencode.ts +1 -1
  42. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  43. package/src/plugins/themes/rose-pine.ts +1 -1
  44. package/src/plugins/themes/solarized-light.ts +1 -1
  45. package/src/plugins/themes/tokyo-night.ts +1 -1
  46. package/src/plugins/update-checker.ts +2 -2
  47. package/src/pricing/estimator.test.ts +1 -1
  48. package/src/pricing/estimator.ts +1 -1
  49. package/src/pricing/fallback.ts +1 -1
  50. package/src/pricing/index.ts +1 -1
  51. package/src/pricing/models-dev.ts +1 -1
  52. package/src/storage/index.ts +6 -0
  53. package/src/storage/migrations/index.ts +17 -0
  54. package/src/storage/persistence-service.ts +206 -0
  55. package/src/storage/repos/pluginStorage.ts +35 -0
  56. package/src/storage/retention.ts +146 -0
  57. package/src/tui/App.tsx +1 -1
  58. package/src/tui/components/DebugPanel.tsx +23 -10
  59. package/src/tui/components/ProviderCard.tsx +1 -1
  60. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  61. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  62. package/src/tui/components/SessionsTable.tsx +15 -4
  63. package/src/tui/components/SettingsModal.tsx +383 -76
  64. package/src/tui/components/ThemePicker.tsx +1 -1
  65. package/src/tui/contexts/AgentSessionContext.tsx +44 -80
  66. package/src/tui/contexts/PluginContext.tsx +13 -3
  67. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  68. package/src/tui/contexts/StorageContext.tsx +42 -130
  69. package/src/tui/contexts/ThemeContext.tsx +3 -3
  70. package/src/tui/hooks/useExitAnimation.ts +4 -0
  71. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  72. package/src/tui/views/Dashboard.tsx +40 -3
  73. package/src/tui/views/ProjectsView.tsx +29 -6
  74. package/src/tui/views/RealTimeDashboard.tsx +29 -8
  75. package/src/tui/views/SettingsView.tsx +9 -4
  76. package/src/version.ts +1 -1
  77. package/src/plugins/types/agent.ts +0 -108
  78. package/src/plugins/types/base.ts +0 -278
  79. package/src/plugins/types/index.ts +0 -19
  80. package/src/plugins/types/notification.ts +0 -51
  81. package/src/plugins/types/provider.ts +0 -237
  82. package/src/plugins/types/theme.ts +0 -78
  83. package/src/storage/database.ts +0 -623
@@ -1,4 +1,5 @@
1
1
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
+ import type { ConfigField, PluginType } from "@tokentop/plugin-sdk";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
4
  import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
4
5
  import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
@@ -11,12 +12,14 @@ import { useToastContext } from "../contexts/ToastContext.tsx";
11
12
  import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
12
13
  import { ThemePicker } from "./ThemePicker.tsx";
13
14
 
14
- type SettingCategory = "refresh" | "display" | "budgets" | "alerts" | "notifications";
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;
22
+ pluginId?: string;
20
23
  type: "toggle" | "select" | "number";
21
24
  options?: string[];
22
25
  getValue: (config: AppConfig) => string | number | boolean | null;
@@ -210,8 +213,88 @@ const CATEGORIES: { id: SettingCategory; label: string }[] = [
210
213
  { id: "budgets", label: "Budgets" },
211
214
  { id: "alerts", label: "Alerts" },
212
215
  { id: "notifications", label: "Notifications" },
216
+ { id: "plugins", label: "Plugins" },
213
217
  ];
214
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
+
215
298
  interface SettingsModalProps {
216
299
  onClose: () => void;
217
300
  }
@@ -219,7 +302,7 @@ interface SettingsModalProps {
219
302
  export function SettingsModal({ onClose }: SettingsModalProps) {
220
303
  const colors = useColors();
221
304
  const { setPreviewTheme } = useTheme();
222
- const { themes } = usePlugins();
305
+ const { providers, agents, themes, notifications } = usePlugins();
223
306
  const { showToast } = useToastContext();
224
307
  const { config, updateConfig, resetToDefaults, saveNow } = useConfig();
225
308
  const { demoMode, seed, preset } = useDemoMode();
@@ -228,6 +311,8 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
228
311
 
229
312
  const [selectedCategory, setSelectedCategory] = useState<SettingCategory>("refresh");
230
313
  const [selectedIndex, setSelectedIndex] = useState(0);
314
+ const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
315
+ const [isPluginsExpanded, setIsPluginsExpanded] = useState(false);
231
316
  const [focusedPane, setFocusedPane] = useState<"categories" | "settings">("categories");
232
317
  const [showThemePicker, setShowThemePicker] = useState(false);
233
318
 
@@ -238,14 +323,82 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
238
323
  const height = Math.min(termHeight - 4, 28);
239
324
 
240
325
  // Calculate available height for settings list
241
- // Modal height - header(1) - footer(1) - outer padding(2) - inner borders(4) - title row with margin(2)
242
- 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);
243
329
  // Each setting takes 2 rows (height=1 + marginBottom=1)
244
330
  const visibleSettingsCount = Math.max(1, Math.floor(settingsAreaHeight / 2));
245
331
 
246
- 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]);
247
395
 
248
- const categorySettings = settings.filter((s) => s.category === selectedCategory);
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]);
249
402
 
250
403
  // Calculate scroll offset to keep selected item visible
251
404
  const scrollOffset = useMemo(() => {
@@ -258,6 +411,19 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
258
411
  const visibleSettings = categorySettings.slice(scrollOffset, scrollOffset + visibleSettingsCount);
259
412
  const hasMoreAbove = scrollOffset > 0;
260
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]);
261
427
 
262
428
  useEffect(() => {
263
429
  setInputFocused(true);
@@ -376,17 +542,74 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
376
542
  }
377
543
 
378
544
  if (focusedPane === "categories") {
379
- const catIdx = CATEGORIES.findIndex((c) => c.id === selectedCategory);
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
+
380
564
  if (key.name === "down" || key.name === "j") {
381
- const nextIdx = Math.min(catIdx + 1, CATEGORIES.length - 1);
382
- setSelectedCategory(CATEGORIES[nextIdx]!.id);
383
- setSelectedIndex(0);
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
+ }
384
579
  } else if (key.name === "up" || key.name === "k") {
385
- const prevIdx = Math.max(catIdx - 1, 0);
386
- setSelectedCategory(CATEGORIES[prevIdx]!.id);
387
- setSelectedIndex(0);
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
+ }
388
595
  } else if (key.name === "return" || key.name === "right" || key.name === "l") {
389
- setFocusedPane("settings");
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
+ }
390
613
  }
391
614
  return;
392
615
  }
@@ -471,19 +694,67 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
471
694
  borderColor={focusedPane === "categories" ? colors.primary : colors.border}
472
695
  padding={1}
473
696
  >
474
- {CATEGORIES.map((cat) => {
475
- const isActive = cat.id === selectedCategory;
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;
476
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
+
477
748
  return (
478
- <box key={cat.id} height={1}>
749
+ <box key={`${item.type}-${item.id}`} height={1}>
479
750
  <text
480
751
  fg={
481
752
  isFocusedActive ? colors.background : isActive ? colors.primary : colors.text
482
753
  }
483
754
  {...(isFocusedActive ? { bg: colors.primary } : {})}
484
755
  >
485
- {isActive ? "▸ " : " "}
486
- {cat.label}
756
+ {prefix}
757
+ {label}
487
758
  </text>
488
759
  </box>
489
760
  );
@@ -522,68 +793,104 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
522
793
  }}
523
794
  />
524
795
  ) : (
525
- <box flexDirection="column" flexGrow={1} padding={1}>
526
- <box flexDirection="row" justifyContent="space-between" marginBottom={1} height={1}>
527
- <text fg={colors.textMuted}>
528
- {CATEGORIES.find((c) => c.id === selectedCategory)?.label.toUpperCase()}
529
- </text>
530
- {(hasMoreAbove || hasMoreBelow) && (
531
- <text fg={colors.textSubtle}>
532
- {hasMoreAbove ? "▲" : " "}
533
- {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"}
534
825
  </text>
535
826
  )}
536
- </box>
537
-
538
- {visibleSettings.map((setting) => {
539
- const realIdx = categorySettings.findIndex((s) => s.key === setting.key);
540
- const isSelected = realIdx === selectedIndex && focusedPane === "settings";
541
- const value = setting.getValue(config);
542
- const isEditingThis =
543
- setting.type === "number" && editingSettingKey === setting.key;
544
-
545
- let displayValue: string;
546
- if (setting.key === "theme") {
547
- const themeName = themes.find((t) => t.id === value)?.name ?? value;
548
- displayValue = `${themeName} ▸`;
549
- } else if (setting.type === "toggle") {
550
- displayValue = value ? "● ON" : "○ OFF";
551
- } else if (setting.type === "select") {
552
- displayValue = `◂ ${value} ▸`;
553
- } else if (setting.type === "number" && !isEditingThis) {
554
- displayValue = formatBudgetDisplay(value as number | null);
555
- } else {
556
- displayValue = String(value);
557
- }
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
+ }
558
852
 
559
- return (
560
- <box
561
- key={setting.key}
562
- flexDirection="row"
563
- height={1}
564
- marginBottom={1}
565
- paddingX={1}
566
- {...(isSelected && !isEditingThis ? { backgroundColor: colors.primary } : {})}
567
- >
568
- <text
569
- flexGrow={1}
570
- fg={isSelected && !isEditingThis ? colors.background : colors.text}
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
+ : {})}
571
863
  >
572
- {setting.label}
573
- </text>
574
- {isEditingThis ? (
575
- <text fg={colors.text}>
576
- ${inputValue}
577
- <span fg={colors.primary}>█</span>
578
- </text>
579
- ) : (
580
- <text fg={isSelected ? colors.background : colors.textMuted}>
581
- {displayValue}
864
+ <text
865
+ flexGrow={1}
866
+ fg={isSelected && !isEditingThis ? colors.background : colors.text}
867
+ >
868
+ {setting.label}
582
869
  </text>
583
- )}
584
- </box>
585
- );
586
- })}
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>
587
894
  </box>
588
895
  )}
589
896
  </box>
@@ -1,6 +1,6 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
+ import type { ColorSchemePreference, ThemePlugin } from "@tokentop/plugin-sdk";
2
3
  import { useEffect, useMemo, useState } from "react";
3
- import type { ColorSchemePreference, ThemePlugin } from "@/plugins/types/theme.ts";
4
4
  import { useColors } from "../contexts/ThemeContext.tsx";
5
5
 
6
6
  interface ThemePickerProps {