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

controller.ts17.7 KB
// The settings page's state and its effects: loading the page, changing a
// setting, enabling a plugin or agent, recording a key. Views only mutate
// this state and redraw; every host or kernel call goes through here.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { App, Host, Kernel, Notify, Plugin } from "convergence/effect";
import type { KernelCallFailed, HostError, PermissionNotGranted } from "convergence/effect";
import type {
  AgentInfo,
  CommandInfo,
  PluginSettingProperty,
  PluginSummary,
  PresetColor,
  SettingValue,
  Settings,
  ToolsOverview,
  UpdateStatus,
  ViewHandle,
  Workspace,
} from "convergence";
import { chordOf } from "./model.ts";
import type { SectionData } from "./model.ts";

export type AgentEntry = AgentInfo & { readonly enabled: boolean };
export type PageError = HostError | KernelCallFailed | PermissionNotGranted;
// Every tag a host or kernel call fails with on this page.
const ERROR_TAGS = ["PermissionNotGranted", "NeedsReview", "HostCallFailed", "KernelCallFailed"] as const;

/// One key being recorded, with the strokes so far.
interface Recording {
  command: string;
  strokes: string[];
  waiting: boolean;
}

export interface PageState {
  section: string;
  updates: UpdateStatus | null;
  settings: Settings | null;
  agents: readonly AgentEntry[];
  plugins: readonly PluginSummary[] | null;
  keys: { readonly commands: readonly CommandInfo[]; readonly errors: readonly string[] } | null;
  workspaces: readonly Workspace[];
  /// The preset colours a workspace's folder can take.
  colors: readonly PresetColor[];
  tools: ToolsOverview | null;
  importWorkspace: string | null;
  importing: boolean;
  loaded: boolean;
  keysQuery: string;
  recording: Recording | null;
  sections: Record<string, SectionData>;
  drafts: Record<string, string>;
  draftErrors: Record<string, string>;
  sectionViews: Record<string, number | null>;
  showRegistry: boolean;
  openPermissions: Record<string, boolean>;
}

export function initialState(): PageState {
  return {
    section: "general",
    updates: null,
    settings: null,
    agents: [],
    plugins: null,
    keys: null,
    workspaces: [],
    colors: [],
    tools: null,
    importWorkspace: null,
    importing: false,
    loaded: false,
    keysQuery: "",
    recording: null,
    sections: {},
    drafts: {},
    draftErrors: {},
    sectionViews: {},
    showRegistry: false,
    openPermissions: {},
  };
}

