@tokentop/ttop 0.3.0 → 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.
@@ -9,7 +9,7 @@ import { useColors } from "../contexts/ThemeContext.tsx";
9
9
  import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
10
10
 
11
11
  type DebugTab = "logs" | "inspector" | "plugins";
12
- type PluginSort = "name" | "type" | "official";
12
+ type PluginSort = "name" | "type" | "source";
13
13
 
14
14
  interface DebugInspectorData {
15
15
  sessions: Array<{
@@ -193,7 +193,7 @@ export function DebugPanel({ onClose, inspectorData }: DebugPanelProps) {
193
193
  if (activeTab === "plugins") {
194
194
  if (key.name === "s") {
195
195
  setPluginSort((prev) => {
196
- const order: PluginSort[] = ["name", "type", "official"];
196
+ const order: PluginSort[] = ["name", "type", "source"];
197
197
  return order[(order.indexOf(prev) + 1) % order.length] ?? "name";
198
198
  });
199
199
  return;
@@ -656,12 +656,12 @@ interface PluginsTabProps {
656
656
 
657
657
  const TYPE_ORDER: Record<string, number> = { provider: 0, agent: 1, theme: 2, notification: 3 };
658
658
 
659
- function isOfficialPlugin(pluginId: string, pluginType: string): boolean {
659
+ function getSourcePriority(pluginId: string, pluginType: string): number {
660
660
  const source = pluginRegistry.getSource(
661
661
  pluginType as "provider" | "agent" | "theme" | "notification",
662
662
  pluginId,
663
663
  );
664
- return source === "builtin";
664
+ return source === "local" ? 0 : source === "npm" ? 1 : 2;
665
665
  }
666
666
 
667
667
  function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
@@ -671,10 +671,10 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
671
671
  if (sortBy === "name") {
672
672
  return a.name.localeCompare(b.name);
673
673
  }
674
- if (sortBy === "official") {
675
- const aOfficial = isOfficialPlugin(a.id, a.type) ? 0 : 1;
676
- const bOfficial = isOfficialPlugin(b.id, b.type) ? 0 : 1;
677
- if (aOfficial !== bOfficial) return aOfficial - bOfficial;
674
+ if (sortBy === "source") {
675
+ const aPriority = getSourcePriority(a.id, a.type);
676
+ const bPriority = getSourcePriority(b.id, b.type);
677
+ if (aPriority !== bPriority) return aPriority - bPriority;
678
678
  return a.name.localeCompare(b.name);
679
679
  }
680
680
  const typeA = TYPE_ORDER[a.type] ?? 99;
@@ -733,6 +733,9 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
733
733
  <text width={14} fg={colors.textSubtle}>
734
734
  {padRight("TYPE", 14)}
735
735
  </text>
736
+ <text width={10} fg={colors.textSubtle}>
737
+ {padRight("SOURCE", 10)}
738
+ </text>
736
739
  <text width={14} fg={colors.textSubtle}>
737
740
  {padRight("VERSION", 14)}
738
741
  </text>
@@ -743,7 +746,14 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
743
746
  {allPlugins.map((plugin) => {
744
747
  const key = `${plugin.type}-${plugin.id}`;
745
748
  const updateInfo = updates.get(key);
746
- const official = isOfficialPlugin(plugin.id, plugin.type);
749
+ const source = pluginRegistry.getSource(plugin.type, plugin.id) ?? "builtin";
750
+ const official = pluginRegistry.isOfficial(plugin.type, plugin.id);
751
+
752
+ const sourceColors: Record<string, string> = {
753
+ builtin: colors.textSubtle,
754
+ local: colors.success,
755
+ npm: colors.info,
756
+ };
747
757
 
748
758
  let versionSuffix = "";
749
759
  let versionColor = colors.textMuted;
@@ -758,12 +768,15 @@ function PluginsTab({ colors, scrollboxRef, sortBy }: PluginsTabProps) {
758
768
  return (
759
769
  <box key={key} flexDirection="row" height={1}>
760
770
  <text width={22}>
761
- {official && <span fg={colors.success}>❖ </span>}
771
+ {official && <span fg={colors.warning}>✦ </span>}
762
772
  <span fg={colors.text}>{padRight(plugin.name, official ? 20 : 22)}</span>
763
773
  </text>
764
774
  <text width={14} fg={typeColors[plugin.type] ?? colors.textMuted}>
765
775
  {padRight(plugin.type, 14)}
766
776
  </text>
777
+ <text width={10} fg={sourceColors[source] ?? colors.textMuted}>
778
+ {padRight(source, 10)}
779
+ </text>
767
780
  <text width={14} fg={versionColor}>
768
781
  {padRight(plugin.version + versionSuffix, 14)}
769
782
  </text>
@@ -386,6 +386,10 @@ export const SessionsTable = forwardRef(function SessionsTable(
386
386
  getKey: getSessionKey,
387
387
  });
388
388
 
389
+ // Track bulk changes to force scrollbox remount (clears ghost text)
390
+ const bulkCountRef = useRef(0);
391
+ if (isBulkChange) bulkCountRef.current++;
392
+
389
393
  let activeIndex = 0;
390
394
  const selectedSession = sessions[selectedRow] ?? null;
391
395
  const inspector = selectedSession ? getInspectorData(selectedSession) : null;
@@ -489,8 +493,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
489
493
  </box>
490
494
  )}
491
495
 
492
- <scrollbox ref={ref} flexGrow={1}>
493
- <box flexDirection="column">
496
+ <scrollbox key={`sessions-sb-${bulkCountRef.current}`} ref={ref} flexGrow={1}>
497
+ <box flexDirection="column" flexGrow={1}>
494
498
  {sessions.length === 0 && animatedSessions.length === 0 && (
495
499
  <box paddingLeft={2}>
496
500
  <text fg={colors.textMuted}>
@@ -1,6 +1,7 @@
1
1
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
2
2
  import { useCallback, useEffect, useMemo, useState } from "react";
3
3
  import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
4
+ import type { ConfigField, PluginType } from "@/plugins/types/base.ts";
4
5
  import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
5
6
  import { useConfig } from "../contexts/ConfigContext.tsx";
6
7
  import { useDemoMode } from "../contexts/DemoModeContext.tsx";
@@ -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>