Official
settings
Built-in settings UI (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/settings@0.2.0
Permissions in 0.2.0
Files
model.ts5.7 KB
// The settings page's pure parts: the sections it lists, the search of the
// Keyboard Shortcuts section, and the check of what the user types into a
// plugin's setting before the host sees it. Node runs them (model.test.ts).
import type { PluginSettingProperty, PluginSettingsSection, SettingValue } from "convergence";
/// What the shortcut search and grouping read of a command: every field of
/// the host's `CommandInfo` they touch, so tests can use smaller fixtures.
export interface CommandMatch {
readonly id: string;
readonly title: string;
readonly category?: string | null;
readonly plugin: string;
readonly pluginTitle?: string | null;
readonly when?: string | null;
readonly keys?: readonly string[];
readonly labels?: readonly string[];
readonly defaultKey?: string | null;
readonly kernel?: boolean;
}
/// The app's own sections, in the order of the page's list.
export interface SectionEntry {
readonly id: string;
readonly label: string;
readonly icon?: string;
readonly plugin?: string;
readonly enabled?: boolean;
}
export const APP_SECTIONS: readonly { readonly id: string; readonly label: string; readonly icon: string }[] = [
{ id: "general", label: "General", icon: "settings" },
{ id: "agents", label: "Agents", icon: "bot" },
{ id: "workspaces", label: "Workspaces", icon: "folders" },
{ id: "plugins", label: "Plugins", icon: "puzzle" },
{ id: "tools", label: "Tools", icon: "wrench" },
{ id: "keys", label: "Keyboard Shortcuts", icon: "keyboard" },
{ id: "updates", label: "Updates", icon: "circle-arrow-up" },
];
/// One section for each plugin with settings, by its title (D42).
export function pluginSections(
plugins: readonly { readonly name: string; readonly settings?: string; readonly enabled: boolean }[] | null,
): SectionEntry[] {
return (plugins ?? [])
.filter((plugin) => plugin.settings)
.map((plugin) => ({
id: `plugin:${plugin.name}`,
label: plugin.settings ?? "",
plugin: plugin.name,
enabled: plugin.enabled,
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
/// Whether a command matches the search of the Keyboard Shortcuts
/// section: every word in its title, category, plugin, id, `when`, or keys.
export function matchesCommand(command: CommandMatch, query: unknown): boolean {
const words = String(query ?? "")
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (!words.length) return true;
const haystack = [
command.title,
command.category,
command.pluginTitle,
command.plugin,
command.id,
command.when,
...(command.keys ?? []),
...(command.labels ?? []),
command.defaultKey ?? undefined,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return words.every((word) => haystack.includes(word));
}
/// The commands the section lists for `query`, grouped by plugin: the
/// app's own first, then each plugin by its name.
export function shortcutGroups<C extends CommandMatch>(commands: readonly C[] | null, query: unknown) {
const groups = new Map<string, C[]>();
for (const command of commands ?? []) {
if (!matchesCommand(command, query)) continue;
const key = command.kernel ? "" : (command.pluginTitle ?? command.plugin);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)?.push(command);
}
return [...groups.entries()]
.sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)))
.map(([title, list]) => ({
title: title || "Divergence",
commands: list.sort((a, b) => a.title.localeCompare(b.title)),
}));
}
/// A recorded chord from its strokes: `cmd-k cmd-s`.
export function chordOf(strokes: readonly string[]): string {
return strokes.filter(Boolean).join(" ");
}
/// The value to send for a setting from what the user typed, or an
/// error: numbers are parsed and kept in range, empty text clears the
/// setting. Everything else the host checks again.
export function parseInput(
property: Pick<PluginSettingProperty, "type" | "minimum" | "maximum">,
text: unknown,
): { value: SettingValue } | { error: string } {
const raw = String(text ?? "");
switch (property.type) {
case "number": {
if (!raw.trim()) return { value: null };
const number = Number(raw);
if (!Number.isFinite(number)) return { error: "Enter a number" };
if (property.minimum != null && number < property.minimum) return { error: `At least ${property.minimum}` };
if (property.maximum != null && number > property.maximum) return { error: `At most ${property.maximum}` };
return { value: number };
}
case "color":
if (!raw.trim()) return { value: null };
return /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(raw.trim())
? { value: raw.trim() }
: { error: "A color is #rgb or #rrggbb" };
default:
return { value: raw.trim() ? raw : null };
}
}
/// The text an input shows for a setting's value.
export function textOf(value: unknown): string {
if (value === null || value === undefined) return "";
return typeof value === "string" ? value : String(value);
}
/// The options of a select or multiSelect, as `{ value, label }`.
export function optionsOf(property: Pick<PluginSettingProperty, "options">) {
return (property.options ?? []).map((option) =>
typeof option === "string"
? { value: option, label: option }
: { value: option.value, label: option.label ?? option.value },
);
}
/// A plugin's settings section, or the placeholder the page shows when
/// the host would not give it (the plugin is gone, or it failed).
export type SectionData =
| PluginSettingsSection
| {
readonly name: string;
readonly error: string;
readonly properties: readonly PluginSettingProperty[];
readonly notices: readonly string[];
};Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 24.7 KB | 7 permissions | Listed |
No comments yet.