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

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3105.1 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.