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