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
grouping.ts3.5 KB
// Consecutive tools and reasoning form groups without changing transcript order.
// Pure display projection: stored transcript items keep their original order.
import type { TranscriptItem, ToolKind } from "convergence";
export type TaskItem = Extract<TranscriptItem, { role: "task" }>;
export type ToolGroup = { kind: "tools"; id: string; items: TranscriptItem[] };
export type TaskGroup = { kind: "tasks"; id: string; items: TaskItem[] };
export type FoldEntry = { kind: "item"; item: TranscriptItem } | ToolGroup | TaskGroup;
const LABELS = {
read: ["Read", "Reads"],
search: ["Search", "Searches"],
execute: ["Run", "Runs"],
edit: ["Edit", "Edits"],
delete: ["Delete", "Deletes"],
move: ["Move", "Moves"],
fetch: ["Fetch", "Fetches"],
think: ["Thought", "Thoughts"],
task: ["Task", "Tasks"],
switch_mode: ["Mode change", "Mode changes"],
other: ["Other tool", "Other tools"],
};
// Every tool kind and reasoning can share a group. Messages, compaction,
// and other standalone entries end it so nothing moves across them.
export function foldItems(items: readonly TranscriptItem[]): FoldEntry[] {
const out: FoldEntry[] = [];
let group: ToolGroup | null = null;
for (const item of items) {
if (item.role !== "tool" && item.role !== "reasoning") {
group = null;
out.push({ kind: "item", item });
continue;
}
if (!group) {
group = { kind: "tools", id: `tools-${item.id}`, items: [] };
out.push(group);
}
group.items.push(item);
}
return out;
}
// "3 Reads, 1 Search": the calls in a fold by kind, in order of first use.
export function groupTitle(items: readonly TranscriptItem[]) {
const counts = new Map<ToolKind, number>();
for (const item of items) {
if (item.role !== "tool") continue;
counts.set(item.call.kind ?? "other", (counts.get(item.call.kind ?? "other") ?? 0) + 1);
}
const parts = [...counts].sort(([a], [b]) => Number(a === "other") - Number(b === "other"));
return parts.map(([kind, n]) => `${n} ${(LABELS[kind] ?? LABELS.other)[n === 1 ? 0 : 1]}`).join(", ") || "Reasoning";
}
// Lines added and removed across the diffs of a fold, or null when no call
// carried a diff.
export function editStats(items: readonly TranscriptItem[]) {
let added = 0;
let removed = 0;
let any = false;
for (const item of items) {
if (item.role !== "tool") continue;
for (const c of item.call.content ?? []) {
if (c.type !== "diff") continue;
any = true;
const stats = c.diff ? countPatch(c.diff) : countTexts(c.oldText ?? "", c.newText ?? "");
added += stats.added;
removed += stats.removed;
}
}
return any ? { added, removed } : null;
}
function countPatch(diff: string) {
let added = 0;
let removed = 0;
for (const line of diff.split("\n")) {
if (line.startsWith("+++") || line.startsWith("---")) continue;
if (line.startsWith("+")) added++;
else if (line.startsWith("-")) removed++;
}
return { added, removed };
}
// Without a patch, count the lines of one text the other does not have.
function countTexts(oldText: string, newText: string) {
const seen = new Map();
for (const line of lines(oldText)) seen.set(line, (seen.get(line) ?? 0) + 1);
let added = 0;
for (const line of lines(newText)) {
const left = seen.get(line) ?? 0;
if (left > 0) seen.set(line, left - 1);
else added++;
}
let removed = 0;
for (const left of seen.values()) removed += left;
return { added, removed };
}
function lines(text: string) {
return text === "" ? [] : text.replace(/\n$/, "").split("\n");
}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.