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

transcript.test.ts7 KB
// Regression coverage for transcript using the real chat modules.
// Fixtures exercise the same behavior and assertions as the JavaScript tests.
import { item as fixtureItem } from "./ui-for-tests.test.ts";
import assert from "node:assert/strict";
import test from "node:test";
import { harness, tree, nodes, chat as makeChat, transcript as initial } from "./ui-for-tests.test.ts";
import { applyEvent } from "./transcript.ts";
import { renderTool as toolView, renderReasoning as reasoningView, renderFold as foldView } from "./views/rows.ts";
import type { Transcript, ToolItem, TextItem } from "./state.ts";
import type { ToolGroup } from "./grouping.ts";
import type { Chat } from "convergence";
const h = harness(),
  renderers = h.renderers;
const renderTool = (t: Transcript, item: ToolItem, chat: Chat, folded?: boolean) =>
  tree(toolView(h.v, t, item, chat, folded));
const renderReasoning = (t: Transcript, item: TextItem, folded?: boolean) => tree(reasoningView(h.v, t, item, folded));
const renderFold = (chat: Chat, t: Transcript, entry: ToolGroup, last?: boolean) =>
  tree(foldView(h.v, chat, t, entry, last));
// A row and what it opens: the activity row, then the body when open.
const header = (node: import("./ui-for-tests.test.ts").TestNode) => node.children[0];
const body = (node: import("./ui-for-tests.test.ts").TestNode) => node.children[1];
const transcript = initial;
const start = (t: Transcript, id = "tool") =>
  applyEvent(t, {
    event: "tool_call_started",
    content: [],
    locations: [],
    id,
    name: "test",
    kind: "execute",
    title: "Run test",
    status: "running",
    input: {},
  });

test("tools default closed and preserve manual expansion across every status", () => {
  for (const status of ["running", "completed", "failed", "cancelled"] as const) {
    for (const manual of [false, true]) {
      const t = transcript();
      start(t);
      assert.equal(body(renderTool(t, t.items[0] as ToolItem, makeChat())), undefined);
      if (manual) header(renderTool(t, t.items[0] as ToolItem, makeChat())).onClick();
      start(t); // A repeated snapshot must not change the user's choice.
      applyEvent(t, { event: "tool_call_updated", id: "tool", status, outputDelta: "output" });
      assert.equal(t.expanded.has("tool"), manual);
      assert.equal(body(renderTool(t, t.items[0] as ToolItem, makeChat())) !== undefined, manual);
    }
  }
});

test("a group is open while it is last, closes once something follows it, and keeps the user's choice", () => {
  const t = transcript();
  start(t);
  const entry: ToolGroup = { kind: "tools", id: "group", items: t.items };
  assert.notEqual(body(renderFold(makeChat(), t, entry, true)), undefined);
  assert.equal(body(renderTool(t, t.items[0] as ToolItem, makeChat(), true)), undefined);
  start(t, "second");
  assert.notEqual(body(renderFold(makeChat(), t, entry, true)), undefined);
  // Text after the group ends it.
  assert.equal(body(renderFold(makeChat(), t, entry, false)), undefined);
  header(renderFold(makeChat(), t, entry, false)).onClick();
  assert.notEqual(body(renderFold(makeChat(), t, entry, false)), undefined);
  // A group the user closed while it was active stays closed.
  const other: ToolGroup = { kind: "tools", id: "other", items: t.items };
  header(renderFold(makeChat(), t, other, true)).onClick();
  assert.equal(body(renderFold(makeChat(), t, other, true)), undefined);
  assert.equal(t.expanded.size, 0);
});

test("a growing tool group keeps one tail-following scroll container", () => {
  const t = transcript();
  start(t);
  const entry: ToolGroup = { kind: "tools", id: "group", items: t.items };
  const first = body(renderFold(makeChat(), t, entry, true));
  assert.equal(first.id, "fold-scroll-group");
  assert.equal(first.followEnd, true);

  start(t, "second");
  entry.items = t.items;
  const second = body(renderFold(makeChat(), t, entry, true));
  assert.equal(second.id, first.id);
  assert.equal(second.followEnd, true);
});

test("grouped thoughts and tools share their horizontal and vertical insets", () => {
  const t = transcript();
  start(t);
  const thought = fixtureItem({ id: "thought", role: "reasoning", text: "Reviewing the output" });
  t.items.push(thought);
  const tool = renderTool(t, t.items[0] as ToolItem, makeChat(), true);
  for (const running of [true, false]) {
    t.running = running;
    const reasoning = renderReasoning(t, thought, true);
    assert.equal(reasoning.padding, tool.padding);
    assert.equal(reasoning.paddingY, tool.paddingY);
    assert.equal(header(reasoning).paddingX, header(tool).paddingX);
    assert.equal(header(reasoning).paddingY, header(tool).paddingY);
  }
  // Opened, a thought and a tool call sit at the same inset.
  t.expanded.add("tool");
  t.expanded.add("thought");
  const openTool = renderTool(t, t.items[0] as ToolItem, makeChat(), true);
  const openThought = renderReasoning(t, thought, true);
  assert.equal(body(openThought).paddingX, body(openTool).paddingX);
  assert.equal(body(openThought).paddingY, body(openTool).paddingY);
});

test("tool calls show their icon in a fold too; thoughts show none", () => {
  const t = transcript();
  start(t);
  const thought = fixtureItem({ id: "thought", role: "reasoning", text: "Reviewing the output" });
  t.items.push(thought);
  // The icons of a row's activity line, the chevron that opens it aside.
  const icons = (row: import("./ui-for-tests.test.ts").TestNode) =>
    nodes(header(row))
      .filter((n) => n.type === "icon" && !n.name.startsWith("chevron"))
      .map((n) => n.name);
  for (const folded of [true, false]) {
    assert.deepEqual(icons(renderTool(t, t.items[0] as ToolItem, makeChat(), folded)), ["square-terminal"]);
    assert.deepEqual(icons(renderReasoning(t, thought, folded)), []);
  }
});

test("a tool another plugin renders is left to that plugin", () => {
  const t = transcript();
  start(t);
  renderers.push({ plugin: "bash-cards", name: "test", kind: null });
  try {
    const row = renderTool(t, t.items[0] as ToolItem, makeChat());
    assert.deepEqual(row, { type: "tool_view", call: (t.items[0] as ToolItem).call, id: "tool" });
  } finally {
    renderers.length = 0;
  }
  assert.equal(renderTool(t, t.items[0] as ToolItem, makeChat()).type, "div", "without a renderer the chat draws it");
});

assert.equal(typeof applyEvent, "function", "the imported implementation is available");

// Cline sends a command's result only as structured output. The live
// reducer dropped it, and the open row showed the command's input instead.
test("a tool update's output reaches the row", () => {
  const t = transcript();
  start(t);
  applyEvent(t, { event: "tool_call_updated", id: "tool", status: "completed", output: "a.txt\nb.txt" });
  const item = t.items[0] as ToolItem;
  assert.equal(item.call.output, "a.txt\nb.txt");
  header(renderTool(t, item, makeChat())).onClick();
  const shown = JSON.stringify(body(renderTool(t, item, makeChat())));
  assert.ok(shown.includes("a.txt\\nb.txt"), "the text itself, not its JSON form");
  assert.ok(!shown.includes('\\"a.txt'), "not quoted");
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.