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

views/pluginSection.ts10.2 KB
// A plugin's own settings section (MARKETPLACE.md §15.1): the properties
// its manifest declares, their controls, its custom view module and the
// commands it registers.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import * as Effect from "effect/Effect";
import { html as htm } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { CommandInfo, PluginSettingProperty, PluginSummary } from "convergence";
import type { SettingsPage } from "../controller.ts";
import { optionsOf, parseInput, textOf } from "../model.ts";
import { card, section, setting, column, row, fill } from "./layout.ts";
import { capsOf } from "./keys.ts";

const draftKey = (name: string, key: string) => `${name}/${key}`;

// Text settings keep what is typed until it is saved: at once for most,
// with the Save button for one a permission refers to (its change needs
// the user's card).
function textSetting(
  ctx: EffectViewContext<object>,
  actions: SettingsPage["Service"],
  name: string,
  property: PluginSettingProperty,
) {
  const state = actions.state;
  const k = draftKey(name, property.key);
  const draft = state.drafts[k];
  const shown = draft ?? textOf(property.value);
  const changed = draft !== undefined && draft !== textOf(property.value);
  const save = () => {
    const parsed = parseInput(property, shown);
    if ("error" in parsed) {
      state.draftErrors[k] = parsed.error;
      ctx.update();
      return;
    }
    ctx.run(actions.saveSetting(name, property, parsed.value));
  };
  const pick =
    property.type === "path"
      ? htm`<button id=${`pick-${k}`} label="Choose…" variant="outline" onClick=${() => {
          ctx.run(
            Effect.andThen(actions.pickPath(property), (picked: string | null) =>
              picked
                ? Effect.sync(() => {
                    state.drafts[k] = picked;
                    ctx.update();
                  })
                : Effect.void,
            ),
          );
        }} />`
      : null;
  return htm`
      <div ...${column} gap="small" padding="small">
        <div ...${row} gap="small" padding="small">
          ${property.type === "color" && /^#/.test(shown) ? htm`<div width=${16} height=${16} borderRadius="sm" background=${shown} flexShrink=${0} />` : null}
          <div ...${column} ...${fill} padding="small">
            <input id=${`setting-${k}`} value=${shown} placeholder=${property.placeholder ?? (property.default != null ? String(property.default) : "")}
              onChange=${(e: { value: string }) => actions.setDraft(k, e.value)}
              onSubmit=${save} />
          </div>
          ${pick}
          ${changed ? htm`<button id=${`save-${k}`} label="Save" variant="primary" onClick=${save} />` : null}
        </div>
      </div>`;
}

function settingControl(
  ctx: EffectViewContext<object>,
  actions: SettingsPage["Service"],
  name: string,
  property: PluginSettingProperty,
) {
  const k = draftKey(name, property.key);
  switch (property.type) {
    case "boolean":
      return htm`<switch id=${`setting-${k}`} label="" checked=${!!property.value} onChange=${(e: { value: boolean }) => ctx.run(actions.saveSetting(name, property, e.value))} />`;
    case "select":
      return htm`<select id=${`setting-${k}`} value=${textOf(property.value)} small options=${optionsOf(property)}
          onChange=${(e: { value: string }) => ctx.run(actions.saveSetting(name, property, e.value))} />`;
    case "multiSelect": {
      const values = Array.isArray(property.value) ? property.value : [];
      const chosen = new Set(values.filter((v): v is string => typeof v === "string"));
      return htm`
          <div ...${column} gap="small" padding="small">
            ${optionsOf(property).map(
              (option) => htm`
              <checkbox id=${`setting-${k}-${option.value}`} label=${option.label} checked=${chosen.has(option.value)}
                onChange=${(e: { value: boolean }) => {
                  const next = optionsOf(property)
                    .map((o) => o.value)
                    .filter((v) => (v === option.value ? e.value : chosen.has(v)));
                  ctx.run(actions.saveSetting(name, property, next));
                }} />`,
            )}
          </div>`;
    }
    // The native field: what is typed goes straight to the system's
    // credential store; this page never sees it.
    case "secret":
      return htm`
          <div ...${column} gap="small" padding="small">
            <secret_input id=${`secret-${k}`} plugin=${name} setting=${property.key} placeholder=${property.secretSet ? "••••••••  (enter a new value)" : "Not set"}
              onSaved=${(e: { ok: true } | { ok: false; error: string }) => {
                if (!e.ok) ctx.run(actions.secretFailed(property, e.error));
                ctx.run(actions.loadSection(name));
              }} />
            ${
              property.secretSet
                ? htm`<button id=${`clear-${k}`} label="Clear" variant="ghost" small onClick=${() => ctx.run(actions.saveSetting(name, property, null))} />`
                : null
            }
          </div>`;
    default:
      return textSetting(ctx, actions, name, property);
  }
}

