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
controller.ts7.5 KB
// Palette effects load catalogs and run selections in their owning plugins.
// Search tickets preserve the baseline's rule that an older response cannot replace a newer one.
import * as Effect from "effect/Effect";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as z from "zod";
import type { KeyConflict, PaletteProviderInfo } from "convergence";
import { App, Host, Kernel, Notify, Services, Views } from "convergence/effect";
import type { HostError, KernelCallFailed } from "convergence/effect";
import type { Settings } from "../settings/contract.ts";
import type { Mode } from "./model.ts";
import { commandLabel, modeOf, rankChats, rankCommands, remember } from "./model.ts";
import { PaletteState } from "./state.ts";
export interface Row {
kind: "command" | "item" | "chat";
id: string;
title: string;
positions: readonly number[];
key: string | null;
detail: string;
conflict?: KeyConflict | null;
run: Effect.Effect<unknown, HostError | KernelCallFailed>;
}
export const controller = Effect.fn("Palette.controller")(function* () {
const state = yield* PaletteState;
const host = yield* Host;
const kernel = yield* Kernel;
const app = yield* App;
const notify = yield* Notify;
const views = yield* Views;
const services = yield* Services;
const scope = yield* Scope.Scope;
const update = () => state.view?.update();
const close = () => {
if (state.open) {
state.open = null;
update();
}
};
const show = Effect.fn("Palette.show")(function* (kind: "commands" | "chats") {
state.open = kind;
state.query = "";
state.selected = 0;
state.found = { key: "", items: [] };
update();
yield* views.focus("palette-input");
});
const openCommands = Effect.fn("Palette.openCommands")(function* () {
if (state.open === "commands") return close();
const [commands, providers] = yield* Effect.all(
[kernel.call("commands", {}), kernel.call("palette.providers", {})],
{ concurrency: "unbounded" },
);
state.commands = commands.commands;
state.providers = providers.providers;
yield* show("commands");
});
const openChats = Effect.fn("Palette.openChats")(function* () {
if (state.open === "chats") return close();
const [chats, workspaces, commands, providers] = yield* Effect.all(
[
host.call("host/chats.list", {}),
host.call("host/workspaces.list", {}),
kernel.call("commands", {}),
kernel.call("palette.providers", {}),
],
{ concurrency: "unbounded" },
);
state.chats = chats.chats;
state.workspaces = Object.fromEntries(workspaces.workspaces.map((workspace) => [workspace.id, workspace]));
state.commands = commands.commands;
state.providers = providers.providers;
yield* show("chats");
});
const runCommand = Effect.fn("Palette.runCommand")(function* (id: string) {
state.recent = remember(state.recent, id);
// The baseline launches persistence before running the command, without waiting for it.
yield* Effect.forkIn(host.call("host/storage.set", { key: "recentCommands", value: state.recent }), scope, {
startImmediately: true,
});
const { ran } = yield* kernel.call("run_command", { id });
if (!ran) yield* notify.warning(`${id} did not run: its plugin is not running.`);
});
const rows = (): Row[] => {
if (!state.open) return [];
const mode: Mode<PaletteProviderInfo> =
state.open === "commands" ? { mode: "commands", query: state.query } : modeOf(state.query, state.providers);
if (mode.mode === "commands")
return rankCommands(state.commands, mode.query, state.recent).map(({ command, positions }) => ({
kind: "command",
id: command.id,
title: commandLabel(command),
positions,
key: command.labels?.[0] ?? null,
conflict: command.conflict ?? null,
detail: command.pluginTitle ?? command.plugin,
run: runCommand(command.id),
}));
if (mode.mode === "provider") {
const provider = mode.provider;
const found = state.found.key === searchKey(provider, mode.query) ? state.found.items : [];
return found.map((item) => ({
kind: "item",
id: `${provider.plugin}/${item.id}`,
title: String(item.title ?? item.id),
positions: [],
key: null,
detail: item.detail ?? provider.title ?? provider.plugin,
run: kernel.call("palette.run", { plugin: provider.plugin, id: provider.id, item }),
}));
}
return rankChats(state.chats, state.workspaces, mode.query).map(({ chat, positions }) => ({
kind: "chat",
id: chat.id,
title: chat.title || "New Chat",
positions,
key: null,
detail: state.workspaces[chat.workspaceId]?.name ?? "",
run: app.select(chat.id, chat.workspaceId),
}));
};
const search = Effect.fn("Palette.search")(function* (provider: PaletteProviderInfo, query: string) {
const key = searchKey(provider, query);
const ticket = ++state.searching;
yield* kernel.call("palette.search", { plugin: provider.plugin, id: provider.id, query }).pipe(
Effect.matchEffect({
onSuccess: (answer) =>
Effect.sync(() => {
if (ticket !== state.searching) return;
state.found = { key, items: answer.items };
update();
}),
onFailure: Effect.fn("Palette.searchFailed")(function* (error) {
if (ticket !== state.searching) return;
state.found = { key, items: [] };
yield* notify.error(`${provider.title ?? provider.plugin}: ${error.message}`);
update();
}),
}),
);
});
const pick = Effect.fn("Palette.pick")(function* (list: readonly Row[], selected: number) {
const chosen = list[selected];
close();
if (chosen)
yield* chosen.run.pipe(
Effect.catchTag(["HostCallFailed", "KernelCallFailed", "PermissionNotGranted", "NeedsReview"], (error) =>
notify.error(`${chosen.title}: ${error.message}`),
),
);
});
const assign = Effect.fn("Palette.assign")(function* (id: string) {
close();
const client = state.settings;
// Without the settings plugin there is no Keyboard Shortcuts page; the
// shared request the baseline wrote would have had no reader either.
if (!client) return;
yield* client.showShortcuts({ query: id }).pipe(
// A call that loses its provider on the way behaves like no reader.
Effect.ignore,
);
});
const loadRecent = Effect.fn("Palette.loadRecent")(function* () {
const { value } = yield* host.call("host/storage.get", { key: "recentCommands" });
// Invalid saved entries were ignored by the JavaScript palette as well.
const saved = z.array(z.unknown()).safeParse(value);
if (saved.success) state.recent = saved.data.filter((id): id is string => typeof id === "string");
});
// Assign needs the settings plugin; the client is kept while it runs.
const followSettings = Effect.fn("Palette.followSettings")(function* () {
yield* services.when<typeof Settings>("convergence/settings").pipe(
Stream.runForEach((option) =>
Effect.sync(() => {
state.settings = option._tag === "Some" ? option.value : undefined;
}),
),
Effect.forkIn(scope, { startImmediately: true }),
);
});
yield* followSettings();
return { state, rows, close, openCommands, openChats, search, pick, assign, loadRecent };
});
const searchKey = (provider: PaletteProviderInfo, query: string) => `${provider.plugin}/${provider.id}/${query}`;
export type Controller = Effect.Success<ReturnType<typeof controller>>;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.