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

requests.ts4.8 KB
// Request failures recover at the same notification/logging boundaries as the JavaScript baseline.
// Native approvals remain kernel-owned; chat questions and checkpoint actions use host services.
// Cached attachment ids prevent image bytes from travelling through streaming renders.
import * as Effect from "effect/Effect";
import type { Controller } from "./controller.ts";
import type { Question } from "./state.ts";
import { errorMessage, textChanged } from "./data.ts";
import { allTasks } from "./subagents.ts";

export const respondQuestion = Effect.fn("Chat.respondQuestion")(function* (
  c: Controller,
  chatId: string,
  req: Question,
  cancelled: boolean,
) {
  const transcript = c.transcript(chatId),
    answers = (transcript.answers[req.id] ??= {});
  if (req.submitting) return;
  req.submitting = true;
  c.view.update();
  const values = cancelled
    ? {}
    : Object.fromEntries(Object.entries(answers).filter(([, value]) => value !== "" && value != null));
  yield* c.host.call("host/chats.respond_question", { chatId, questionId: req.id, answer: { values, cancelled } }).pipe(
    Effect.tap(() =>
      Effect.sync(() => {
        transcript.questions = transcript.questions.filter((question) => question.id !== req.id);
        delete transcript.answers[req.id];
      }),
    ),
    Effect.catch((error) => c.notify.error(`Answer failed: ${errorMessage(error)}`)),
    Effect.ensuring(
      Effect.sync(() => {
        req.submitting = false;
        c.view.update();
      }),
    ),
  );
});
export const openQuestionLink = Effect.fn("Chat.openQuestionLink")(function* (
  c: Controller,
  chatId: string,
  req: Question,
) {
  if (req.url) yield* Effect.forkIn(c.kernel.call("open_url", { url: req.url }), c.scope, { startImmediately: true });
  yield* respondQuestion(c, chatId, req, false);
});
export const submitEdit = Effect.fn("Chat.submitEdit")(function* (c: Controller, chatId: string, itemId: string) {
  const transcript = c.transcript(chatId),
    text = transcript.editText.trim();
  if (!text) return;
  transcript.editing = null;
  c.view.update();
  yield* c.host
    .call("host/chats.edit_message", { chatId, itemId, text })
    .pipe(Effect.catch((error) => c.notify.error(`Edit failed: ${errorMessage(error)}`)));
});
export const revert = Effect.fn("Chat.revert")(function* (c: Controller, chatId: string, itemId: string) {
  const item = c.transcript(chatId).items.find((item) => item.id === itemId);
  const text = item?.role === "user" ? c.plainText(item.blocks) : "";
  yield* Effect.gen(function* () {
    const coverage = yield* c.host.call("host/chats.checkpoint_coverage", { chatId, itemId });
    if (coverage.unknownPaths.length) {
      const names = coverage.unknownPaths.slice(0, 5).join(", "),
        more = coverage.unknownPaths.length > 5 ? ` and ${coverage.unknownPaths.length - 5} more` : "";
      yield* c.notify.warning(`These files cannot be reverted and are left as they are: ${names}${more}`);
    }
    yield* c.host.call("host/chats.restore_checkpoint", { chatId, itemId });
    const composer = c.composer(chatId);
    if (!composer.text.trim()) composer.text = text;
    yield* textChanged(c, chatId);
    c.view.update();
  }).pipe(Effect.catch((error) => c.notify.error(`Restore failed: ${errorMessage(error)}`)));
});
export const stopTask = Effect.fn("Chat.stopTask")(function* (c: Controller, chat: { id: string }, taskId: string) {
  yield* c.host
    .call("host/chats.cancel_task", { chatId: chat.id, taskId })
    .pipe(Effect.catch((error) => c.notify.error(`Could not stop the subagent: ${errorMessage(error)}`)));
});
export const loadImages = Effect.fn("Chat.loadImages")(function* (c: Controller, chatId: string) {
  const transcript = c.transcript(chatId);
  const lists = [transcript.items, ...allTasks(transcript.items).map((row) => row.item.items ?? [])];
  for (const items of lists)
    for (const item of items) {
      if (item.role !== "user" || c.state.itemImages[item.id]) continue;
      const blocks = item.blocks.filter((block) => block.type === "image");
      if (!blocks.length) continue;
      c.state.itemImages[item.id] = "pending";
      yield* Effect.forkIn(
        Effect.all(
          blocks.map((block) =>
            c.kernel.call("attach_image", {
              data: block.data,
              name: `Image.${(block.mimeType ?? "image/png").split("/")[1]}`,
            }),
          ),
          { concurrency: "unbounded" },
        ).pipe(
          Effect.tap((images) =>
            Effect.sync(() => {
              c.state.itemImages[item.id] = images.map((image) => image.id);
              c.view.update();
            }),
          ),
          Effect.catch((error) => {
            c.state.itemImages[item.id] = [];
            return c.notify.warning(`Could not show an attached image: ${errorMessage(error)}`);
          }),
        ),
        c.scope,
        { startImmediately: true },
      );
    }
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.