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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 11.2 KB | 3 permissions | Listed |
No comments yet.