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

service.test.ts7.7 KB
// The convergence/settings service: showShortcuts routes to settings and
// opens Keyboard Shortcuts searched for the command. Real activation
// against a fake host and kernel; calls go through the provided method.
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
  Api,
  AppState,
  AppStatePatch,
  HostEvent,
  RenderResult,
  SlotOptions,
  ViewContext,
} from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";

const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
  JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));

type ServiceImpl = Record<string, (params: never, ctx: unknown) => Promise<unknown>>;

function fakeApi() {
  let state: AppState = {};
  const appListeners = new Set<(state: AppState) => void>();
  const views: { render: (ctx: ViewContext<object>) => RenderResult; active: boolean }[] = [];
  const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
  const hostCalls: { method: string; params: unknown }[] = [];
  const kernelCalls: { method: string; params: unknown }[] = [];
  const notices: string[] = [];
  let dirty = true;
  const provided = new Map<string, ServiceImpl>();
  const emitted: { event: string; payload: unknown }[] = [];
  const commands = {
    commands: [
      {
        id: "a.open",
        title: "Open",
        category: "A",
        plugin: "a",
        pluginTitle: "A",
        official: true,
        key: "cmd+x",
        defaultKey: "cmd+x",
        keys: ["cmd+x"],
        labels: ["⌘X"],
        source: "default",
        conflict: null,
        problem: null,
        kernel: false,
        enabled: true,
        registered: true,
      },
    ],
    errors: [],
  };
  const update = (patch: AppStatePatch) => {
    state = {
      ...state,
      ...patch,
      workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
      chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
      route: patch.route ?? state.route,
      shared: { ...state.shared, ...patch.shared },
    };
    dirty = true;
    for (const listener of appListeners) listener(state);
  };
  const hostCall = async (method: string, params: unknown) => {
    hostCalls.push({ method, params });
    switch (method) {
      case "host/settings.get":
        return { appearance: "system", tools: { mode: "code", disabled: [], disabledRules: [] } };
      case "host/agents.all":
        return { agents: [] };
      case "host/plugins.list":
        return { plugins: [] };
      case "host/workspaces.list":
        return { workspaces: [] };
      case "host/updates.status":
        return {};
      case "host/tools.list":
        return null;
      case "host/plugins.settings":
        return { name: "x", error: null, properties: [], notices: [] };
      default:
        throw new Error(`Unexpected host method ${method}`);
    }
  };
  const kernelCall = async (method: string, params: unknown) => {
    kernelCalls.push({ method, params });
    if (method === "commands") return commands;
    if (method === "themes") return { themes: [] };
    if (method === "settings_view") return { view: null };
    throw new Error(`Unexpected kernel method ${method}`);
  };
  const broker = testLayer();
  // This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
  const api = new Proxy(
    {
      ...broker.api,
      plugin: "settings",
      id: "convergence/settings",
      ui,
      services: {
        provide: async (def: { name: string }, impl: ServiceImpl) => {
          provided.set(def.name, impl);
          return {
            emit: (event: string, payload: unknown) => {
              emitted.push({ event, payload });
            },
            remove: () => {
              provided.delete(def.name);
            },
          };
        },
        get: (name: string) => {
          throw new Error(`no provider for ${name}`);
        },
        available: () => false,
        when: () => () => {},
      },
      host: { ...broker.api.host, call: hostCall, kernel: kernelCall },
      app: {
        state: () => state,
        update,
        select: (chatId: string, workspaceId: string) => update({ chatId, workspaceId, route: "chat" }),
        route: (route: string) => update({ route }),
        onChange: (fn: (state: AppState) => void) => {
          appListeners.add(fn);
          return () => {
            appListeners.delete(fn);
          };
        },
      },
      slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, _options?: SlotOptions) => {
        const view = { render, active: true };
        views.push(view);
        dirty = true;
        return {
          id: views.length,
          update: () => {
            dirty = true;
          },
          remove: () => {
            view.active = false;
          },
        };
      },
      on: (name: string, fn: (event: HostEvent) => unknown) => {
        const subscribers = listeners.get(name) ?? new Set();
        listeners.set(name, subscribers);
        subscribers.add(fn);
        return () => {
          subscribers.delete(fn);
        };
      },
      command: () => ({ remove() {} }),
      notify: (message: string) => {
        notices.push(message);
      },
      focus: () => {},
    },
    {
      get(target, key, receiver) {
        if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
        throw new Error(`Unexpected API ${String(key)}`);
      },
    },
  ) as unknown as Api;
  const render = () =>
    views
      .filter((view) => view.active)
      .map((view) =>
        view.render({
          state: {},
          app: state,
          plugin: "settings",
          update: () => {
            dirty = true;
          },
        }),
      );
  let trees: RenderResult[] = [];
  const settle = async () => {
    for (let round = 0; round < 50; round++) {
      await tick();
      if (!dirty) {
        await tick();
        if (!dirty) return;
      }
      dirty = false;
      trees = render();
    }
    throw new Error("settings did not settle");
  };
  const ctx = { caller: { plugin: "test/client", id: "test/client" }, signal: AbortSignal.timeout(5000) };
  return {
    api,
    hostCalls,
    kernelCalls,
    notices,
    provided,
    settle,
    trees: () => stable(trees),
    state: () => state,
    callService: (method: string, params: unknown) =>
      provided.get("convergence/settings")?.[method]?.(params as never, ctx),
  };
}

test("showShortcuts routes to settings and searches Keyboard Shortcuts", async () => {
  const fake = fakeApi();
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    assert.ok(fake.provided.has("convergence/settings"));
    // Before the page ever loads there are no keys to reload; the route still moves.
    assert.deepEqual(await fake.callService("showShortcuts", { query: "assign-me" }), null);
    assert.equal(fake.state().route, "settings");
    assert.ok(!fake.kernelCalls.some((call) => call.method === "commands"));
    assert.equal(fake.state().shared?.showShortcuts, undefined);
    // Opening the page loads it; the next request reloads the keys too.
    await fake.settle();
    assert.ok(fake.trees().includes("Keyboard Shortcuts") || fake.trees().includes("Loading settings"));
    await fake.settle();
    assert.deepEqual(await fake.callService("showShortcuts", { query: "assign-me" }), null);
    await fake.settle();
    assert.ok(fake.kernelCalls.some((call) => call.method === "commands"));
    assert.ok(fake.trees().includes("assign-me"));
    assert.equal(fake.notices.length, 0);
  } finally {
    await dispose();
  }
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.