@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.
- package/package.json +3 -3
- package/src/plugins/lifecycle.ts +17 -13
- package/src/plugins/notifications/terminal-bell.ts +8 -1
- package/src/plugins/notifications/visual-flash.ts +12 -2
- package/src/plugins/providers/zai.ts +3 -1
- package/src/plugins/registry.ts +42 -37
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/retention.ts +146 -0
- package/src/tui/components/DebugPanel.tsx +23 -10
- package/src/tui/components/SessionsTable.tsx +6 -2
- package/src/tui/components/SettingsModal.tsx +383 -76
- package/src/tui/contexts/AgentSessionContext.tsx +43 -79
- package/src/tui/contexts/PluginContext.tsx +6 -0
- package/src/tui/contexts/StorageContext.tsx +42 -130
- package/src/tui/hooks/useExitAnimation.ts +4 -0
- package/src/tui/views/Dashboard.tsx +40 -3
- package/src/tui/views/ProjectsView.tsx +29 -6
- package/src/tui/views/RealTimeDashboard.tsx +29 -8
- package/src/tui/views/SettingsView.tsx +8 -3
- package/src/version.ts +1 -1
|
@@ -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" | "
|
|
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", "
|
|
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
|
|
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 === "
|
|
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 === "
|
|
675
|
-
const
|
|
676
|
-
const
|
|
677
|
-
if (
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
382
|
-
|
|
383
|
-
|
|
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 =
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
475
|
-
|
|
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={
|
|
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
|
-
{
|
|
486
|
-
{
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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>
|