478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
/**
|
|
* Plugin settings UI components.
|
|
*
|
|
* Provides a hierarchical settings interface:
|
|
* - Plugin list (shows all installed plugins)
|
|
* - Plugin detail (enable/disable, features, config)
|
|
* - Feature toggles
|
|
* - Config value editor
|
|
*/
|
|
import {
|
|
Container,
|
|
Input,
|
|
type SelectItem,
|
|
SelectList,
|
|
type SettingItem,
|
|
SettingsList,
|
|
Spacer,
|
|
Text,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { PluginManager } from "../../extensibility/plugins/manager";
|
|
import type { InstalledPlugin, PluginSettingSchema } from "../../extensibility/plugins/types";
|
|
import { getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
|
|
import { DynamicBorder } from "./dynamic-border";
|
|
|
|
// =============================================================================
|
|
// Plugin List Component
|
|
// =============================================================================
|
|
|
|
export interface PluginListCallbacks {
|
|
onPluginSelect: (plugin: InstalledPlugin) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
/**
|
|
* Shows list of installed plugins with enable/disable status.
|
|
* Selecting a plugin opens its detail view.
|
|
*/
|
|
export class PluginListComponent extends Container {
|
|
readonly #selectList: SelectList;
|
|
|
|
constructor(
|
|
private readonly plugins: InstalledPlugin[],
|
|
callbacks: PluginListCallbacks,
|
|
) {
|
|
super();
|
|
|
|
// Title
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", " Plugins")), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
|
|
if (plugins.length === 0) {
|
|
this.addChild(new Text(theme.fg("muted", " No plugins installed"), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Install with: omp plugin install <package>"), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new DynamicBorder());
|
|
|
|
// Create empty list that just handles escape
|
|
this.#selectList = new SelectList([], 1, getSelectListTheme());
|
|
this.#selectList.onCancel = callbacks.onCancel;
|
|
return;
|
|
}
|
|
|
|
const items: SelectItem[] = plugins.map(p => {
|
|
const status = p.enabled
|
|
? theme.fg("success", theme.status.enabled)
|
|
: theme.fg("muted", theme.status.disabled);
|
|
const featureCount = p.manifest.features ? Object.keys(p.manifest.features).length : 0;
|
|
const enabledCount = p.enabledFeatures?.length ?? featureCount;
|
|
|
|
let details = `v${p.version}`;
|
|
if (featureCount > 0) {
|
|
details += ` ${theme.sep.dot} ${enabledCount}/${featureCount} features`;
|
|
}
|
|
|
|
return {
|
|
value: p.name,
|
|
label: `${status} ${p.name}`,
|
|
description: details,
|
|
};
|
|
});
|
|
|
|
this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme());
|
|
|
|
this.#selectList.onSelect = item => {
|
|
const plugin = this.plugins.find(p => p.name === item.value);
|
|
if (plugin) {
|
|
callbacks.onPluginSelect(plugin);
|
|
}
|
|
};
|
|
|
|
this.#selectList.onCancel = callbacks.onCancel;
|
|
|
|
this.addChild(this.#selectList);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0));
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#selectList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Plugin Detail Component
|
|
// =============================================================================
|
|
|
|
export interface PluginDetailCallbacks {
|
|
onEnabledChange: (enabled: boolean) => void;
|
|
onFeatureChange: (feature: string, enabled: boolean) => void;
|
|
onConfigChange: (key: string, value: unknown) => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
/**
|
|
* Shows detail settings for a single plugin:
|
|
* - Enable/disable toggle
|
|
* - Feature toggles
|
|
* - Config settings
|
|
*/
|
|
export class PluginDetailComponent extends Container {
|
|
#settingsList!: SettingsList;
|
|
|
|
constructor(
|
|
private plugin: InstalledPlugin,
|
|
private readonly manager: PluginManager,
|
|
private readonly callbacks: PluginDetailCallbacks,
|
|
) {
|
|
super();
|
|
|
|
void this.#rebuild();
|
|
}
|
|
|
|
async #rebuild(): Promise<void> {
|
|
this.clear();
|
|
|
|
const plugin = this.plugin;
|
|
const manifest = plugin.manifest;
|
|
|
|
// Header
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.name}`)), 0, 0));
|
|
if (manifest.description) {
|
|
this.addChild(new Text(theme.fg("muted", ` ${manifest.description}`), 0, 0));
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
|
|
const items: SettingItem[] = [];
|
|
|
|
// Enable/disable toggle
|
|
items.push({
|
|
id: "__enabled__",
|
|
label: "Enabled",
|
|
description: "Enable or disable this plugin",
|
|
currentValue: plugin.enabled ? "true" : "false",
|
|
values: ["true", "false"],
|
|
});
|
|
|
|
// Feature toggles
|
|
if (manifest.features && Object.keys(manifest.features).length > 0) {
|
|
const enabledSet = new Set(plugin.enabledFeatures ?? []);
|
|
const defaultFeatures = Object.entries(manifest.features)
|
|
.filter(([_, f]) => f.default)
|
|
.map(([name]) => name);
|
|
|
|
// If enabledFeatures is null, use defaults
|
|
const effectiveEnabled = plugin.enabledFeatures === null ? new Set(defaultFeatures) : enabledSet;
|
|
|
|
for (const [featName, feat] of Object.entries(manifest.features)) {
|
|
const isEnabled = effectiveEnabled.has(featName);
|
|
items.push({
|
|
id: `feature:${featName}`,
|
|
label: ` ${featName}`,
|
|
description: feat.description || `Enable ${featName} feature`,
|
|
currentValue: isEnabled ? "true" : "false",
|
|
values: ["true", "false"],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Config settings
|
|
if (manifest.settings && Object.keys(manifest.settings).length > 0) {
|
|
const settings = await this.manager.getPluginSettings(plugin.name);
|
|
|
|
for (const [key, schema] of Object.entries(manifest.settings)) {
|
|
const currentValue = settings[key] ?? schema.default;
|
|
const displayValue = schema.secret && currentValue ? "••••••••" : String(currentValue ?? "(not set)");
|
|
|
|
if (schema.type === "boolean") {
|
|
items.push({
|
|
id: `config:${key}`,
|
|
label: ` ${key}`,
|
|
description: schema.description || `Configure ${key}`,
|
|
currentValue: currentValue ? "true" : "false",
|
|
values: ["true", "false"],
|
|
});
|
|
} else if (schema.type === "enum") {
|
|
items.push({
|
|
id: `config:${key}`,
|
|
label: ` ${key}`,
|
|
description: schema.description || `Configure ${key}`,
|
|
currentValue: String(currentValue ?? schema.default ?? ""),
|
|
submenu: (cv, done) =>
|
|
new ConfigEnumSubmenu(
|
|
key,
|
|
schema.description || `Select value for ${key}`,
|
|
schema.values,
|
|
cv,
|
|
value => {
|
|
this.callbacks.onConfigChange(key, value);
|
|
done(value);
|
|
},
|
|
() => done(),
|
|
),
|
|
});
|
|
} else {
|
|
// string or number - show as submenu with input
|
|
items.push({
|
|
id: `config:${key}`,
|
|
label: ` ${key}`,
|
|
description: schema.description || `Configure ${key}`,
|
|
currentValue: displayValue,
|
|
submenu: (cv, done) =>
|
|
new ConfigInputSubmenu(
|
|
key,
|
|
schema,
|
|
cv === "(not set)" ? "" : cv,
|
|
value => {
|
|
const parsed = schema.type === "number" ? Number(value) : value;
|
|
this.callbacks.onConfigChange(key, parsed);
|
|
done(String(value));
|
|
},
|
|
() => done(),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
this.#settingsList = new SettingsList(
|
|
items,
|
|
Math.min(items.length, 10),
|
|
getSettingsListTheme(),
|
|
(id, newValue) => {
|
|
if (id === "__enabled__") {
|
|
this.callbacks.onEnabledChange(newValue === "true");
|
|
this.plugin = { ...this.plugin, enabled: newValue === "true" };
|
|
} else if (id.startsWith("feature:")) {
|
|
const featName = id.slice(8);
|
|
this.callbacks.onFeatureChange(featName, newValue === "true");
|
|
// Update local state
|
|
const current = new Set(this.plugin.enabledFeatures ?? []);
|
|
if (newValue === "true") {
|
|
current.add(featName);
|
|
} else {
|
|
current.delete(featName);
|
|
}
|
|
this.plugin = { ...this.plugin, enabledFeatures: [...current] };
|
|
} else if (id.startsWith("config:")) {
|
|
const key = id.slice(7);
|
|
const schema = this.plugin.manifest.settings?.[key];
|
|
if (schema?.type === "boolean") {
|
|
this.callbacks.onConfigChange(key, newValue === "true");
|
|
}
|
|
}
|
|
},
|
|
this.callbacks.onBack,
|
|
);
|
|
|
|
this.addChild(this.#settingsList);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0));
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (!this.#settingsList) return;
|
|
this.#settingsList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Config Submenus
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Submenu for enum config values.
|
|
*/
|
|
class ConfigEnumSubmenu extends Container {
|
|
#selectList: SelectList;
|
|
|
|
constructor(
|
|
key: string,
|
|
description: string,
|
|
values: string[],
|
|
currentValue: string,
|
|
onSelect: (value: string) => void,
|
|
onCancel: () => void,
|
|
) {
|
|
super();
|
|
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0));
|
|
if (description) {
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("muted", description), 0, 0));
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
|
|
const items: SelectItem[] = values.map(v => ({ value: v, label: v }));
|
|
this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme());
|
|
|
|
const currentIndex = values.indexOf(currentValue);
|
|
if (currentIndex !== -1) {
|
|
this.#selectList.setSelectedIndex(currentIndex);
|
|
}
|
|
|
|
this.#selectList.onSelect = item => onSelect(item.value);
|
|
this.#selectList.onCancel = onCancel;
|
|
|
|
this.addChild(this.#selectList);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0));
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#selectList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submenu for string/number config values with text input.
|
|
*/
|
|
class ConfigInputSubmenu extends Container {
|
|
#input: Input;
|
|
|
|
constructor(
|
|
key: string,
|
|
schema: PluginSettingSchema,
|
|
currentValue: string,
|
|
private readonly onSubmit: (value: string) => void,
|
|
private readonly onCancel: () => void,
|
|
) {
|
|
super();
|
|
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0));
|
|
if (schema.description) {
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("muted", schema.description), 0, 0));
|
|
}
|
|
|
|
// Type hint
|
|
let hint = `Type: ${schema.type}`;
|
|
if (schema.type === "number") {
|
|
const numSchema = schema as { min?: number; max?: number };
|
|
if (numSchema.min !== undefined || numSchema.max !== undefined) {
|
|
hint += ` (${numSchema.min ?? ""}..${numSchema.max ?? ""})`;
|
|
}
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", hint), 0, 0));
|
|
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Input field
|
|
this.#input = new Input();
|
|
if (!schema.secret && currentValue) {
|
|
this.#input.setValue(currentValue);
|
|
}
|
|
|
|
this.#input.onSubmit = value => {
|
|
if (value.trim()) {
|
|
this.onSubmit(value);
|
|
} else {
|
|
this.onCancel();
|
|
}
|
|
};
|
|
|
|
this.addChild(this.#input);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to save · Esc to cancel"), 0, 0));
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (data === "\x1b" || data === "\x1b\x1b") {
|
|
this.onCancel();
|
|
return;
|
|
}
|
|
this.#input.handleInput(data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main Plugin Settings Selector
|
|
// =============================================================================
|
|
|
|
export interface PluginSettingsCallbacks {
|
|
onClose: () => void;
|
|
onPluginChanged: () => void;
|
|
}
|
|
|
|
/** Component with handleInput method */
|
|
interface InputHandler {
|
|
handleInput(data: string): void;
|
|
}
|
|
|
|
/**
|
|
* Top-level plugin settings component.
|
|
* Manages navigation between plugin list and plugin detail views.
|
|
*/
|
|
export class PluginSettingsComponent extends Container {
|
|
#manager: PluginManager;
|
|
#viewComponent: (Container & InputHandler) | null = null;
|
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
|
|
#currentView: "list" | "detail" = "list";
|
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
|
|
#currentPlugin: InstalledPlugin | null = null;
|
|
|
|
constructor(
|
|
cwd: string,
|
|
private readonly callbacks: PluginSettingsCallbacks,
|
|
) {
|
|
super();
|
|
this.#manager = new PluginManager(cwd);
|
|
this.#showPluginList();
|
|
}
|
|
|
|
async #showPluginList(): Promise<void> {
|
|
this.#currentView = "list";
|
|
this.#currentPlugin = null;
|
|
this.clear();
|
|
|
|
const plugins = await this.#manager.list();
|
|
|
|
this.#viewComponent = new PluginListComponent(plugins, {
|
|
onPluginSelect: plugin => this.#showPluginDetail(plugin),
|
|
onCancel: () => this.callbacks.onClose(),
|
|
});
|
|
|
|
this.addChild(this.#viewComponent);
|
|
}
|
|
|
|
#showPluginDetail(plugin: InstalledPlugin): void {
|
|
this.#currentView = "detail";
|
|
this.#currentPlugin = plugin;
|
|
this.clear();
|
|
|
|
this.#viewComponent = new PluginDetailComponent(plugin, this.#manager, {
|
|
onEnabledChange: async enabled => {
|
|
await this.#manager.setEnabled(plugin.name, enabled);
|
|
this.callbacks.onPluginChanged();
|
|
},
|
|
onFeatureChange: async (feature, enabled) => {
|
|
const current = new Set((await this.#manager.getEnabledFeatures(plugin.name)) ?? []);
|
|
if (enabled) {
|
|
current.add(feature);
|
|
} else {
|
|
current.delete(feature);
|
|
}
|
|
await this.#manager.setEnabledFeatures(plugin.name, [...current]);
|
|
this.callbacks.onPluginChanged();
|
|
},
|
|
onConfigChange: async (key, value) => {
|
|
await this.#manager.setPluginSetting(plugin.name, key, value);
|
|
this.callbacks.onPluginChanged();
|
|
},
|
|
onBack: () => this.#showPluginList(),
|
|
});
|
|
|
|
this.addChild(this.#viewComponent);
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#viewComponent?.handleInput(data);
|
|
}
|
|
}
|