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

views/usage.ts9.5 KB
// The account's usage limits and the chat's context window, under the prompt box.
import { html } from "convergence";
import type { Element, Chat, AgentInfo, CanvasCommand, Color, PathStep } from "convergence";
import type { ChatView } from "../controller.ts";
import { column, row, clickable, HOVER, BORDER } from "./styles.ts";

type Window = NonNullable<NonNullable<AgentInfo["usageLimits"]>["windows"]>[number];

// Whole numbers from 10% up, one decimal below: 0.4% is not 0%.
const percent = (value: number) => `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}%`;
const tokens = (value: number | undefined) => (value === undefined ? "-" : Math.round(value).toLocaleString("en"));
const toneOf = (used: number, blocked = false) => (blocked || used >= 95 ? "danger" : used >= 80 ? "warning" : "muted");

/// "5h", "7d": the window's length, which is what tells the windows apart
/// at a glance.
export function shortLabel(window: Window): string {
  const minutes = window.windowMinutes ?? 0;
  return minutes < 24 * 60 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / (24 * 60))}d`;
}

const RING = 14;
const RING_WIDTH = 2;

/// The draw list of a progress ring: a faint track and, clockwise from
/// twelve o'clock, an arc for `fraction` of it, in cubic segments of at
/// most a quarter turn.
export function ringCommands(fraction: number, color: Color): CanvasCommand[] {
  const centre = RING / 2;
  const radius = (RING - RING_WIDTH) / 2;
  const point = (angle: number): [number, number] => [centre + radius * Math.cos(angle), centre + radius * Math.sin(angle)];
  const arc = (from: number, to: number) => {
    const d: PathStep[] = [["moveTo", ...point(from)]];
    const steps = Math.max(1, Math.ceil((to - from) / (Math.PI / 2)));
    const step = (to - from) / steps;
    const k = (4 / 3) * Math.tan(step / 4) * radius;
    for (let i = 0; i < steps; i++) {
      const a = from + i * step;
      const b = a + step;
      d.push([
        "cubicTo",
        centre + radius * Math.cos(a) - k * Math.sin(a),
        centre + radius * Math.sin(a) + k * Math.cos(a),
        centre + radius * Math.cos(b) + k * Math.sin(b),
        centre + radius * Math.sin(b) - k * Math.cos(b),
        ...point(b),
      ]);
    }
    return d;
  };
  const top = -Math.PI / 2;
  const sweep = Math.min(1, Math.max(0, fraction)) * 2 * Math.PI;
  return [
    { op: "path" as const, d: arc(top, top + 2 * Math.PI), stroke: "mutedForeground/25", lineWidth: RING_WIDTH },
    ...(sweep > 0 ? [{ op: "path" as const, d: arc(top, top + sweep), stroke: color, lineWidth: RING_WIDTH }] : []),
  ];
}

function ring(used: number, tone: "danger" | "warning" | "muted"): Element {
  return html`<canvas width=${RING} height=${RING} commands=${ringCommands(used / 100, tone === "muted" ? "mutedForeground" : tone)} />`;
}

/// The windows shown beside the prompt: one per length the agent reports
/// (its 5-hour and its 7-day window), the first of each in the agent's
/// order, or a blocked one wherever it is. The rest are in the card. With
/// none of those (only a spend allowance, say) the first window stands in,
/// so the card stays one press away.
export function headlineWindows(windows: readonly Window[]): Window[] {
  const lengths = new Set<number>();
  const shown = windows.filter((window) => {
    if (window.blocked) return true;
    if (!window.windowMinutes || lengths.has(window.windowMinutes)) return false;
    lengths.add(window.windowMinutes);
    return true;
  });
  return shown.length ? shown : windows.slice(0, 1);
}

export function resetsIn(iso: string | undefined, now = Date.now()): string | null {
  const at = iso ? Date.parse(iso) : Number.NaN;
  if (Number.isNaN(at)) return null;
  const minutes = Math.ceil((at - now) / 60_000);
  if (minutes <= 0) return "now";
  const days = Math.floor(minutes / 1440);
  const hours = Math.floor((minutes % 1440) / 60);
  if (days) return hours ? `${days}d ${hours}h` : `${days}d`;
  return hours ? (minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`) : `${minutes}m`;
}

// A trigger that opens a card above the composer, the way the old
// tooltip did, but pressable so it works without hover.
function meter(v: ChatView, id: string, label: Element[], card: Element): Element {
  const open = !!v.controller.state.wsPicker[id];
  const set = (to: boolean) => {
    v.controller.state.wsPicker[id] = to;
    v.controller.view.update();
  };
  return html`
    <popover id=${id} open=${open} align="end" width=${260} onDismiss=${() => set(false)}>
      <div ...${row} ...${clickable} gap="small" padding="small" paddingX=${6} paddingY=${2} height=${22} borderRadius=${6} hover=${HOVER} onPress=${() => set(!open)}>${label}</div>
      <div ...${column} gap="medium" padding="medium" background="popover" borderRadius="md" border=${BORDER}>${card}</div>
    </popover>`;
}

