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

usage.test.ts5 KB
// The limits and context meters under the prompt box, and the combined model picker.
import assert from "node:assert/strict";
import test from "node:test";
import type { AgentInfo } from "convergence";
import { headlineWindows, resetsIn, ringCommands, usageMeter, contextMeter } from "./views/usage.ts";
import { harness, tree as inspect, nodes, chat as makeChat } from "./ui-for-tests.test.ts";

const texts = (element: unknown) => nodes(element).flatMap((node) => (node.text ? [node.text] : []));
const agent = (fields: Partial<AgentInfo>) =>
  ({ id: "one", name: "One", capabilities: { usageLimits: true }, status: { state: "ready" }, ...fields }) as AgentInfo;

test("an agent that reports limits always gets a meter, empty until the first report", () => {
  const h = harness();
  const chat = makeChat({ id: "c", agentId: "one" });
  h.controller.state.agents.one = agent({});
  assert.deepEqual(texts(usageMeter(h.v, chat)).slice(0, 1), ["—"]);
  h.controller.state.agents.one = agent({
    usageLimits: {
      windows: [
        { id: "five_hour", label: "5 hours", usedPercent: 23.4, windowMinutes: 300 },
        { id: "seven_day", label: "7 days", usedPercent: 91, windowMinutes: 10080 },
      ],
    },
  });
  // Beside the prompt: rings and their lengths, never the share. The
  // numbers are a press away in the card.
  assert.deepEqual(texts(usageMeter(h.v, chat)).slice(0, 2), ["5h", "7d"]);
  // Beside the prompt, one window per length: a model's own weekly window
  // and windows of no fixed length are in the card only.
  assert.deepEqual(
    headlineWindows([
      { id: "five_hour", label: "5 hours", usedPercent: 9, windowMinutes: 300 },
      { id: "overage", label: "Extra Usage", usedPercent: 0 },
      { id: "seven_day", label: "7 days", usedPercent: 37, windowMinutes: 10080 },
      { id: "seven_day_fable", label: "7 days (Fable)", usedPercent: 0, windowMinutes: 10080 },
    ]).map((window) => window.id),
    ["five_hour", "seven_day"],
  );
  // Below 10% the card keeps one decimal.
  h.controller.state.agents.one = agent({
    usageLimits: { windows: [{ id: "five_hour", label: "5 hours", usedPercent: 0.4, windowMinutes: 300 }] },
  });
  assert.equal(texts(usageMeter(h.v, chat))[0], "5h");
  assert.ok(texts(usageMeter(h.v, chat)).includes("0.4% used"));
  // A spend allowance alone still gives the card a trigger.
  assert.deepEqual(
    headlineWindows([{ id: "overage", label: "Extra Usage", usedPercent: 12 }]).map((window) => window.id),
    ["overage"],
  );
  h.controller.state.agents.one = agent({ capabilities: {} });
  assert.equal(usageMeter(h.v, chat), null);
});

test("the context meter is a ring, the share and its name", () => {
  const h = harness();
  const chat = makeChat({ id: "c" });
  assert.equal(contextMeter(h.v, chat), null);
  h.controller.transcript("c").usage = { usedTokens: 25_000, contextWindow: 100_000 };
  assert.deepEqual(texts(contextMeter(h.v, chat)).slice(0, 2), ["25%", "Ctx"]);
  assert.ok(nodes(contextMeter(h.v, chat)).some((node) => node.type === "canvas"));
});

test("the windows under the prompt are set apart from one another", () => {
  const h = harness();
  const chat = makeChat({ id: "c", agentId: "one" });
  const windows = [
    { id: "five_hour", label: "5 hours", usedPercent: 23.4, windowMinutes: 300 },
    { id: "seven_day", label: "7 days", usedPercent: 91, windowMinutes: 10080 },
  ];
  h.controller.state.agents.one = agent({ usageLimits: { windows } });
  const strip = () => nodes(usageMeter(h.v, chat)).find((node) => node.type === "div" && node.children?.every((child) => child.type === "div" && child.children.some((part) => part.type === "canvas")))!;
  const at = () => strip().children;
  assert.equal(at().length, 2);
  // Ring to length stays tight; window to window opens up, so a 5h ring
  // is never read as the 7d one's.
  assert.ok(Number(strip().gap) > Number(at()[0].gap));
  h.controller.state.agents.one = agent({ usageLimits: { windows: windows.slice(0, 1) } });
  assert.equal(at().length, 1);
  assert.equal(Number(strip().gap), Number(at()[0].gap));
});

test("reset times read as the time left", () => {
  const now = Date.parse("2026-01-01T00:00:00Z");
  assert.equal(resetsIn("2026-01-01T02:05:00Z", now), "2h 5m");
  assert.equal(resetsIn("2026-01-03T03:00:00Z", now), "2d 3h");
  assert.equal(resetsIn("2025-12-31T00:00:00Z", now), "now");
  assert.equal(resetsIn(undefined, now), null);
});

test("a limit's ring is a full faint track and an arc clockwise from the top for the share used", () => {
  const [track, arc] = ringCommands(0.25, "warning") as { op: string; d: (string | number)[][]; stroke: string }[];
  assert.equal(track.stroke, "mutedForeground/25");
  assert.equal(arc.stroke, "warning");
  const end = (d: (string | number)[][]) => d.at(-1)!.slice(-2).map((n) => Math.round(Number(n) * 100) / 100);
  assert.deepEqual(arc.d[0].map((n) => (typeof n === "number" ? Math.round(n * 100) / 100 : n)), ["moveTo", 7, 1]);
  assert.deepEqual(end(arc.d), [13, 7]);
  assert.deepEqual(end(track.d), [7, 1]);
  assert.equal(ringCommands(0, "danger").length, 1);
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.