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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.