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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.