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
subagents.ts7.3 KB
// Subagents in the transcript: where their items live, how they group, and
// how their state reads. Pure: the chat plugin draws what this returns.
//
// A subagent is a `task` item ({ id, role: "task", task: TaskInfo, items })
// with its own transcript inside, at any depth. Events carry the task's id
// in `taskId`; the host keeps the same shape.
import type { TranscriptItem, ToolCall } from "convergence";
import type { TaskInfo } from "convergence/protocol";
import type { FoldEntry, TaskItem } from "./grouping.ts";
export type TaskRow = { item: TaskItem; task: TaskInfo; depth: number };
const STATUS = {
queued: { label: "Queued", tone: "muted" },
running: { label: "Running", tone: "primary" },
waiting: { label: "Needs you", tone: "warning" },
idle: { label: "Idle", tone: "muted" },
completed: { label: "Completed", tone: "success" },
failed: { label: "Failed", tone: "danger" },
cancelled: { label: "Stopped", tone: "muted" },
};
export function isLive(task: Pick<TaskInfo, "status">) {
return task.status === "running" || task.status === "waiting" || task.status === "queued";
}
export function statusOf(task: Pick<TaskInfo, "status">) {
return STATUS[task.status ?? "running"] ?? STATUS.running;
}
// The task item with this id, at any depth.
export function findTask(items: readonly TranscriptItem[], id: string | undefined): TaskItem | null {
for (const item of items) {
if (item.role !== "task") continue;
if (item.task.id === id) return item;
const inner = findTask(item.items ?? [], id);
if (inner) return inner;
}
return null;
}
// A new snapshot replaces the one it names wherever it is; a new task goes
// inside the task that started it, or at the end of `items`.
export function applyTask(items: TranscriptItem[], task: TaskInfo): TaskItem {
const existing = findTask(items, task.id);
if (existing) {
existing.task = task;
return existing;
}
const parent = task.parentTaskId ? findTask(items, task.parentTaskId) : null;
const item: TaskItem = { id: `task-${task.id}`, role: "task", task, items: [] };
(parent ? (parent.items ??= []) : items).push(item);
return item;
}
// Tool rows that started a subagent in this list: the task row stands in
// for them, so the call is not shown twice.
export function withoutSpawnRows(items: TranscriptItem[]) {
const spawned = new Set();
for (const item of items) if (item.role === "task" && item.task.toolCallId) spawned.add(item.task.toolCallId);
if (!spawned.size) return items;
return items.filter((item) => !(item.role === "tool" && spawned.has(item.call?.id)));
}
// Two or more subagents in a row, as started together in one turn, become
// one group entry. A workflow has its own card and is never grouped.
// Takes and returns `foldItems` entries.
export function groupTasks(entries: FoldEntry[]): FoldEntry[] {
const out: FoldEntry[] = [];
let run: TaskItem[] = [];
const flush = () => {
if (run.length >= 2) out.push({ kind: "tasks", id: `tasks-${run[0]?.id}`, items: run });
else for (const item of run) out.push({ kind: "item", item });
run = [];
};
for (const entry of entries) {
if (entry.kind === "item" && entry.item.role === "task" && !entry.item.task.workflow) {
run.push(entry.item);
continue;
}
flush();
out.push(entry);
}
flush();
return out;
}
// Every subagent under `items`, parents before their children.
export function allTasks(items: readonly TranscriptItem[], depth = 0, out: TaskRow[] = []): TaskRow[] {
for (const item of items) {
if (item.role !== "task") continue;
out.push({ item, task: item.task, depth });
allTasks(item.items ?? [], depth + 1, out);
}
return out;
}
export function counts(tasks: readonly TaskInfo[]) {
const c = { working: 0, queued: 0, idle: 0, completed: 0, failed: 0, cancelled: 0 };
for (const task of tasks) {
if (task.status === "queued") c.queued++;
else if (isLive(task)) c.working++;
else if (
task.status === "idle" ||
task.status === "completed" ||
task.status === "failed" ||
task.status === "cancelled"
)
c[task.status]++;
}
return c;
}
// "2 working · 1 completed · 1 failed"
export function countsLabel(c: ReturnType<typeof counts>) {
const parts = [];
if (c.working) parts.push(`${c.working} working`);
if (c.queued) parts.push(`${c.queued} queued`);
if (c.idle) parts.push(`${c.idle} idle`);
if (c.completed) parts.push(`${c.completed} completed`);
if (c.failed) parts.push(`${c.failed} failed`);
if (c.cancelled) parts.push(`${c.cancelled} stopped`);
return parts.join(" · ");
}
export function groupLabel(tasks: readonly TaskInfo[]) {
const n = tasks.length;
const noun = n === 1 ? "subagent" : "subagents";
return tasks.some(isLive) ? `Kicked off ${n} ${noun}` : `Ran ${n} ${noun}`;
}
// "42s", "3m 05s", "1h 02m". Null without a start time.
export function elapsed(task: TaskInfo, now: number) {
if (!task.startedAt) return null;
const end = task.endedAt ? Date.parse(task.endedAt) : now;
const seconds = Math.max(0, Math.round((end - Date.parse(task.startedAt)) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
}
export function tokens(n: number | bigint | undefined) {
if (!n) return null;
n = Number(n);
if (n < 1000) return `${n} tok`;
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k tok`;
return `${(n / 1_000_000).toFixed(1)}M tok`;
}
// "model · effort · 1m 05s · 12k tok · 8 tools"
export function metaLine(task: TaskInfo, now: number) {
const model = [task.model, task.effort].filter(Boolean).join(" · ");
const tools = task.toolUses ? `${task.toolUses} ${Number(task.toolUses) === 1 ? "tool" : "tools"}` : null;
return [model || null, elapsed(task, now), tokens(task.usage?.usedTokens), tools].filter(Boolean).join(" · ");
}
// The subagent's name for a label: its own name, else its title.
export function nameOf(task: TaskInfo) {
return task.name || task.title || "Subagent";
}
// Text of a tool call's output, as the agent returned it.
function outputOf(call: ToolCall | null) {
return (call?.content ?? [])
.map((c) => (c.type === "text" ? c.text : c.type === "terminal" ? c.output : ""))
.join("")
.trim();
}
function findCall(items: readonly TranscriptItem[], id: string | undefined): ToolCall | null {
for (const item of items) {
if (item.role === "tool" && item.call?.id === id) return item.call;
if (item.role === "task") {
const inner = findCall(item.items ?? [], id);
if (inner) return inner;
}
}
return null;
}
// What a finished subagent handed back: the agent's own summary of the
// result, else the output of the tool call that started it (hidden behind
// the task row). Null while it runs, and when it only repeats the
// subagent's last message, which the transcript already shows.
export function resultOf(item: TaskItem, allItems: readonly TranscriptItem[]) {
if (isLive(item.task)) return null;
const text = (item.task.summary || outputOf(findCall(allItems, item.task.toolCallId)) || "").trim();
if (!text) return null;
const last = [...(item.items ?? [])].reverse().find((i) => i.role === "assistant");
return last && last.text.trim() === text ? null : text;
}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.