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

  • Manage plugins plugins.manageOfficial onlyReloads and enables plugins. Only official plugins can have it.List, enable, disable and reload plugins, install updates, and quit to finish an update
  • Change app settings settings.writeHighChanges the app settings.Change the settings and the theme
  • Manage agents agents.manageHighEnables, disables and signs in agents, and changes their settings.Turn agents on and off, sign in to them and update them
  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Import an agent's own sessions as chats
  • Read chats chats.readMediumReads your transcripts and chat lists.List the workspaces an agent's sessions can be imported into
  • Read app settings settings.readLowReads the app settings.Show the settings, and the tools and rules of every plugin
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the settings pageSlots: center

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <324.7 KB7 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.