function limitRow(window: Window): Element {
  const used = window.usedPercent;
  const resets = resetsIn(window.resetsAt);
  return html`
    <div ...${column} gap=${4}>
      <div ...${row} gap="small">
        <text style="body" size="xs" weight=${600}>${window.label}</text>
        <spacer />
        <text style="body" size="xs" tone=${window.blocked ? "danger" : "muted"}>${window.blocked ? "Used up" : `${percent(used)} used`}</text>
      </div>
      <progress value=${Math.min(1, Math.max(0, used / 100))} tone=${toneOf(used, window.blocked)} />
      <div ...${row} gap="small">
        <text style="small" tone="muted">${`${percent(Math.max(0, 100 - used))} remaining`}</text>
        <spacer />
        ${resets ? html`<text style="small" tone="muted">${`Resets in ${resets}`}</text>` : null}
      </div>
    </div>`;
}

/// Always drawn for an agent that reports limits: a dash with the reason
/// until the first report arrives.
export function usageMeter(v: ChatView, chat: Chat): Element | null {
  const agent = v.controller.state.agents[chat.agentId];
  if (!agent?.capabilities?.usageLimits) return null;
  const windows = agent.usageLimits?.windows ?? [];
  const id = `usage-${chat.id}`;
  if (!windows.length) {
    const note = agent.status?.state === "ready" ? "Usage limits not reported yet" : "Usage limits unavailable until the agent is ready";
    return meter(
      v,
      id,
      [ring(0, "muted"), html`<text style="body" size="xs" tone="faint">—</text>`],
      html`<text style="body" size="xs" tone="muted">${`${note}. They appear after the agent reports them.`}</text>`,
    );
  }
  // Beside the prompt a window is its ring and its length: the share is a
  // press away in the card, and a row of numbers under the prompt is noise
  // while the chat runs. Windows are set apart from one another, so a 5h
  // ring is never read as the 7d one's.
  const headline = headlineWindows(windows);
  const label = [
    html`<div ...${row} gap=${headline.length > 1 ? 10 : 4}>
      ${headline.map(
        (window) => html`<div ...${row} gap=${4}>
          ${ring(window.blocked ? 100 : window.usedPercent, toneOf(window.usedPercent, window.blocked))}
          <text style="body" size="xs" weight=${600} tone=${toneOf(window.usedPercent, window.blocked)}>${window.windowMinutes ? shortLabel(window) : window.label}</text>
        </div>`,
      )}
    </div>`,
  ];
  const card = html`
    <div ...${column} gap="medium">
      <div ...${column} gap=${2}>
        <text style="small" tone="muted">Account usage</text>
        <text style="body" weight=${600}>${`${agent.name} limits`}</text>
      </div>
      ${windows.map(limitRow)}
    </div>`;
  return meter(v, id, label, card);
}

export function contextMeter(v: ChatView, chat: Chat): Element | null {
  const usage = v.controller.state.transcripts[chat.id]?.usage;
  if (!usage?.contextWindow) return null;
  const used = (Number(usage.usedTokens ?? 0) / Number(usage.contextWindow)) * 100;
  const detail = (name: string, value: number | undefined) =>
    value === undefined
      ? null
      : html`
        <div ...${row} gap="small">
          <text style="small" tone="muted">${name}</text>
          <spacer />
          <text style="small" weight=${600}>${tokens(value)}</text>
        </div>`;
  const card = html`
    <div ...${column} gap="medium">
      <div ...${row} gap="small">
        <div ...${column} gap=${2}>
          <text style="small" tone="muted">Context</text>
          <text style="body" weight=${600}>${`${tokens(usage.usedTokens)} / ${tokens(usage.contextWindow)}`}</text>
        </div>
        <spacer />
        <text style="body" weight=${600}>${percent(used)}</text>
      </div>
      <progress value=${Math.min(1, Math.max(0, used / 100))} tone=${toneOf(used)} />
      <div ...${column} gap=${4}>
        ${detail("Remaining", Math.max(0, usage.contextWindow - (usage.usedTokens ?? 0)))}
        ${detail("Input", usage.inputTokens)}
        ${detail("Cached input", usage.cachedInputTokens)}
        ${detail("Output", usage.outputTokens)}
        ${detail("Reasoning", usage.reasoningTokens)}
        ${usage.costUsd === undefined ? null : html`
        <div ...${row} gap="small"><text style="small" tone="muted">Cost</text><spacer /><text style="small" weight=${600}>${`$${usage.costUsd.toFixed(4)}`}</text></div>`}
      </div>
    </div>`;
  return meter(
    v,
    `context-${chat.id}`,
    // Like the usage windows: a ring and a name, with the share before it.
    [
      html`<div ...${row} gap=${4}>
        ${ring(used, toneOf(used))}
        <text style="body" size="xs" weight=${600} tone=${toneOf(used)}>${percent(used)}</text>
        <text style="body" size="xs" weight=${600} tone=${toneOf(used)}>Ctx</text>
      </div>`,
    ],
    card,
  );
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.