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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.