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

view.ts4.8 KB
// Render the palette overlay without starting work; handlers run scoped effects through their view context.
// Typed factories avoid HTM whitespace changes when Prettier formats a template.
import { h } from "convergence";
import type { DivProps } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Controller, Row } from "./controller.ts";
import { VISIBLE, modeOf, move, window } from "./model.ts";

const column = { display: "flex", flexDirection: "column" } satisfies DivProps;
const row = { display: "flex", flexDirection: "row", alignItems: "center" } satisfies DivProps;
const fill = { flexGrow: 1, flexShrink: 1, flexBasis: "0%", minWidth: 0 } satisfies DivProps;
const clickable = { group: "hover", cursor: "pointer" } satisfies DivProps;

function titleOf(entry: Row, chosen: boolean) {
  const marked = new Set(entry.positions);
  const parts = [];
  let run = "";
  let bold = false;
  [...entry.title].forEach((char, index) => {
    const hit = marked.has(index);
    if (hit !== bold && run) {
      parts.push({ text: run, bold });
      run = "";
    }
    bold = hit;
    run += char;
  });
  if (run) parts.push({ text: run, bold });
  return h(
    "div",
    { ...row, ...fill, gap: 0, whiteSpace: "nowrap", overflow: "hidden" },
    parts.map((part, index) =>
      h(
        "text",
        {
          key: index,
          style: "body",
          size: 13,
          weight: part.bold ? 650 : 400,
          tone: chosen || part.bold ? "foreground" : "muted",
        },
        part.text,
      ),
    ),
  );
}

export function render(palette: Controller, ctx: EffectViewContext<object>) {
  const { state } = palette;
  const list = palette.rows();
  const selection = () => Math.min(state.selected, Math.max(0, list.length - 1));
  const selected = selection();
  const typed = (value: string) => {
    state.query = value;
    state.selected = 0;
    if (state.open === "chats") {
      const mode = modeOf(value, state.providers);
      if (mode.mode === "provider") ctx.run(palette.search(mode.provider, mode.query));
    }
    ctx.update();
  };
  const step = (delta: number) => {
    state.selected = move(selection(), list.length, delta);
    ctx.update();
  };
  const pick = (index = selection()) => ctx.run(palette.pick(list, index));
  function rowOf(entry: Row, index: number) {
    const chosen = index === selected;
    return h(
      "div",
      {
        key: entry.id,
        ...row,
        ...clickable,
        gap: 10,
        paddingX: 10,
        paddingY: 6,
        height: 34,
        borderRadius: "sm",
        background: chosen ? "accent" : undefined,
        hover: chosen ? undefined : { background: "hover" },
        onClick: () => {
          state.selected = index;
          pick(index);
        },
      },
      titleOf(entry, chosen),
      entry.detail ? h("text", { style: "body", size: 11, tone: "faint", truncate: true }, entry.detail) : null,
      entry.conflict && !entry.key
        ? h(
            "div",
            { ...row, gap: 6, flexShrink: 0, stopPropagation: true },
            h("badge", { variant: "warning" }, "Conflict"),
            h("button", {
              id: `palette-assign-${entry.id}`,
              label: "Assign…",
              variant: "ghost",
              small: true,
              tooltip: `${entry.conflict.label} is used by ${entry.conflict.withTitle}`,
              onClick: () => ctx.run(palette.assign(entry.id)),
            }),
          )
        : null,
      entry.key ? h("text", { keys: true }, entry.key) : null,
    );
  }
  const { from, to } = window(list.length, selected, VISIBLE);
  const placeholder =
    state.open === "commands" ? "Type a command" : "Search chats by title or workspace; type > for commands";
  const empty = state.open === "commands" ? "No command matches." : "Nothing matches.";
  const keys = {
    up: () => step(-1),
    down: () => step(1),
    pageup: () => step(-VISIBLE),
    pagedown: () => step(VISIBLE),
    escape: palette.close,
  };
  return h(
    "overlay",
    { id: "palette", open: !!state.open, width: 620, top: 0.12, keys, onDismiss: palette.close },
    h(
      "div",
      // No divider: the input and the rows share the panel, the rows'
      // selection fill is enough to tell them apart.
      { ...column, gap: 2, padding: 2 },
      h("input", {
        id: "palette-input",
        value: state.query,
        placeholder,
        ghost: true,
        onChange: (event) => typed(event.value),
        onSubmit: () => pick(),
      }),
      list.length
        ? h(
            "div",
            { ...column, gap: 1 },
            list.slice(from, to).map((entry, index) => rowOf(entry, from + index)),
          )
        : h("div", { ...row, padding: 10 }, h("text", { style: "body", size: 12, tone: "muted" }, empty)),
    ),
  );
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.