Official
palette
Built-in command palette (⌘⇧P) and chat search (⌘P).
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/palette@0.2.0
Permissions in 0.2.0
Files
settings.test.ts7.6 KB
// Assign opens Keyboard Shortcuts through the convergence/settings
// service. Real activation with a canned settings client: Assign calls the
// service, and without a provider it only closes the palette.
import assert from "node:assert/strict";
import { test } from "node:test";
import * as z from "zod";
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));
function fakeApi(withSettings: boolean) {
let state: AppState = {};
const appListeners = new Set<(state: AppState) => void>();
const views: { render: (ctx: ViewContext<object>) => RenderResult; active: boolean }[] = [];
const settingsCalls: { method: string; params: unknown }[] = [];
const focused: string[] = [];
let dirty = true;
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 commands = [
{
id: "a.open",
title: "Open",
category: "A",
plugin: "a",
pluginTitle: "A",
official: true,
labels: [],
source: "default",
conflict: { key: "cmd+x", label: "⌘X", with: "b.other", withTitle: "Other" },
problem: null,
kernel: false,
enabled: true,
registered: true,
},
];
const kernelCall = async (method: string, params: unknown) => {
if (method === "commands") return { commands };
if (method === "palette.providers") return { providers: [] };
throw new Error(`Unexpected kernel method ${method} ${JSON.stringify(params)}`);
};
const hostCall = async (method: string, params: unknown) => {
if (method === "host/storage.get") return { value: null };
if (method === "host/storage.set") return {};
throw new Error(`Unexpected host method ${method} ${JSON.stringify(params)}`);
};
const settingsClient = {
showShortcuts: Object.assign(async () => null, {
// The Effect adapter calls through `result`, never the direct call.
result: async (params: unknown) => {
settingsCalls.push({ method: "showShortcuts", params });
return { ok: true as const, value: null };
},
}),
on: () => () => {},
};
const broker = testLayer();
const registered = new Map<string, () => unknown>();
// This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
const api = new Proxy(
{
...broker.api,
plugin: "palette",
id: "convergence/palette",
ui,
services: {
provide: async () => ({ emit: () => {}, remove: () => {} }),
get: (name: string) => {
if (name === "convergence/settings" && withSettings) return settingsClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/settings" && withSettings,
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/settings" && withSettings ? settingsClient : undefined);
return () => {};
},
},
host: { ...broker.api.host, call: hostCall, kernel: kernelCall },
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
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: () => () => {},
command: ({ id, run }: { id: string; run: () => unknown }) => {
registered.set(id, run);
return { remove: () => {} };
},
notify: () => {},
focus: (id: string) => {
focused.push(id);
},
},
{
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: "palette",
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("palette did not settle");
};
const nodeSchema = z.object({ id: z.string().optional(), children: z.array(z.unknown()).optional() }).passthrough();
function found(tree: unknown): Record<string, unknown>[] {
if (Array.isArray(tree)) return tree.flatMap(found);
const parsed = nodeSchema.safeParse(tree);
if (!parsed.success) return [];
return [
parsed.data,
...(parsed.data.children ?? []).flatMap(found),
...(Array.isArray(parsed.data.items) ? parsed.data.items.flatMap(found) : []),
];
}
const invoke = (id: string, event = "onClick", payload: unknown = {}) => {
const node = trees.flatMap((view) => found(view)).find((node) => node.id === id);
assert.ok(node, `Missing ${id}`);
const handler = z.custom<(payload: unknown) => unknown>((value) => typeof value === "function").parse(node[event]);
handler(payload);
};
return {
api,
registered,
settingsCalls,
settle,
invoke,
trees: () => stable(trees),
state: () => state,
};
}
test("Assign on a conflicting key calls the settings service", async () => {
const fake = fakeApi(true);
const dispose = activate(fake.api);
try {
await fake.settle();
fake.registered.get("palette.commands")?.();
await fake.settle();
assert.ok(fake.trees().includes("palette-assign-a.open"));
fake.invoke("palette-assign-a.open");
await fake.settle();
assert.deepEqual(fake.settingsCalls, [{ method: "showShortcuts", params: { query: "a.open" } }]);
assert.equal(fake.state().shared?.showShortcuts, undefined);
} finally {
await dispose();
}
});
test("without the settings plugin Assign only closes the palette", async () => {
const fake = fakeApi(false);
const dispose = activate(fake.api);
try {
await fake.settle();
fake.registered.get("palette.commands")?.();
await fake.settle();
assert.ok(fake.trees().includes("palette-assign-a.open"));
fake.invoke("palette-assign-a.open");
await fake.settle();
assert.deepEqual(fake.settingsCalls, []);
assert.equal(fake.state().shared?.showShortcuts, undefined);
assert.ok(!fake.trees().includes("palette-assign-a.open"));
} finally {
await dispose();
}
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 11.2 KB | 3 permissions | Listed |
No comments yet.