Official
settings
Built-in settings UI (JavaScript UI plugin).
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/settings@0.2.0
Permissions in 0.2.0
Files
views/pluginSection.ts10.2 KB
// A plugin's own settings section (MARKETPLACE.md §15.1): the properties
// its manifest declares, their controls, its custom view module and the
// commands it registers.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import * as Effect from "effect/Effect";
import { html as htm } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { CommandInfo, PluginSettingProperty, PluginSummary } from "convergence";
import type { SettingsPage } from "../controller.ts";
import { optionsOf, parseInput, textOf } from "../model.ts";
import { card, section, setting, column, row, fill } from "./layout.ts";
import { capsOf } from "./keys.ts";
const draftKey = (name: string, key: string) => `${name}/${key}`;
// Text settings keep what is typed until it is saved: at once for most,
// with the Save button for one a permission refers to (its change needs
// the user's card).
function textSetting(
ctx: EffectViewContext<object>,
actions: SettingsPage["Service"],
name: string,
property: PluginSettingProperty,
) {
const state = actions.state;
const k = draftKey(name, property.key);
const draft = state.drafts[k];
const shown = draft ?? textOf(property.value);
const changed = draft !== undefined && draft !== textOf(property.value);
const save = () => {
const parsed = parseInput(property, shown);
if ("error" in parsed) {
state.draftErrors[k] = parsed.error;
ctx.update();
return;
}
ctx.run(actions.saveSetting(name, property, parsed.value));
};
const pick =
property.type === "path"
? htm`<button id=${`pick-${k}`} label="Choose…" variant="outline" onClick=${() => {
ctx.run(
Effect.andThen(actions.pickPath(property), (picked: string | null) =>
picked
? Effect.sync(() => {
state.drafts[k] = picked;
ctx.update();
})
: Effect.void,
),
);
}} />`
: null;
return htm`
<div ...${column} gap="small" padding="small">
<div ...${row} gap="small" padding="small">
${property.type === "color" && /^#/.test(shown) ? htm`<div width=${16} height=${16} borderRadius="sm" background=${shown} flexShrink=${0} />` : null}
<div ...${column} ...${fill} padding="small">
<input id=${`setting-${k}`} value=${shown} placeholder=${property.placeholder ?? (property.default != null ? String(property.default) : "")}
onChange=${(e: { value: string }) => actions.setDraft(k, e.value)}
onSubmit=${save} />
</div>
${pick}
${changed ? htm`<button id=${`save-${k}`} label="Save" variant="primary" onClick=${save} />` : null}
</div>
</div>`;
}
function settingControl(
ctx: EffectViewContext<object>,
actions: SettingsPage["Service"],
name: string,
property: PluginSettingProperty,
) {
const k = draftKey(name, property.key);
switch (property.type) {
case "boolean":
return htm`<switch id=${`setting-${k}`} label="" checked=${!!property.value} onChange=${(e: { value: boolean }) => ctx.run(actions.saveSetting(name, property, e.value))} />`;
case "select":
return htm`<select id=${`setting-${k}`} value=${textOf(property.value)} small options=${optionsOf(property)}
onChange=${(e: { value: string }) => ctx.run(actions.saveSetting(name, property, e.value))} />`;
case "multiSelect": {
const values = Array.isArray(property.value) ? property.value : [];
const chosen = new Set(values.filter((v): v is string => typeof v === "string"));
return htm`
<div ...${column} gap="small" padding="small">
${optionsOf(property).map(
(option) => htm`
<checkbox id=${`setting-${k}-${option.value}`} label=${option.label} checked=${chosen.has(option.value)}
onChange=${(e: { value: boolean }) => {
const next = optionsOf(property)
.map((o) => o.value)
.filter((v) => (v === option.value ? e.value : chosen.has(v)));
ctx.run(actions.saveSetting(name, property, next));
}} />`,
)}
</div>`;
}
// The native field: what is typed goes straight to the system's
// credential store; this page never sees it.
case "secret":
return htm`
<div ...${column} gap="small" padding="small">
<secret_input id=${`secret-${k}`} plugin=${name} setting=${property.key} placeholder=${property.secretSet ? "•••••••• (enter a new value)" : "Not set"}
onSaved=${(e: { ok: true } | { ok: false; error: string }) => {
if (!e.ok) ctx.run(actions.secretFailed(property, e.error));
ctx.run(actions.loadSection(name));
}} />
${
property.secretSet
? htm`<button id=${`clear-${k}`} label="Clear" variant="ghost" small onClick=${() => ctx.run(actions.saveSetting(name, property, null))} />`
: null
}
</div>`;
default:
return textSetting(ctx, actions, name, property);
}
}
// The plugin on or off, at the top of its own page. A plugin never
// enabled, or one with permissions to review, is enabled from its review
// card, as in the plugin list.
function enabledRow(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], plugin: PluginSummary) {
const reviewFirst = !plugin.enabled && (plugin.state === "not_enabled" || plugin.pending.length > 0);
return setting({
title: "Enabled",
description: plugin.enabled
? "Turned off, the plugin stops: its views, commands, tools and agents go away until you turn it on."
: "The plugin does not run. Its settings apply once it is on.",
control: reviewFirst
? htm`<button id=${`review-page-${plugin.name}`} label="Review and enable…" small variant="primary" onClick=${() => ctx.run(actions.review(plugin.name))} />`
: htm`<switch id=${`plugin-page-${plugin.name}`} label="" checked=${plugin.enabled} onChange=${(e: { value: boolean }) => ctx.run(actions.setPluginEnabled(plugin.name, e.value))} />`,
});
}
export function renderPluginSection(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], name: string) {
const state = actions.state;
const data = state.sections[name];
if (!data) {
ctx.run(actions.loadSection(name));
return htm`<empty>Loading…</empty>`;
}
if ("error" in data) return htm`<code>${data.error}</code>`;
const plugin = state.plugins?.find((p) => p.name === name);
const commands: readonly CommandInfo[] = (state.keys?.commands ?? []).filter((command) => command.plugin === name);
const wide = (property: PluginSettingProperty) => !["boolean", "select"].includes(property.type);
// An object setting has no field: the plugin's own view edits it.
const rows = data.properties
.filter((property) => property.type !== "object")
.map((property) => {
const k = draftKey(name, property.key);
const error = state.draftErrors[k] ?? property.error;
const notes = [
property.description,
property.locked
? "A permission uses this setting: a new value needs your approval on a card before the plugin can use it."
: null,
]
.filter(Boolean)
.join(" ");
return htm`
<div key=${property.key} ...${column} gap="small" padding="small">
${setting({ title: property.title, description: notes, wide: wide(property), control: settingControl(ctx, actions, name, property) })}
${error ? htm`<div ...${row} padding="small" paddingX="large"><text style="body" size="xs" tone="danger">${error}</text></div>` : null}
</div>`;
});
const custom = state.sectionViews[name];
const granted = plugin?.permissions.filter((entry) => entry.granted).length ?? 0;
return htm`
<div ...${column} gap="large" padding="small">
${
data.notices.length
? htm`<panel>
${data.notices.map((notice) => htm`<text style="body" size="xs" tone="warning">${notice}</text>`)}
</panel>`
: null
}
${section(data.title, plugin?.description ?? null, card([plugin ? htm`<div ...${column} gap="small" padding="small">${enabledRow(ctx, actions, plugin)}</div>` : null, ...rows]))}
${custom ? htm`<div ...${column} padding="small" flexShrink=${0}><view view=${custom} fit /></div>` : null}
${
commands.length
? section(
"Commands",
"Change a key in Keyboard Shortcuts.",
card(
commands.map(
(command) => htm`
<div key=${command.id} ...${row} gap="medium" padding="small" paddingX="medium" paddingY="small">
<text style="body" size="sm">${command.title}</text>
<spacer />
${capsOf(command.labels[0] ?? null)}
<button id=${`keys-of-${command.id}`} icon="keyboard" variant="ghost" small tooltip="Change in Keyboard Shortcuts"
onClick=${() => {
state.section = "keys";
state.keysQuery = command.id;
ctx.update();
}} />
</div>`,
),
),
)
: null
}
${
plugin
? section(
"Permissions",
null,
card([
setting({
title: `${granted} of ${plugin.permissions.length} granted`,
description: plugin.pending.length
? `${plugin.pending.length} wait for your review.`
: "What the plugin may do beyond drawing its own views.",
control: htm`<button id=${`manage-${name}`} label="Manage" variant="outline" small onClick=${() => {
state.openPermissions = { ...state.openPermissions, [name]: true };
state.section = "plugins";
ctx.update();
}} />`,
}),
]),
)
: null
}
</div>`;
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 24.7 KB | 7 permissions | Listed |
No comments yet.