const makePage = Effect.fn("SettingsPage.make")(function* () {
  const host = yield* Host;
  const kernel = yield* Kernel;
  const app = yield* App;
  const notify = yield* Notify;
  const plugin = yield* Plugin;
  const state = initialState();
  const views: ViewHandle[] = [];
  const redraw = () => {
    for (const view of views) view.update();
  };

  // `run(label, action)` from the original: the label and the error's
  // message go to a notice, whatever went wrong.
  const attempt = <A, R>(label: string, effect: Effect.Effect<A, PageError, R>) =>
    effect.pipe(
      Effect.catchTag(ERROR_TAGS, (error) => notify.error(`${label}: ${error.message}`)),
      Effect.asVoid,
    );

  // The plugin whose section is open, if one is.
  const sectionPlugin = () => (state.section.startsWith("plugin:") ? state.section.slice("plugin:".length) : null);

  const loadKeys = Effect.fn("SettingsPage.loadKeys")(function* () {
    state.keys = yield* kernel.call("commands", {});
    redraw();
  });

  const loadSection = Effect.fn("SettingsPage.loadSection")(function* (name: string) {
    state.sections[name] = yield* host
      .call("host/plugins.settings", { name })
      .pipe(
        Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
          Effect.succeed<SectionData>({ name, error: error.message, properties: [], notices: [] }),
        ),
      );
    const custom = yield* kernel
      .call("settings_view", { plugin: name })
      .pipe(Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.succeed({ view: null })));
    state.sectionViews[name] = custom.view;
    redraw();
  });

  const load = Effect.fn("SettingsPage.load")(function* () {
    const [settings, agents, plugins, commands, workspaces, updates] = yield* Effect.all(
      [
        host.call("host/settings.get", {}),
        host.call("host/agents.all", {}),
        host.call("host/plugins.list", {}),
        kernel.call("commands", {}),
        host.call("host/workspaces.list", {}),
        host.call("host/updates.status", {}),
      ],
      { concurrency: "unbounded" },
    );
    state.updates = updates;
    state.settings = settings;
    state.agents = agents.agents;
    state.plugins = plugins.plugins;
    state.keys = commands;
    state.workspaces = workspaces.workspaces;
    state.colors = workspaces.colors ?? [];
    // Through `call`: a build without plugin tools answers "unknown host method".
    state.tools = yield* host
      .call("host/tools.list", {})
      .pipe(Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], () => Effect.succeed(null)));
    if (!state.workspaces.some((w) => w.id === state.importWorkspace)) {
      const here = (yield* app.state).workspaceId;
      state.importWorkspace = state.workspaces.find((w) => w.id === here)?.id ?? state.workspaces[0]?.id ?? null;
    }
    state.loaded = true;
    redraw();
    const open = sectionPlugin();
    if (open) yield* loadSection(open);
  });

  const updateSettings = Effect.fn("SettingsPage.updateSettings")(function* (patch: Partial<Settings>) {
    state.settings = yield* host.call("host/settings.update", patch);
    redraw();
  });

  const changeAppearance = Effect.fn("SettingsPage.changeAppearance")(function* (value: Settings["appearance"]) {
    yield* updateSettings({ appearance: value });
    yield* kernel.call("theme", { mode: value === "system" ? undefined : value });
  });

  const importSessions = Effect.fn("SettingsPage.importSessions")(function* () {
    const workspaceId = state.importWorkspace;
    // The button is disabled without one; the guard is for the type only.
    if (workspaceId === null) return;
    state.importing = true;
    redraw();
    let total = 0;
    for (const agent of state.agents.filter((agent) => agent.enabled)) {
      yield* host.call("host/chats.import", { workspaceId, agentId: agent.id }).pipe(
        Effect.tap((result) =>
          Effect.sync(() => {
            total += result.imported;
          }),
        ),
        Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
          notify.error(`Could not import from ${agent.name}: ${error.message}`),
        ),
      );
    }
    state.importing = false;
    // The baseline notified without a level; keep the call identical.
    yield* Effect.sync(() => plugin.api.notify(total ? `Imported ${total} sessions.` : "No new sessions found."));
    redraw();
  });

  const signIn = Effect.fn("SettingsPage.signIn")(function* (agent: AgentEntry, method: { readonly id: string }) {
    yield* host.call("host/agents.authenticate", { agentId: agent.id, method: method.id }).pipe(
      // The baseline notified without a level; keep the call identical.
      Effect.tap(() => Effect.sync(() => plugin.api.notify(`${agent.name}: signed in.`))),
      Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
        notify.error(`${agent.name}: ${error.message}`),
      ),
    );
  });

  const updateAgent = Effect.fn("SettingsPage.updateAgent")(function* (agent: AgentEntry) {
    yield* host.call("host/agents.update", { agentId: agent.id }).pipe(
      // The baseline notified without a level; keep the call identical.
      Effect.tap(() => Effect.sync(() => plugin.api.notify(`${agent.name}: updated.`))),
      Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
        notify.error(`${agent.name}: ${error.message}`),
      ),
    );
  });

  const setAgentEnabled = Effect.fn("SettingsPage.setAgentEnabled")((agent: AgentEntry, enabled: boolean) =>
    host.call("host/agents.set_enabled", { agentId: agent.id, enabled }),
  );

  // Only the native card grants anything: the page asks for it, and the
  // user answers there.
  const review = (name: string) =>
    attempt(
      `Could not review ${name}`,
      Effect.gen(function* () {
        const { requestId } = yield* host.call("host/plugins.request_enable", { name });
        if (!requestId) yield* load();
      }),
    );

  // On again works only for a plugin enabled before with nothing new to
  // review; anything else goes to its card.
  const setPluginEnabled = (name: string, enabled: boolean) =>
    host.call("host/plugins.set_enabled", { name, enabled }).pipe(
      Effect.catchTag("NeedsReview", () => host.call("host/plugins.request_enable", { name })),
      Effect.catchTag(["HostCallFailed", "PermissionNotGranted"], (error) =>
        notify.error(`Could not change ${name}: ${error.message}`),
      ),
      Effect.asVoid,
    );

  const revoke = (name: string, entry: PluginSummary["permissions"][number]) =>
    attempt(
      `Could not revoke ${entry.permission}`,
      host.call("host/plugins.revoke", { name, permission: entry.permission, scope: entry.scope }),
    );

  const reload = (name: string) =>
    attempt(
      `Could not reload ${name}`,
      Effect.gen(function* () {
        yield* host.call("host/plugins.reload", { name });
        yield* load();
      }),
    );

  const reportBug = () => attempt("Could not open the bug report form", host.call("host/market.open_bug_form", {}));

  // A workspace's mark: a folder in `color`, or (`null`) its project
  // icon. The workspace-changed event that follows reloads the page.
  const setWorkspaceColor = (workspaceId: string, color: PresetColor | null) =>
    attempt("Could not change the workspace's icon", host.call("host/workspaces.set_color", { workspaceId, color }));

  const updateTools = Effect.fn("SettingsPage.updateTools")(function* (patch: Partial<Settings["tools"]>) {
    const tools = state.settings?.tools ?? { mode: "code", disabled: [], disabledRules: [] };
    yield* updateSettings({ tools: { ...tools, ...patch } });
    state.tools = yield* host.call("host/tools.list", {});
    redraw();
  });

  const route = (name: string) => app.route(name);

  const installApp = () =>
    attempt(
      "The update failed",
      Effect.gen(function* () {
        const result = yield* host.call("host/updates.install_app", {});
        if (result.restart) yield* kernel.call("quit", {});
      }),
    );

  const applyPlugins = () =>
    attempt(
      "The plugin update failed",
      Effect.gen(function* () {
        const { report, chatId } = yield* host.call("host/updates.apply_plugins", {});
        const changed = report.updated.length + report.added.length + report.removed.length;
        if (!chatId)
          // The baseline notified without a level; keep the call identical.
          yield* Effect.sync(() =>
            plugin.api.notify(
              changed ? `Plugins updated (${changed} changed).` : "The plugins were already up to date.",
            ),
          );
      }),
    );

  const checkNow = () => attempt("Could not check for updates", host.call("host/updates.check", {}));

  const refreshUpdates = Effect.fn("SettingsPage.refreshUpdates")(function* () {
    state.updates = yield* host.call("host/updates.status", {});
    redraw();
  });

  // --- Keyboard Shortcuts ---------------------------------------------------

  const keysRun = (label: string, action: Effect.Effect<unknown, PageError>) =>
    attempt(label, Effect.andThen(action, loadKeys));

  const record = Effect.fn("SettingsPage.record")(function* (command: CommandInfo) {
    state.recording = { command: command.id, strokes: [], waiting: true };
    redraw();
    const result = yield* kernel
      .call("keys.record", {})
      .pipe(
        Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], (error) =>
          Effect.succeed<{ readonly key: string | null; readonly error?: string }>({ key: null, error: error.message }),
        ),
      );
    const current = state.recording;
    if (!current || current.command !== command.id) return;
    current.waiting = false;
    if (result.key) current.strokes.push(result.key);
    else if ("error" in result && result.error) yield* notify.warning(`That key cannot be bound: ${result.error}`);
    if (!current.strokes.length) state.recording = null;
    redraw();
  });

  const recordMore = Effect.fn("SettingsPage.recordMore")(function* () {
    const current = state.recording;
    if (!current || current.strokes.length >= 2) return;
    current.waiting = true;
    redraw();
    const { key } = yield* kernel
      .call("keys.record", {})
      .pipe(Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.succeed({ key: null })));
    if (state.recording !== current) return;
    current.waiting = false;
    if (key) current.strokes.push(key);
    redraw();
  });

  const cancelRecord = kernel.call("keys.record_cancel", {}).pipe(
    Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.void),
    Effect.asVoid,
  );

  // View-side: clears the recorder at once; the cancel goes out on its own.
  const stopRecording = () => {
    const waiting = state.recording?.waiting ?? false;
    state.recording = null;
    redraw();
    return waiting;
  };

  const saveRecording = Effect.fn("SettingsPage.saveRecording")(function* () {
    const current = state.recording;
    state.recording = null;
    if (!current?.strokes.length) {
      redraw();
      return;
    }
    yield* keysRun(
      "Could not change the key",
      kernel.call("keybindings.change", { command: current.command, change: "set", key: chordOf(current.strokes) }),
    );
  });

  const openKeybindings = () => attempt("Could not open keybindings.json", kernel.call("keybindings.open", {}));

  const resetKey = (command: CommandInfo) =>
    keysRun("Could not reset the key", kernel.call("keybindings.change", { command: command.id, change: "reset" }));

  const unbindKey = (command: CommandInfo) =>
    keysRun("Could not remove the key", kernel.call("keybindings.change", { command: command.id, change: "unbind" }));

  // --- a plugin's own section -----------------------------------------------

  const saveSetting = Effect.fn("SettingsPage.saveSetting")(function* (
    name: string,
    property: PluginSettingProperty,
    value: SettingValue,
  ) {
    const k = `${name}/${property.key}`;
    yield* host.call("host/plugins.set_setting", { name, key: property.key, value }).pipe(
      Effect.tap((result) =>
        Effect.andThen(
          Effect.sync(() => {
            delete state.drafts[k];
            delete state.draftErrors[k];
          }),
          result.requestId
            ? // The baseline notified without a level; keep the call identical.
              Effect.sync(() => plugin.api.notify(`${property.title}: allow the change on the card to use it.`))
            : Effect.void,
        ),
      ),
      Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
        Effect.sync(() => {
          state.draftErrors[k] = error.message;
        }),
      ),
    );
    yield* loadSection(name);
  });

  // The masked secret field tells the page its save failed; the host
  // never sees the value itself.
  const secretFailed = (property: PluginSettingProperty, error: string) => notify.error(`${property.title}: ${error}`);

  // Text and path settings pick what they show from a native dialog.
  const pickPath = Effect.fn("SettingsPage.pickPath")(function* (property: PluginSettingProperty) {
    const picked =
      property.kind === "folder"
        ? (yield* Effect.promise(() => plugin.api.pickFolder())).path
        : (yield* Effect.promise(() => plugin.api.pickImages())).paths[0];
    return picked ?? null;
  });

  const setDraft = (key: string, value: string) => {
    state.drafts[key] = value;
    delete state.draftErrors[key];
    redraw();
  };

  const openSection = Effect.fn("SettingsPage.openSection")(function* (id: string, plugin?: string) {
    state.section = id;
    redraw();
    if (plugin) yield* loadSection(plugin);
    else if (id === "keys") yield* loadKeys();
  });

  // The palette's Assign request for a command in conflict: Keyboard
  // Shortcuts, searched for that command. The convergence/settings service
  // reaches this; the route brings the page on screen.
  const showShortcuts = Effect.fn("SettingsPage.showShortcuts")(function* (query: string) {
    state.section = "keys";
    state.keysQuery = query;
    redraw();
    yield* route("settings");
    // The page is already on its way; a keys load that fails now is a
    // notice, not a failed service call.
    if (state.loaded)
      yield* loadKeys().pipe(
        Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], (error) =>
          notify.error(`Could not load keyboard shortcuts: ${error.message}`),
        ),
      );
    return null;
  });

  return {
    state,
    views,
    load,
    loadKeys,
    loadSection,
    updateSettings,
    changeAppearance,
    importSessions,
    signIn,
    updateAgent,
    setAgentEnabled,
    review,
    setPluginEnabled,
    revoke,
    reload,
    reportBug,
    updateTools,
    setWorkspaceColor,
    route,
    installApp,
    applyPlugins,
    checkNow,
    refreshUpdates,
    keysRun,
    record,
    recordMore,
    cancelRecord,
    stopRecording,
    saveRecording,
    openKeybindings,
    resetKey,
    unbindKey,
    saveSetting,
    secretFailed,
    pickPath,
    setDraft,
    openSection,
    showShortcuts,
    sectionPlugin,
  };
});

export class SettingsPage extends Context.Service<SettingsPage, Effect.Success<ReturnType<typeof makePage>>>()(
  "settings/SettingsPage",
) {
  static readonly layer = Layer.effect(SettingsPage, makePage());
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.