Official
chat
Built-in chat 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/chat@0.2.0
Permissions in 0.2.0
Files
models.ts5.9 KB
// Request failures recover at the same notification/logging boundaries as the JavaScript baseline.
// Model picker state and scoped storage effects retain provider-qualified favorites.
import * as Semaphore from "effect/Semaphore";
import * as Effect from "effect/Effect";
import * as z from "zod";
import type { Chat, ConfigOption, Element } from "convergence";
import type { ConfigChoice } from "convergence/protocol";
import type { ChatView, Controller } from "./controller.ts";
import { parse } from "convergence/effect";
import { errorMessage } from "./data.ts";
export type Section = { name: string; favorite?: boolean; choices: ConfigChoice[] };
export type ModelPicker = ReturnType<typeof createModelPicker>;
import { html } from "convergence";
// Favorites use the agent's complete model id, including its provider.
export function sections(choices: readonly ConfigChoice[], favorites: readonly string[], query: string): Section[] {
const terms = query.trim().toLowerCase().split(/\s+/);
const matches = choices.filter((choice) => {
const text = `${choice.name} ${choice.value} ${choice.group ?? ""}`.toLowerCase();
return terms.every((term) => text.includes(term));
});
const groups = new Map<string, ConfigChoice[]>();
const starred = matches.filter((choice) => favorites.includes(choice.value));
for (const choice of matches) {
if (favorites.includes(choice.value)) continue;
const group = choice.group ?? "";
if (!groups.has(group)) groups.set(group, []);
groups.get(group)?.push(choice);
}
return [
...(starred.length ? [{ name: "Favorites", favorite: true, choices: starred }] : []),
...[...groups].map(([name, choices]) => ({ name, choices })),
];
}
export function createModelPicker(controller: Controller) {
let favorites: Record<string, string[]> = {};
let loaded = false;
const writes = Semaphore.makeUnsafe(1);
const load = Effect.fn("Chat.loadModelFavorites")(function* () {
yield* Effect.gen(function* () {
const stored = (yield* controller.host.call("host/storage.get", { key: "modelFavorites" })).value;
favorites = yield* parse("model favorites", z.record(z.string(), z.array(z.string())), stored ?? {});
loaded = true;
controller.view.update();
}).pipe(Effect.catch((error) => controller.notify.error(`Could not load model favorites: ${errorMessage(error)}`)));
});
// Writes are serialized so an older snapshot never overwrites a newer star action.
const star = Effect.fn("Chat.starModel")(function* (agentId: string, value: string) {
const current = favorites[agentId] ?? [];
favorites = {
...favorites,
[agentId]: current.includes(value) ? current.filter((id) => id !== value) : [...current, value],
};
const snapshot = favorites;
controller.view.update();
yield* writes.withPermit(
controller.host
.call("host/storage.set", { key: "modelFavorites", value: snapshot })
.pipe(
Effect.catch((error) => controller.notify.error(`Could not save model favorites: ${errorMessage(error)}`)),
),
);
});
// One menu, as the Electron app had it: a row per setting (model, mode,
// effort, speed, …) with what it is set to, each opening its choices
// beside it. The model's choices are searchable, grouped by provider,
// with the starred ones first. The trigger names the model and, fainter,
// what the other settings are set to. The kernel's combobox draws all of
// it; this only says what is in it.
const render = (v: ChatView, chat: Chat, model: ConfigOption | undefined, extras: ConfigOption[] = []): Element => {
const chosen = (o: ConfigOption) => o.choices?.find((choice) => choice.value === o.value)?.name;
const starred = favorites[chat.agentId] ?? [];
const modelChoices = (o: ConfigOption) =>
sections(o.choices ?? [], starred, "").flatMap((section) =>
section.choices.map((choice) => ({
value: choice.value,
label: choice.name,
svg: choice.icon,
group: section.name || undefined,
starred: loaded ? starred.includes(choice.value) : undefined,
})),
);
const row = (o: ConfigOption) => {
const isModel = o === model;
const count = o.choices?.length ?? 0;
return {
value: o.id,
label: isModel ? "Model" : o.name,
detail: chosen(o) ?? "Default",
submenu: {
value: String(o.value ?? ""),
width: isModel ? 288 : 256,
search: isModel && count > 8 ? "Search models…" : undefined,
empty: isModel ? "No models found" : undefined,
options: isModel
? modelChoices(o)
: // Names only, as for models: a level's description took room.
(o.choices ?? []).map((choice) => ({ value: choice.value, label: choice.name, svg: choice.icon })),
},
};
};
const rows = [...(model ? [model] : []), ...extras];
const lead = model ?? extras[0];
// The trigger names the model and how hard it thinks: the model's
// reasoning setting, whatever the agent calls it (effort, reasoning
// effort, reasoning). Speed and the rest stay in the menu.
const thinking = extras.find((o) => o !== lead && (o.category === "reasoning" || /effort|reasoning/i.test(o.name)));
const summary = thinking ? chosen(thinking) : undefined;
return html`
<combobox id=${`models-${chat.id}-${chat.agentId}`} look="toolbar" tooltip="Model and reply settings"
label=${lead ? (chosen(lead) ?? lead.name) : "Settings"} detail=${summary || undefined}
options=${rows.map(row)}
onChange=${(e: { value?: string; option?: string }) => {
if (!e.option || e.value === undefined) return;
v.ctx.run(controller.setOption(chat.id, e.option, e.value));
v.ctx.run(controller.views.focus(`prompt-${chat.id}`));
}}
onStar=${(e: { value: string }) => v.ctx.run(star(chat.agentId, e.value))} />`;
};
return { load: load(), render };
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.