// The plugin on or off, at the top of its own page. A plugin never
// enabled, or one with permissions to review, is enabled from its review
// card, as in the plugin list.
function enabledRow(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], plugin: PluginSummary) {
  const reviewFirst = !plugin.enabled && (plugin.state === "not_enabled" || plugin.pending.length > 0);
  return setting({
    title: "Enabled",
    description: plugin.enabled
      ? "Turned off, the plugin stops: its views, commands, tools and agents go away until you turn it on."
      : "The plugin does not run. Its settings apply once it is on.",
    control: reviewFirst
      ? htm`<button id=${`review-page-${plugin.name}`} label="Review and enable…" small variant="primary" onClick=${() => ctx.run(actions.review(plugin.name))} />`
      : htm`<switch id=${`plugin-page-${plugin.name}`} label="" checked=${plugin.enabled} onChange=${(e: { value: boolean }) => ctx.run(actions.setPluginEnabled(plugin.name, e.value))} />`,
  });
}

export function renderPluginSection(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], name: string) {
  const state = actions.state;
  const data = state.sections[name];
  if (!data) {
    ctx.run(actions.loadSection(name));
    return htm`<empty>Loading…</empty>`;
  }
  if ("error" in data) return htm`<code>${data.error}</code>`;
  const plugin = state.plugins?.find((p) => p.name === name);
  const commands: readonly CommandInfo[] = (state.keys?.commands ?? []).filter((command) => command.plugin === name);
  const wide = (property: PluginSettingProperty) => !["boolean", "select"].includes(property.type);
  // An object setting has no field: the plugin's own view edits it.
  const rows = data.properties
    .filter((property) => property.type !== "object")
    .map((property) => {
      const k = draftKey(name, property.key);
      const error = state.draftErrors[k] ?? property.error;
      const notes = [
        property.description,
        property.locked
          ? "A permission uses this setting: a new value needs your approval on a card before the plugin can use it."
          : null,
      ]
        .filter(Boolean)
        .join(" ");
      return htm`
        <div key=${property.key} ...${column} gap="small" padding="small">
          ${setting({ title: property.title, description: notes, wide: wide(property), control: settingControl(ctx, actions, name, property) })}
          ${error ? htm`<div ...${row} padding="small" paddingX="large"><text style="body" size="xs" tone="danger">${error}</text></div>` : null}
        </div>`;
    });
  const custom = state.sectionViews[name];
  const granted = plugin?.permissions.filter((entry) => entry.granted).length ?? 0;
  return htm`
      <div ...${column} gap="large" padding="small">
        ${
          data.notices.length
            ? htm`<panel>
              ${data.notices.map((notice) => htm`<text style="body" size="xs" tone="warning">${notice}</text>`)}
            </panel>`
            : null
        }
        ${section(data.title, plugin?.description ?? null, card([plugin ? htm`<div ...${column} gap="small" padding="small">${enabledRow(ctx, actions, plugin)}</div>` : null, ...rows]))}
        ${custom ? htm`<div ...${column} padding="small" flexShrink=${0}><view view=${custom} fit /></div>` : null}
        ${
          commands.length
            ? section(
                "Commands",
                "Change a key in Keyboard Shortcuts.",
                card(
                  commands.map(
                    (command) => htm`
              <div key=${command.id} ...${row} gap="medium" padding="small" paddingX="medium" paddingY="small">
                <text style="body" size="sm">${command.title}</text>
                <spacer />
                ${capsOf(command.labels[0] ?? null)}
                <button id=${`keys-of-${command.id}`} icon="keyboard" variant="ghost" small tooltip="Change in Keyboard Shortcuts"
                  onClick=${() => {
                    state.section = "keys";
                    state.keysQuery = command.id;
                    ctx.update();
                  }} />
              </div>`,
                  ),
                ),
              )
            : null
        }
        ${
          plugin
            ? section(
                "Permissions",
                null,
                card([
                  setting({
                    title: `${granted} of ${plugin.permissions.length} granted`,
                    description: plugin.pending.length
                      ? `${plugin.pending.length} wait for your review.`
                      : "What the plugin may do beyond drawing its own views.",
                    control: htm`<button id=${`manage-${name}`} label="Manage" variant="outline" small onClick=${() => {
                      state.openPermissions = { ...state.openPermissions, [name]: true };
                      state.section = "plugins";
                      ctx.update();
                    }} />`,
                  }),
                ]),
              )
            : null
        }
      </div>`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.