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
controller.ts17.7 KB
// The settings page's state and its effects: loading the page, changing a
// setting, enabling a plugin or agent, recording a key. Views only mutate
// this state and redraw; every host or kernel call goes through here.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { App, Host, Kernel, Notify, Plugin } from "convergence/effect";
import type { KernelCallFailed, HostError, PermissionNotGranted } from "convergence/effect";
import type {
AgentInfo,
CommandInfo,
PluginSettingProperty,
PluginSummary,
PresetColor,
SettingValue,
Settings,
ToolsOverview,
UpdateStatus,
ViewHandle,
Workspace,
} from "convergence";
import { chordOf } from "./model.ts";
import type { SectionData } from "./model.ts";
export type AgentEntry = AgentInfo & { readonly enabled: boolean };
export type PageError = HostError | KernelCallFailed | PermissionNotGranted;
// Every tag a host or kernel call fails with on this page.
const ERROR_TAGS = ["PermissionNotGranted", "NeedsReview", "HostCallFailed", "KernelCallFailed"] as const;
/// One key being recorded, with the strokes so far.
interface Recording {
command: string;
strokes: string[];
waiting: boolean;
}
export interface PageState {
section: string;
updates: UpdateStatus | null;
settings: Settings | null;
agents: readonly AgentEntry[];
plugins: readonly PluginSummary[] | null;
keys: { readonly commands: readonly CommandInfo[]; readonly errors: readonly string[] } | null;
workspaces: readonly Workspace[];
/// The preset colours a workspace's folder can take.
colors: readonly PresetColor[];
tools: ToolsOverview | null;
importWorkspace: string | null;
importing: boolean;
loaded: boolean;
keysQuery: string;
recording: Recording | null;
sections: Record<string, SectionData>;
drafts: Record<string, string>;
draftErrors: Record<string, string>;
sectionViews: Record<string, number | null>;
showRegistry: boolean;
openPermissions: Record<string, boolean>;
}
export function initialState(): PageState {
return {
section: "general",
updates: null,
settings: null,
agents: [],
plugins: null,
keys: null,
workspaces: [],
colors: [],
tools: null,
importWorkspace: null,
importing: false,
loaded: false,
keysQuery: "",
recording: null,
sections: {},
drafts: {},
draftErrors: {},
sectionViews: {},
showRegistry: false,
openPermissions: {},
};
}
const makePage = Effect.fn("SettingsPage.make")(function* () {
const host = yield* Host;
const kernel = yield* Kernel;
const app = yield* App;
const notify = yield* Notify;
const plugin = yield* Plugin;
const state = initialState();
const views: ViewHandle[] = [];
const redraw = () => {
for (const view of views) view.update();
};
// `run(label, action)` from the original: the label and the error's
// message go to a notice, whatever went wrong.
const attempt = <A, R>(label: string, effect: Effect.Effect<A, PageError, R>) =>
effect.pipe(
Effect.catchTag(ERROR_TAGS, (error) => notify.error(`${label}: ${error.message}`)),
Effect.asVoid,
);
// The plugin whose section is open, if one is.
const sectionPlugin = () => (state.section.startsWith("plugin:") ? state.section.slice("plugin:".length) : null);
const loadKeys = Effect.fn("SettingsPage.loadKeys")(function* () {
state.keys = yield* kernel.call("commands", {});
redraw();
});
const loadSection = Effect.fn("SettingsPage.loadSection")(function* (name: string) {
state.sections[name] = yield* host
.call("host/plugins.settings", { name })
.pipe(
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
Effect.succeed<SectionData>({ name, error: error.message, properties: [], notices: [] }),
),
);
const custom = yield* kernel
.call("settings_view", { plugin: name })
.pipe(Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.succeed({ view: null })));
state.sectionViews[name] = custom.view;
redraw();
});
const load = Effect.fn("SettingsPage.load")(function* () {
const [settings, agents, plugins, commands, workspaces, updates] = yield* Effect.all(
[
host.call("host/settings.get", {}),
host.call("host/agents.all", {}),
host.call("host/plugins.list", {}),
kernel.call("commands", {}),
host.call("host/workspaces.list", {}),
host.call("host/updates.status", {}),
],
{ concurrency: "unbounded" },
);
state.updates = updates;
state.settings = settings;
state.agents = agents.agents;
state.plugins = plugins.plugins;
state.keys = commands;
state.workspaces = workspaces.workspaces;
state.colors = workspaces.colors ?? [];
// Through `call`: a build without plugin tools answers "unknown host method".
state.tools = yield* host
.call("host/tools.list", {})
.pipe(Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], () => Effect.succeed(null)));
if (!state.workspaces.some((w) => w.id === state.importWorkspace)) {
const here = (yield* app.state).workspaceId;
state.importWorkspace = state.workspaces.find((w) => w.id === here)?.id ?? state.workspaces[0]?.id ?? null;
}
state.loaded = true;
redraw();
const open = sectionPlugin();
if (open) yield* loadSection(open);
});
const updateSettings = Effect.fn("SettingsPage.updateSettings")(function* (patch: Partial<Settings>) {
state.settings = yield* host.call("host/settings.update", patch);
redraw();
});
const changeAppearance = Effect.fn("SettingsPage.changeAppearance")(function* (value: Settings["appearance"]) {
yield* updateSettings({ appearance: value });
yield* kernel.call("theme", { mode: value === "system" ? undefined : value });
});
const importSessions = Effect.fn("SettingsPage.importSessions")(function* () {
const workspaceId = state.importWorkspace;
// The button is disabled without one; the guard is for the type only.
if (workspaceId === null) return;
state.importing = true;
redraw();
let total = 0;
for (const agent of state.agents.filter((agent) => agent.enabled)) {
yield* host.call("host/chats.import", { workspaceId, agentId: agent.id }).pipe(
Effect.tap((result) =>
Effect.sync(() => {
total += result.imported;
}),
),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
notify.error(`Could not import from ${agent.name}: ${error.message}`),
),
);
}
state.importing = false;
// The baseline notified without a level; keep the call identical.
yield* Effect.sync(() => plugin.api.notify(total ? `Imported ${total} sessions.` : "No new sessions found."));
redraw();
});
const signIn = Effect.fn("SettingsPage.signIn")(function* (agent: AgentEntry, method: { readonly id: string }) {
yield* host.call("host/agents.authenticate", { agentId: agent.id, method: method.id }).pipe(
// The baseline notified without a level; keep the call identical.
Effect.tap(() => Effect.sync(() => plugin.api.notify(`${agent.name}: signed in.`))),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
notify.error(`${agent.name}: ${error.message}`),
),
);
});
const updateAgent = Effect.fn("SettingsPage.updateAgent")(function* (agent: AgentEntry) {
yield* host.call("host/agents.update", { agentId: agent.id }).pipe(
// The baseline notified without a level; keep the call identical.
Effect.tap(() => Effect.sync(() => plugin.api.notify(`${agent.name}: updated.`))),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
notify.error(`${agent.name}: ${error.message}`),
),
);
});
const setAgentEnabled = Effect.fn("SettingsPage.setAgentEnabled")((agent: AgentEntry, enabled: boolean) =>
host.call("host/agents.set_enabled", { agentId: agent.id, enabled }),
);
// Only the native card grants anything: the page asks for it, and the
// user answers there.
const review = (name: string) =>
attempt(
`Could not review ${name}`,
Effect.gen(function* () {
const { requestId } = yield* host.call("host/plugins.request_enable", { name });
if (!requestId) yield* load();
}),
);
// On again works only for a plugin enabled before with nothing new to
// review; anything else goes to its card.
const setPluginEnabled = (name: string, enabled: boolean) =>
host.call("host/plugins.set_enabled", { name, enabled }).pipe(
Effect.catchTag("NeedsReview", () => host.call("host/plugins.request_enable", { name })),
Effect.catchTag(["HostCallFailed", "PermissionNotGranted"], (error) =>
notify.error(`Could not change ${name}: ${error.message}`),
),
Effect.asVoid,
);
const revoke = (name: string, entry: PluginSummary["permissions"][number]) =>
attempt(
`Could not revoke ${entry.permission}`,
host.call("host/plugins.revoke", { name, permission: entry.permission, scope: entry.scope }),
);
const reload = (name: string) =>
attempt(
`Could not reload ${name}`,
Effect.gen(function* () {
yield* host.call("host/plugins.reload", { name });
yield* load();
}),
);
const reportBug = () => attempt("Could not open the bug report form", host.call("host/market.open_bug_form", {}));
// A workspace's mark: a folder in `color`, or (`null`) its project
// icon. The workspace-changed event that follows reloads the page.
const setWorkspaceColor = (workspaceId: string, color: PresetColor | null) =>
attempt("Could not change the workspace's icon", host.call("host/workspaces.set_color", { workspaceId, color }));
const updateTools = Effect.fn("SettingsPage.updateTools")(function* (patch: Partial<Settings["tools"]>) {
const tools = state.settings?.tools ?? { mode: "code", disabled: [], disabledRules: [] };
yield* updateSettings({ tools: { ...tools, ...patch } });
state.tools = yield* host.call("host/tools.list", {});
redraw();
});
const route = (name: string) => app.route(name);
const installApp = () =>
attempt(
"The update failed",
Effect.gen(function* () {
const result = yield* host.call("host/updates.install_app", {});
if (result.restart) yield* kernel.call("quit", {});
}),
);
const applyPlugins = () =>
attempt(
"The plugin update failed",
Effect.gen(function* () {
const { report, chatId } = yield* host.call("host/updates.apply_plugins", {});
const changed = report.updated.length + report.added.length + report.removed.length;
if (!chatId)
// The baseline notified without a level; keep the call identical.
yield* Effect.sync(() =>
plugin.api.notify(
changed ? `Plugins updated (${changed} changed).` : "The plugins were already up to date.",
),
);
}),
);
const checkNow = () => attempt("Could not check for updates", host.call("host/updates.check", {}));
const refreshUpdates = Effect.fn("SettingsPage.refreshUpdates")(function* () {
state.updates = yield* host.call("host/updates.status", {});
redraw();
});
// --- Keyboard Shortcuts ---------------------------------------------------
const keysRun = (label: string, action: Effect.Effect<unknown, PageError>) =>
attempt(label, Effect.andThen(action, loadKeys));
const record = Effect.fn("SettingsPage.record")(function* (command: CommandInfo) {
state.recording = { command: command.id, strokes: [], waiting: true };
redraw();
const result = yield* kernel
.call("keys.record", {})
.pipe(
Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], (error) =>
Effect.succeed<{ readonly key: string | null; readonly error?: string }>({ key: null, error: error.message }),
),
);
const current = state.recording;
if (!current || current.command !== command.id) return;
current.waiting = false;
if (result.key) current.strokes.push(result.key);
else if ("error" in result && result.error) yield* notify.warning(`That key cannot be bound: ${result.error}`);
if (!current.strokes.length) state.recording = null;
redraw();
});
const recordMore = Effect.fn("SettingsPage.recordMore")(function* () {
const current = state.recording;
if (!current || current.strokes.length >= 2) return;
current.waiting = true;
redraw();
const { key } = yield* kernel
.call("keys.record", {})
.pipe(Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.succeed({ key: null })));
if (state.recording !== current) return;
current.waiting = false;
if (key) current.strokes.push(key);
redraw();
});
const cancelRecord = kernel.call("keys.record_cancel", {}).pipe(
Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.void),
Effect.asVoid,
);
// View-side: clears the recorder at once; the cancel goes out on its own.
const stopRecording = () => {
const waiting = state.recording?.waiting ?? false;
state.recording = null;
redraw();
return waiting;
};
const saveRecording = Effect.fn("SettingsPage.saveRecording")(function* () {
const current = state.recording;
state.recording = null;
if (!current?.strokes.length) {
redraw();
return;
}
yield* keysRun(
"Could not change the key",
kernel.call("keybindings.change", { command: current.command, change: "set", key: chordOf(current.strokes) }),
);
});
const openKeybindings = () => attempt("Could not open keybindings.json", kernel.call("keybindings.open", {}));
const resetKey = (command: CommandInfo) =>
keysRun("Could not reset the key", kernel.call("keybindings.change", { command: command.id, change: "reset" }));
const unbindKey = (command: CommandInfo) =>
keysRun("Could not remove the key", kernel.call("keybindings.change", { command: command.id, change: "unbind" }));
// --- a plugin's own section -----------------------------------------------
const saveSetting = Effect.fn("SettingsPage.saveSetting")(function* (
name: string,
property: PluginSettingProperty,
value: SettingValue,
) {
const k = `${name}/${property.key}`;
yield* host.call("host/plugins.set_setting", { name, key: property.key, value }).pipe(
Effect.tap((result) =>
Effect.andThen(
Effect.sync(() => {
delete state.drafts[k];
delete state.draftErrors[k];
}),
result.requestId
? // The baseline notified without a level; keep the call identical.
Effect.sync(() => plugin.api.notify(`${property.title}: allow the change on the card to use it.`))
: Effect.void,
),
),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
Effect.sync(() => {
state.draftErrors[k] = error.message;
}),
),
);
yield* loadSection(name);
});
// The masked secret field tells the page its save failed; the host
// never sees the value itself.
const secretFailed = (property: PluginSettingProperty, error: string) => notify.error(`${property.title}: ${error}`);
// Text and path settings pick what they show from a native dialog.
const pickPath = Effect.fn("SettingsPage.pickPath")(function* (property: PluginSettingProperty) {
const picked =
property.kind === "folder"
? (yield* Effect.promise(() => plugin.api.pickFolder())).path
: (yield* Effect.promise(() => plugin.api.pickImages())).paths[0];
return picked ?? null;
});
const setDraft = (key: string, value: string) => {
state.drafts[key] = value;
delete state.draftErrors[key];
redraw();
};
const openSection = Effect.fn("SettingsPage.openSection")(function* (id: string, plugin?: string) {
state.section = id;
redraw();
if (plugin) yield* loadSection(plugin);
else if (id === "keys") yield* loadKeys();
});
// The palette's Assign request for a command in conflict: Keyboard
// Shortcuts, searched for that command. The convergence/settings service
// reaches this; the route brings the page on screen.
const showShortcuts = Effect.fn("SettingsPage.showShortcuts")(function* (query: string) {
state.section = "keys";
state.keysQuery = query;
redraw();
yield* route("settings");
// The page is already on its way; a keys load that fails now is a
// notice, not a failed service call.
if (state.loaded)
yield* loadKeys().pipe(
Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], (error) =>
notify.error(`Could not load keyboard shortcuts: ${error.message}`),
),
);
return null;
});
return {
state,
views,
load,
loadKeys,
loadSection,
updateSettings,
changeAppearance,
importSessions,
signIn,
updateAgent,
setAgentEnabled,
review,
setPluginEnabled,
revoke,
reload,
reportBug,
updateTools,
setWorkspaceColor,
route,
installApp,
applyPlugins,
checkNow,
refreshUpdates,
keysRun,
record,
recordMore,
cancelRecord,
stopRecording,
saveRecording,
openKeybindings,
resetKey,
unbindKey,
saveSetting,
secretFailed,
pickPath,
setDraft,
openSection,
showShortcuts,
sectionPlugin,
};
});
export class SettingsPage extends Context.Service<SettingsPage, Effect.Success<ReturnType<typeof makePage>>>()(
"settings/SettingsPage",
) {
static readonly layer = Layer.effect(SettingsPage, makePage());
}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.