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
workflows.ts8.2 KB
// Workflows in the transcript: a task whose `workflow` is set runs a script
// of agents in phases (a Claude Code dynamic workflow). Its agents are its
// child task items, each naming the phase it runs in. Pure: the chat plugin
// draws what this returns. Self-contained, so the tests can load it alone.
import type { TaskStatus } from "convergence";
import type { TaskInfo, ConfigChoice } from "convergence/protocol";
import type { TaskItem } from "./grouping.ts";
export type PhaseState = "pending" | "waiting" | "running" | "queued" | "failed" | "stopped" | "done";
export type Phase = {
title: string;
agents: TaskItem[];
index: number;
state: PhaseState;
tally: ReturnType<typeof tally>;
};
export type Segment = { id: string; surface: string; tone?: string; opacity?: number };
export type PhaseStrip = { phase: string; segments: Segment[] };
const LIVE = new Set(["queued", "running", "waiting"]);
export function isWorkflow(task: Pick<TaskInfo, "workflow"> | null | undefined) {
return !!task?.workflow;
}
// The agent items of a workflow item, in the order the script started them.
export function agentsOf(item: TaskItem) {
return (item.items ?? []).filter((child) => child.role === "task");
}
// { total, done, failed, running, waiting, queued, stopped } over tasks.
export function tally(tasks: readonly TaskInfo[]) {
const t = { total: tasks.length, done: 0, failed: 0, running: 0, waiting: 0, queued: 0, stopped: 0 };
for (const task of tasks) {
if (task.status === "completed" || task.status === "idle") t.done++;
else if (task.status === "failed") t.failed++;
else if (task.status === "waiting") t.waiting++;
else if (task.status === "queued") t.queued++;
else if (task.status === "cancelled") t.stopped++;
else t.running++;
}
return t;
}
// One word for where a phase stands. `pending` is a phase no agent has
// reached yet; the others follow the most urgent of its agents.
export function phaseState(agents: TaskItem[]): PhaseState {
if (!agents.length) return "pending";
const t = tally(agents.map((agent) => agent.task));
if (t.waiting) return "waiting";
if (t.running) return "running";
if (t.queued) return "queued";
if (t.failed) return "failed";
if (t.stopped && !t.done) return "stopped";
return "done";
}
// Phases in the script's order, each with its agents. An agent in a phase
// the script did not declare gets a phase of its own after the declared
// ones; agents with no phase share an untitled one.
export function phasesOf(item: TaskItem): Phase[] {
const phases = (item.task.workflow?.phases ?? []).map((phase) => ({ title: phase.title, agents: [] as TaskItem[] }));
const byTitle = new Map(phases.map((phase) => [phase.title, phase]));
for (const agent of agentsOf(item)) {
const title = agent.task.phase ?? "";
let phase = byTitle.get(title);
if (!phase) {
phase = { title, agents: [] };
byTitle.set(title, phase);
phases.push(phase);
}
phase.agents.push(agent);
}
return phases.map((phase, index) => ({
...phase,
index,
state: phaseState(phase.agents),
tally: tally(phase.agents.map((agent) => agent.task)),
}));
}
// The phase the run has reached: the last one an agent started in, else
// the first. Null for a run with no phases at all.
export function currentPhase(phases: Phase[]): Phase | null {
let current: Phase | null = null;
for (const phase of phases) if (phase.state !== "pending" && phase.state !== "queued") current = phase;
return current ?? phases[0] ?? null;
}
// The status the workflow row shows: its own, except that an agent waiting
// on the user makes the whole run wait on the user.
export function displayStatus(item: TaskItem) {
const own = item.task.status;
if (!LIVE.has(own ?? "")) return own;
return agentsOf(item).some((agent) => agent.task.status === "waiting") ? "waiting" : own;
}
const SEGMENT: Record<TaskStatus, Omit<Segment, "id">> = {
completed: { surface: "tint", tone: "success" },
idle: { surface: "tint", tone: "success" },
failed: { surface: "tint", tone: "danger" },
waiting: { surface: "tint", tone: "warning" },
running: { surface: "tint", tone: "primary" },
queued: { surface: "muted" },
cancelled: { surface: "muted", opacity: 0.5 },
};
// The progress strip: one segment per agent, grouped by phase, so a
// phase's share of the strip is its share of the agents. A phase no agent
// has reached is one faint placeholder.
export function stripOf(phases: Phase[]): PhaseStrip[] {
return phases.map((phase) => ({
phase: phase.title,
segments: phase.agents.length
? phase.agents.map((agent) => ({
id: agent.task.id,
...(SEGMENT[agent.task.status ?? "running"] ?? SEGMENT.running),
}))
: [{ id: `pending-${phase.index}`, surface: "muted", opacity: 0.35 }],
}));
}
// "Phase 2 of 3 · Verify · 4/7 agents". Leaves out what the run lacks.
export function progressLabel(item: TaskItem, phases: Phase[]) {
const agents = tally(agentsOf(item).map((agent) => agent.task));
const parts = [];
const declared = phases.filter((phase) => phase.title);
const current = currentPhase(declared);
if (current && declared.length > 1) parts.push(`Phase ${current.index + 1} of ${declared.length}`);
if (current?.title && LIVE.has(item.task.status ?? "")) parts.push(current.title);
if (agents.total) parts.push(`${agents.done}/${agents.total} ${agents.total === 1 ? "agent" : "agents"}`);
if (agents.failed) parts.push(`${agents.failed} failed`);
return parts.join(" · ");
}
// "2/2 · 26k tok" for a phase row, or what it waits for.
export function phaseDetail(phase: Phase) {
const t = phase.tally;
if (!t.total) return "Not started";
const parts = [];
if (t.running) parts.push(`${t.running} running`);
if (t.waiting) parts.push(`${t.waiting} need${t.waiting === 1 ? "s" : ""} you`);
if (t.queued) parts.push(`${t.queued} queued`);
parts.push(`${t.done}/${t.total}`);
if (t.failed) parts.push(`${t.failed} failed`);
if (t.stopped) parts.push(`${t.stopped} stopped`);
const spent = phase.agents.reduce((sum, agent) => sum + Number(agent.task.usage?.usedTokens ?? 0), 0);
if (spent) parts.push(tokens(spent));
return parts.join(" · ");
}
// Whether a phase starts open: while its agents run or need the user, and
// when one failed, so the failure is in sight.
export function phaseOpen(phase: Phase) {
return phase.state === "running" || phase.state === "waiting" || phase.state === "failed";
}
// What the script returned, as the UI shows it: text as markdown, any
// other value as indented JSON. Null while there is nothing.
export function resultView(result: unknown) {
if (result === undefined || result === null) return null;
if (typeof result === "string") return result.trim() ? { kind: "text", text: result } : null;
return { kind: "json", text: JSON.stringify(result, null, 2) };
}
// "12k tok"
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`;
}
// The agent's own name for a model id, from its model option: the choice
// with that value or alias, ignoring a context suffix and a dated
// snapshot ("claude-haiku-4-5-20251001" is "Haiku 4.5"). Else the id.
export function modelName(option: { choices?: readonly ConfigChoice[] } | undefined, id: string | null | undefined) {
if (!id) return null;
const bare = (value: string) => value.replace(/\[[^\]]*\]$/, "").replace(/-\d{8}$/, "");
const wanted = bare(id);
const choice = (option?.choices ?? []).find((choice) =>
[choice.value, ...(choice.aliases ?? [])].some((value) => bare(value) === wanted),
);
return choice?.name ?? id;
}
// The keywords `text` contains as whole words, in any case: not part of a
// path, a flag or a file name ("/ultracode", "--ultracode", "ultracode.ts").
export function keywordsIn<K extends { word: string }>(text: string, keywords: readonly K[] | undefined) {
if (!text || !keywords?.length) return [];
return keywords.filter((keyword) => {
const word = keyword.word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|[^\\w/\\\\.-])${word}(?![\\w-]|\\.\\w)`, "i").test(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.