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/agents.ts6.1 KB
// The Agents section: every agent the host serves, with its account state,
// plan usage and one-click updates. Registry agents collapse behind one row.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { html as htm } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { AgentEntry, SettingsPage } from "../controller.ts";
import { TITLE_LINE, besideTitle, card, listRow, section, column, fill, row } from "./layout.ts";
// How much of the account's plan is used, once it is worth saying.
function usageRow(a: AgentEntry) {
const windows = a.usageLimits?.windows ?? [];
if (!a.enabled || !windows.length) return null;
const shown = windows.filter((w) => w.usedPercent >= 1 || w.blocked);
if (!shown.length) return null;
return htm`
<div ...${row} gap="medium" padding="small" flexWrap="wrap">
${shown.map((w) => {
const tone = w.blocked ? "danger" : w.usedPercent >= 90 ? "warning" : "muted";
const resets = w.resetsAt
? ` · resets ${new Date(w.resetsAt).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`
: "";
return htm`<text style="body" size="xs" tone=${tone}>${`${w.label} ${Math.round(w.usedPercent)}%${resets}`}</text>`;
})}
</div>`;
}
// The version, and a one-click upgrade only when the installer that owns
// the binary could be proven. Anything else stays manual.
function maintenanceNote(a: AgentEntry) {
const m = a.maintenance;
if (!a.enabled || !m) return null;
const behind = m.latestVersion && m.latestVersion !== m.installedVersion;
if (!behind) return null;
const how = m.canUpdate ? "" : " Update it the way you installed it.";
return htm`<text style="body" size="xs" tone="warning">${`${m.latestVersion} is available.${how}`}</text>`;
}
function maintenanceAction(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], a: AgentEntry) {
const m = a.maintenance;
if (!a.enabled || !m?.canUpdate) return null;
return htm`<button id=${`update-${a.id}`} label=${`Update with ${m.manager}`} small variant="outline" onClick=${() => ctx.run(actions.updateAgent(a))} />`;
}
function agentRow(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], a: AgentEntry) {
const status = a.status?.state;
const badge =
status === "ready"
? htm`<badge variant="success">Ready</badge>`
: status === "auth_required"
? htm`<badge variant="warning">Sign in needed</badge>`
: a.enabled
? htm`<badge variant="danger">Unavailable</badge>`
: null;
const statusMessage = a.status !== undefined && "message" in a.status ? a.status.message : null;
const detail = status !== "ready" && a.enabled && statusMessage ? statusMessage : a.description;
const methods = a.authMethods ?? [];
const actions$ = [
...(a.enabled && status !== "ready" && methods.length
? methods.map(
(m) =>
htm`<button id=${`auth-${a.id}-${m.id}`} label=${m.name} small variant="primary" onClick=${() => ctx.run(actions.signIn(a, m))} />`,
)
: []),
maintenanceAction(ctx, actions, a),
].filter(Boolean);
// One left edge under the title, the switch centred on the title line.
return listRow(
besideTitle(
htm`<switch id=${`agent-${a.id}`} label="" checked=${a.enabled} onChange=${(e: { value: boolean }) => ctx.run(actions.setAgentEnabled(a, e.value))} />`,
36,
),
htm`
<div ...${column} ...${fill} gap=${4}>
<div ...${row} gap="small" height=${TITLE_LINE}>
<icon name="bot" small tone="muted" svg=${a.icon} />
<text style="body" size="sm" bold>${a.name}</text>
${a.version ? htm`<text style="body" size="xs" tone="faint">${a.version}</text>` : null}
<spacer />
${badge}
</div>
${detail ? htm`<text style="body" size="xs" tone="muted">${detail}</text>` : null}
${usageRow(a)}
${maintenanceNote(a)}
${actions$.length ? htm`<div ...${row} gap="small" flexWrap="wrap">${actions$}</div>` : null}
</div>`,
);
}
export function renderAgents(ctx: EffectViewContext<object>, actions: SettingsPage["Service"]) {
const state = actions.state;
// Direct providers first; the long registry list is collapsed by
// default. The test is the ACP prefix, not any colon: a second account
// of a direct provider is an id like `codex:personal`.
const direct = state.agents.filter((a) => !a.id.startsWith("acp:"));
const registry = state.agents.filter((a) => a.id.startsWith("acp:"));
const enabledRegistry = registry.filter((a) => a.enabled);
const others = registry.filter((a) => !a.enabled);
return htm`
<div ...${column} gap="large" padding="small">
${section("Agents", "Enabled agents start with the app and appear in the agent pickers.", card(direct.map((a) => agentRow(ctx, actions, a))))}
${
registry.length
? section(
"More agents",
`${registry.length} agents from the Agent Client Protocol registry.${enabledRegistry.length ? "" : " None enabled."}`,
htm`
<div ...${column} gap="small">
${enabledRegistry.length ? card(enabledRegistry.map((a) => agentRow(ctx, actions, a))) : null}
<div ...${row}>
<button id="registry-agents" label=${state.showRegistry ? `Hide the other ${others.length}` : `Show the other ${others.length}`}
icon=${state.showRegistry ? "chevron-down" : "chevron-right"} small variant="ghost" onClick=${() => {
state.showRegistry = !state.showRegistry;
ctx.update();
}} />
</div>
${
// At the same left edge as the enabled ones: the kernel's
// collapsible indented its body.
state.showRegistry ? card(others.map((a) => agentRow(ctx, actions, a))) : null
}
</div>`,
)
: 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.