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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 24.7 KB | 7 permissions | Listed |
No comments yet.