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

  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the command you pick, whichever plugin it belongs to
  • Read chats chats.readMediumReads your transcripts and chat lists.Search your chats by title and workspace, and open the one you pick
  • Show panels ui.slotsLowShows views in the listed parts of the window.Hold the palette, which opens over the windowSlots: title

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <311.2 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.