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

composer-toolbar.test.ts4.4 KB
// The prompt toolbar: a draft queues the chat and has no prompt queue,
// typing while the agent works turns Stop into Send, and input already
// handed to the agent shows as the user's message, never as a queue card.
import assert from "node:assert/strict";
import test from "node:test";
import * as Effect from "effect/Effect";
import type { Chat, InputSnapshot, PendingInput } from "convergence";
import { renderComposer } from "./views/composer.ts";
import { renderPending } from "./views/pending.ts";
import { held } from "./testing.ts";
import { harness, chat, nodes, item, transcript } from "./ui-for-tests.test.ts";

// The catalogs a composer loads, empty.
const empty: Record<string, unknown> = {
  "host/chats.options": { options: [] },
  "host/chats.commands": { commands: [] },
  "host/chats.skills": { skills: [] },
};
const quiet = () => harness((method) => (method in empty ? Effect.succeed(empty[method]) : undefined));
const ids = (h: ReturnType<typeof quiet>, c: Chat) =>
  nodes(renderComposer(h.v, c)).map((node) => node.id).filter((id): id is string => typeof id === "string");

test("a draft queues the chat and has no prompt queue", () => {
  const h = quiet();
  const draft = chat({ id: "d" });
  h.controller.state.chats.d = draft;
  const shown = ids(h, draft);
  assert.ok(shown.includes("queue-chat-d"));
  assert.ok(!shown.includes("queue-d"));
});

test("a started chat queues prompts, not the chat", () => {
  const h = quiet();
  const started = chat({ id: "s", sessionId: "session" });
  h.controller.state.chats.s = started;
  const shown = ids(h, started);
  assert.ok(shown.includes("queue-s"));
  assert.ok(!shown.includes("queue-chat-s"));
});

test("a draft whose first prompt is starting the session is not a draft", () => {
  const h = quiet();
  const starting = chat({ id: "f", status: "running" });
  h.controller.state.chats.f = starting;
  assert.ok(!ids(h, starting).includes("queue-chat-f"));
});

test("while the agent works, Stop shows until something is typed, then Send replaces it", () => {
  const h = quiet();
  const running = chat({ id: "r", sessionId: "session", status: "running" });
  h.controller.state.chats.r = running;
  let shown = ids(h, running);
  assert.ok(shown.includes("stop-r"));
  assert.ok(!shown.includes("send-r"));
  h.controller.composer("r").text = "also check the tests";
  shown = ids(h, running);
  assert.ok(shown.includes("send-r"));
  assert.ok(!shown.includes("stop-r"));
});

const snapshot = (inputs: PendingInput[]): InputSnapshot => ({
  chatId: "c1",
  agentId: "agent",
  sessionId: "session",
  generation: 1,
  revision: 1,
  inputs,
  running: true,
  settling: false,
  policyState: null,
});
const texts = (h: ReturnType<typeof quiet>, c: Chat) =>
  renderPending(h.v, c).flatMap((card) => nodes(card)).map((node) => node.text);

test("a sent prompt the transcript already shows has no queue card while the agent picks it up", () => {
  const h = quiet();
  const c = chat({ id: "c1", status: "running" });
  h.controller.state.chats.c1 = c;
  h.controller.state.transcripts.c1 = transcript([item({ id: "sent", role: "user", text: "sent" })]);
  for (const state of ["dispatching", "native_admitted", "delivery_unknown"] as const) {
    h.controller.scheduler.snapshots.c1 = snapshot([held("sent", { intent: "send", originalIntent: "send", state })]);
    assert.deepEqual(renderPending(h.v, c), [], state);
  }
});

test("a sent prompt the agent refused keeps its card, with the error", () => {
  const h = quiet();
  const c = chat({ id: "c1", status: "running" });
  h.controller.state.chats.c1 = c;
  h.controller.state.transcripts.c1 = transcript([item({ id: "sent", role: "user", text: "sent" })]);
  h.controller.scheduler.snapshots.c1 = snapshot([
    held("sent", { intent: "send", originalIntent: "send", state: "rejected", error: "busy" }),
  ]);
  const shown = texts(h, c);
  assert.ok(shown.includes("Send · not admitted"));
  assert.ok(shown.includes("busy"));
});

test("the prompt this composer is dispatching has no card before the agent claims it", () => {
  const h = quiet();
  const c = chat({ id: "c1" });
  h.controller.state.chats.c1 = c;
  h.controller.scheduler.snapshots.c1 = snapshot([
    held("sent", { intent: "send", originalIntent: "send" }),
    held("later"),
  ]);
  h.controller.composer("c1").dispatching = "sent";
  const shown = texts(h, c);
  assert.ok(!shown.includes("sent"));
  assert.ok(shown.includes("later"), "a real queued follow-up still shows");
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.