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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.