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

model.ts7.4 KB
// The palette's model: fuzzy matching, the order of what it lists, and
// where the selection sits. Pure functions over plain objects, so the
// tests run them in Node.

import type { Chat, CommandInfo, PaletteProviderInfo, Workspace } from "convergence";

export type Command = Pick<CommandInfo, "id" | "title" | "category"> &
  Partial<Pick<CommandInfo, "enabled" | "registered">>;
export type ChatEntry = Pick<Chat, "id" | "workspaceId"> & Partial<Pick<Chat, "title" | "updatedAt" | "archived">>;
export type Provider = Pick<PaletteProviderInfo, "plugin" | "id"> & Partial<Pick<PaletteProviderInfo, "prefix">>;
export type Mode<P> = { mode: "commands" | "chats"; query: string } | { mode: "provider"; provider: P; query: string };
export interface Match {
  score: number;
  positions: number[];
}

/// How many rows the palette draws at once.
export const VISIBLE = 12;
/// How many commands the palette remembers as recently run.
export const RECENT_LIMIT = 20;

/// A fuzzy match of `query` in `text`: every character of the query, in
/// order, case-insensitively. Returns a score (higher is better) and the
/// matched positions, or null when it does not match. Matches at the start
/// of a word and runs of consecutive characters score more; an exact prefix
/// scores most.
export function fuzzy(query: unknown, text: unknown): Match | null {
  const q = String(query ?? "")
    .trim()
    .toLowerCase();
  const t = String(text ?? "");
  if (!q) return { score: 0, positions: [] };
  // Two readings: the first occurrence of each character, and word starts
  // where there are some ("nc" in "New Chat"). The better one counts.
  const candidates = [matchWith(q, t, false), matchWith(q, t, true)].filter((candidate) => candidate !== null);
  const best = candidates.sort((a, b) => b.score - a.score)[0];
  if (!best) return null;
  // Letters strewn through the text, none at a word start and none next to
  // another, are not what anyone typed for it.
  if (best.score < q.replace(/ /g, "").length * 3) return null;
  if (t.toLowerCase().startsWith(q)) best.score += 20;
  // Shorter texts win a tie: the match covers more of them.
  best.score -= t.length / 100;
  return best;
}

function matchWith(q: string, t: string, preferStarts: boolean): Match | null {
  const lower = t.toLowerCase();
  const positions = [];
  let score = 0;
  let from = 0;
  let last = -2;
  for (const char of q) {
    if (char === " ") continue;
    let at = lower.indexOf(char, from);
    if (preferStarts && at !== last + 1) {
      const start = wordStartAt(lower, char, from);
      if (start >= 0) at = start;
    }
    if (at < 0) return null;
    const boundary =
      at === 0 ||
      /[\s._\-/:>]/.test(t.charAt(at - 1)) ||
      (t.charAt(at) >= "A" && t.charAt(at) <= "Z" && t.charAt(at - 1) >= "a" && t.charAt(at - 1) <= "z");
    score += 1 + (boundary ? 8 : 0) + (at === last + 1 ? 5 : 0);
    positions.push(at);
    last = at;
    from = at + 1;
  }
  return { score, positions };
}

function wordStartAt(lower: string, char: string, from: number) {
  for (let i = from; i < lower.length; i++) {
    if (lower[i] !== char) continue;
    if (i === 0 || /[\s._\-/:>]/.test(lower.charAt(i - 1))) return i;
  }
  return -1;
}

/// The text a command is searched and shown by: `Category: Title`.
export function commandLabel(command: Pick<CommandInfo, "title" | "category">) {
  return command.category ? `${command.category}: ${command.title}` : command.title;
}

/// The commands the palette lists for `query`: those whose `when` holds and
/// whose plugin runs, fuzzy over the title and the category; with no
/// query, recently run first, then by label.
export function rankCommands<C extends Command>(
  commands: readonly C[],
  query: unknown,
  recent: readonly string[] = [],
) {
  const shown = commands.filter((command) => command.enabled !== false && command.registered !== false);
  const recency = (command: C) => {
    const at = recent.indexOf(command.id);
    return at < 0 ? Infinity : at;
  };
  if (!String(query ?? "").trim()) {
    return shown
      .map((command) => ({ command, positions: [] }))
      .sort(
        (a, b) =>
          recency(a.command) - recency(b.command) || commandLabel(a.command).localeCompare(commandLabel(b.command)),
      );
  }
  return shown
    .map((command) => {
      const label = commandLabel(command);
      const match = fuzzy(query, label) ?? fuzzy(query, command.title);
      return match
        ? { command, positions: match.positions, score: match.score + (recency(command) < Infinity ? 3 : 0) }
        : null;
    })
    .filter((candidate) => candidate !== null)
    .sort((a, b) => b.score - a.score || recency(a.command) - recency(b.command));
}

/// The chats the palette lists for `query`: by title and workspace name,
/// recently active first.
export function rankChats<C extends ChatEntry>(
  chats: readonly C[],
  workspaces: Readonly<Record<string, Pick<Workspace, "name">>>,
  query: unknown,
) {
  const open = chats.filter((chat) => !chat.archived);
  const name = (chat: C) => workspaces[chat.workspaceId]?.name ?? "";
  const newest = (a: C, b: C) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""));
  if (!String(query ?? "").trim())
    return open
      .slice()
      .sort(newest)
      .map((chat) => ({ chat, positions: [] }));
  return open
    .map((chat) => {
      const title = fuzzy(query, chat.title ?? "");
      const workspace = fuzzy(query, name(chat));
      const both = fuzzy(query, `${name(chat)} ${chat.title ?? ""}`);
      const best = [
        title && { ...title, positions: title.positions },
        workspace && { score: workspace.score - 2, positions: [] },
        both && { score: both.score - 4, positions: [] },
      ]
        .filter((candidate) => candidate !== null)
        .sort((a, b) => b.score - a.score)[0];
      return best ? { chat, positions: best.positions, score: best.score } : null;
    })
    .filter((candidate) => candidate !== null)
    .sort((a, b) => b.score - a.score || newest(a.chat, b.chat));
}

/// `recent` with `id` first, at most RECENT_LIMIT long.
export function remember(recent: readonly string[], id: string) {
  return [id, ...recent.filter((known) => known !== id)].slice(0, RECENT_LIMIT);
}

/// The mode a query asks for in the chat palette (⌘P): `>` switches to
/// commands, a provider's prefix to that provider, anything else searches
/// chats. Returns `{ mode, provider?, query }` with the prefix taken off.
export function modeOf<P extends Provider>(query: unknown, providers: readonly P[] = []): Mode<P> {
  const text = String(query ?? "");
  if (text.startsWith(">")) return { mode: "commands", query: text.slice(1).trimStart() };
  const provider = providers
    .filter((p): p is P & { prefix: string } => !!p.prefix && text.startsWith(p.prefix))
    .sort((a, b) => b.prefix.length - a.prefix.length)[0];
  if (provider) return { mode: "provider", provider, query: text.slice(provider.prefix.length).trimStart() };
  return { mode: "chats", query: text };
}

/// Moves the selection by `delta` rows, wrapping around.
export function move(selected: number, count: number, delta: number) {
  if (!count) return 0;
  return (((selected + delta) % count) + count) % count;
}

/// The rows to draw with the selection in sight.
export function window(count: number, selected: number, max = VISIBLE) {
  if (count <= max) return { from: 0, to: count };
  const from = Math.min(Math.max(0, selected - Math.floor(max / 2)), count - max);
  return { from, to: from + max };
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.