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/rows.ts17.7 KB
// Rows rendering preserves the chat's retained tree and interaction keys.
import * as z from "zod";
import { html } from "convergence";
import { editStats, groupTitle, foldItems } from "../grouping.ts";
import { groupTasks, withoutSpawnRows } from "../subagents.ts";
import type { Element, Chat, Plan, SecurityRequest, TranscriptItem, ValueEvent } from "convergence";
import type { Transcript, UserItem, TextItem, ToolItem, ActivityProps } from "../state.ts";
import type { ChatView } from "../controller.ts";
import type { ToolGroup } from "../grouping.ts";
import { renderApproval, renderPublishRow, placeRequests, renderQuestion } from "./questions.ts";
import { renderTask, renderTaskGroup, renderWorkflow } from "./tasks.ts";
import {
READING_WIDTH,
FIELD_INSET_X,
column,
row,
fill,
full,
HOVER,
clickable,
revealOnHover,
centred,
ROW_TEXT,
TOOL_ICONS,
TOOL_LABELS,
} from "./styles.ts";
export function activityRow(_v: ChatView, props: ActivityProps): Element | null {
const { icon, label, detail, extra, status, open, onClick, tone, compact } = props;
const gap = "medium";
// The text sits in a filled row so the detail still truncates; the
// opacity is on the row because text takes none. No padding of its
// own, so the label starts on the reply text's line.
return html`
<div ...${row} ...${onClick ? clickable : null} gap=${gap} padding="small" paddingX=${compact ? 6 : 5} paddingY=${compact ? 5 : "small"}
borderRadius="md" hover=${onClick ? HOVER : undefined} onClick=${onClick}>
${
icon
? html`
<div ...${row} gap="small" padding="small" width=${14} flexShrink=${0} height=${14} lineHeight=${14} justifyContent="center">
${typeof icon === "string" ? html`<icon name=${icon} small tone=${tone ?? "faint"} />` : icon}
</div>`
: null
}
<div ...${row} ...${fill} gap=${gap} padding=${0} opacity=${tone ? undefined : 0.8}>
${label ? html`<text style="body" ...${ROW_TEXT} tone=${tone ?? "foreground"}>${label}</text>` : null}
${detail ? html`<text style="body" ...${ROW_TEXT} tone=${tone ?? "foreground"} truncate>${detail}</text>` : null}
${extra ?? null}
</div>
${status ?? null}
${
onClick
? html`<div ...${column} ...${revealOnHover} gap="small" padding=${0}><icon name=${open ? "chevron-down" : "chevron-right"} small tone="muted" /></div>`
: null
}
</div>`;
}
export function statsRow(_v: ChatView, stats: { added: number; removed: number } | null): Element | null {
if (!stats) return null;
return html`
<div ...${row} gap=${6} padding="small" paddingX=${3}>
<text style="body" size="xs" weight=${620} lineHeight=${14.6} tone="success">${`+${stats.added}`}</text>
<text style="body" size="xs" weight=${620} lineHeight=${14.6} tone="danger">${`-${stats.removed}`}</text>
</div>`;
}
export function renderUser(v: ChatView, chatId: string, t: Transcript, item: UserItem): Element | null {
// Editing replaces the message: the files go back to the checkpoint
// taken before it and the turns after it are dropped, so it is offered
// only while nothing is running.
if (t.editing === item.id) {
return html`
<div ...${column} gap="small" padding="small" paddingY="medium" alignItems="end">
<div ...${column} ...${full} gap="small" padding="small" maxWidth=${600}>
<div ...${column} gap="small" padding="small" paddingX=${FIELD_INSET_X} paddingY="small" background="card" borderRadius="md">
<input id=${`edit-${item.id}`} value=${t.editText} multiline ghost submitOnEnter
onChange=${(e: ValueEvent<string>) => {
t.editText = e.value;
}}
onSubmit=${() => v.ctx.run(v.controller.submitEdit(chatId, item.id))} />
</div>
<div ...${row} gap="small" padding="small">
<spacer />
<button id=${`edit-cancel-${item.id}`} label="Cancel" variant="ghost" small onClick=${() => {
t.editing = null;
v.controller.view.update();
}} />
<button id=${`edit-send-${item.id}`} label="Send again" variant="primary" small onClick=${() => v.ctx.run(v.controller.submitEdit(chatId, item.id))} />
</div>
</div>
</div>`;
}
const running = v.controller.isRunning(v.controller.state.chats[chatId]);
const images = v.controller.imagesOf(item);
const text = v.controller.plainText(item.blocks);
return html`
<div ...${column} padding="small" paddingY="small" alignItems="end">
${
images.length
? html`
<div ...${row} gap="small" padding="small" paddingY="small" maxWidth=${600} justifyContent="end" flexWrap="wrap">
${images.map((id) => html`<image id=${id} maxHeight=${180} radius=${10} />`)}
</div>`
: null
}
${
text
? html`
<div ...${column} gap="small" padding="small" paddingX=${11} paddingY="medium" maxWidth=${600} background="bubble"
fontSize=${13.125} lineHeight=${21.3} borderRadius="xl">
<markdown id=${`md-${item.id}`} text=${text} />
</div>`
: null
}
<div ...${row} ...${full} gap="small" padding="small" justifyContent="end">
${
running
? null
: html`<button id=${`edit-${item.id}`} icon="pencil" variant="ghost" small tooltip="Edit this message and run again from here"
onClick=${() => {
t.editing = item.id;
t.editText = v.controller.plainText(item.blocks);
v.controller.view.update();
}} />`
}
${
t.checkpoints.has(item.id)
? html`<button id=${`restore-${item.id}`} icon="rotate-ccw" variant="ghost" small tooltip="Revert the workspace files to before this prompt"
onClick=${() => v.ctx.run(v.controller.revert(chatId, item.id))} />`
: null
}
</div>
</div>`;
}
export function renderAssistant(_v: ChatView, _chat: Chat, item: TextItem): Element | null {
// A model sometimes ends a step with a blank line and nothing else;
// a heading over empty space would look like a lost reply.
if (!item.text.trim()) return null;
return html`
<div ...${column} gap="small" padding="small" paddingX=${5} paddingY=${8} fontSize=${13.125} lineHeight=${18.75}>
<markdown id=${`md-${item.id}`} text=${item.text} />
</div>`;
}
export function bodyInset(_v: ChatView, folded?: boolean): { paddingX: number; paddingY: string } {
return { paddingX: folded ? 6 : 5, paddingY: "small" };
}
export function renderReasoning(v: ChatView, t: Transcript, item: TextItem, folded?: boolean): Element | null {
const streaming = t.running && t.items[t.items.length - 1]?.id === item.id;
const open = t.expanded.has(item.id) || streaming;
const first =
item.text
.split("\n")
.find((l) => l.trim())
?.replace(/^#+\s*|\*\*/g, "") ?? "";
const title = streaming ? "Thinking" : open || !first ? "Thought" : first;
return html`
<div ...${column} padding=${0}>
${activityRow(v, { compact: folded, detail: title, open, onClick: () => v.controller.toggle(t.expanded, item.id) })}
${
open
? html`
<div ...${column} gap="small" padding="small" ...${bodyInset(v, folded)} opacity=${0.85} fontSize=${11.25} lineHeight=${14.6}>
<markdown id=${`md-${item.id}`} text=${item.text} />
</div>`
: null
}
</div>`;
}
export function renderToolBody(v: ChatView, call: import("convergence").ToolCall, chat: Chat): Element | null {
const parts = [];
const diffPaths = new Set<string>();
const code = (text: string, language?: string) => html`<code text=${text} language=${language} />`;
// Code mode: the program the agent ran, then what it gave back.
const parsed = z.object({ code: z.string() }).strict().safeParse(call.input);
const program = parsed.success ? parsed.data.code : null;
if (program) parts.push(code(program, "javascript"));
for (const c of call.content ?? []) {
if (c.type === "terminal") {
// The command column shrinks so a long command wraps inside the
// card instead of running past its edge.
parts.push(
html`
<div ...${column} gap="small" padding="small">
<div ...${row} gap="small" padding="small" alignItems="start">
<text style="mono" tone="muted">$</text>
<div ...${column} ...${fill} gap="small" padding="small"><text style="mono" text=${String(c.command ?? "")} /></div>
</div>
${c.output ? code(clip(v, c.output)) : null}
${c.exitCode ? html`<text style="body" size="xs" tone="muted">${`exit ${c.exitCode}`}</text>` : null}
</div>`,
);
} else if (c.type === "diff") {
diffPaths.add(c.path);
parts.push(
html`<diff path=${inWorkspace(v, c.path, chat)} diff=${c.diff} oldText=${c.oldText} newText=${c.newText} maxLines=${200} />`,
);
} else if (c.type === "text") {
parts.push(code(clip(v, c.text)));
} else if (c.type === "image") {
parts.push(html`<text style="body" size="xs" tone="muted">${c.path ? `Image: ${c.path}` : "Image"}</text>`);
}
}
// Some agents report the whole result as structured output and send no
// content at all, which used to render an empty card.
// A text result (a command's output) reads as text, not as one quoted line.
if (!call.content?.length && call.output != null)
parts.push(
typeof call.output === "string"
? code(clip(v, call.output))
: code(clip(v, JSON.stringify(call.output, null, 2)), "json"),
);
if (!parts.length && call.input != null) parts.push(code(JSON.stringify(call.input, null, 2), "json"));
const locations = (call.locations ?? [])
.filter((l) => !diffPaths.has(l.path))
.map((l) => inWorkspace(v, l.line ? `${l.path}:${l.line}` : l.path, chat));
if (locations.length) parts.push(html`<text style="body" size="xs" tone="muted">${locations.join(" ")}</text>`);
return html`
<div ...${column} gap="small" padding="medium" background="card" fontSize=${11.25} lineHeight=${14.6} borderRadius="sm">${parts}</div>`;
}
export function clip(_v: ChatView, text: string): string {
const MAX = 20000;
return text.length > MAX ? `…\n${text.slice(-MAX)}` : text;
}
export function inWorkspace(v: ChatView, text: string, chat: Chat): string {
const root = v.controller.state.workspaces[chat?.workspaceId]?.path;
return root ? text.split(`${root}/`).join("") : text;
}
export function renderTool(v: ChatView, t: Transcript, item: ToolItem, chat: Chat, folded?: boolean): Element | null {
const call = item.call;
// A plugin that registered a renderer for this tool draws it, in its
// own runtime; the kernel places its view here.
const owned = v.controller.api
.toolRenderers()
.some((r) => (r.name && r.name === call.name) || (r.kind && r.kind === call.kind));
if (owned) return html`<tool_view call=${call} id=${String(call.id ?? "")} />`;
const hasBody = (call.content?.length ?? 0) > 0 || call.output != null || call.input != null;
const open = hasBody && t.expanded.has(call.id);
const status =
call.status === "failed"
? html`<icon name="circle-alert" small tone="muted" />`
: call.status === "cancelled"
? html`<text style="body" size="xs" tone="muted">cancelled</text>`
: call.status === "completed"
? null
: html`<spinner />`;
const label = TOOL_LABELS[call.kind ?? "other"] ?? "";
const stats = call.kind === "edit" && call.status === "completed" ? statsRow(v, editStats([item])) : null;
// A multi-line command (a heredoc, say) shows its first line in the row;
// the whole command is in the body.
const detail =
inWorkspace(v, call.title, chat)
.split("\n")
.find((l) => l.trim()) ?? "";
return html`
<div ...${column} padding=${0}>
${activityRow(v, { compact: folded, icon: TOOL_ICONS[call.kind ?? "other"] ?? "cpu", label, detail, extra: stats, status, open, onClick: hasBody ? () => v.controller.toggle(t.expanded, call.id) : undefined })}
${open ? html`<div ...${column} gap="small" padding="small" ...${bodyInset(v, folded)}>${renderToolBody(v, call, chat)}</div>` : null}
</div>`;
}
export function renderPlan(_v: ChatView, plan: Plan): Element | null {
const icon = { pending: "circle", in_progress: "loader", completed: "circle-check" };
const tone = { pending: "faint", in_progress: "info", completed: "success" };
return html`
<div ...${column} gap="small" padding="medium" background="card" borderRadius="md">
${(plan.entries ?? []).map(
(e) =>
html`
<div ...${row} gap="small" padding="small">
<icon name=${icon[e.status ?? "pending"] ?? "circle"} small tone=${tone[e.status ?? "pending"] ?? "muted"} />
<text style="body" size="sm" text=${String(e.content ?? "")} />
</div>`,
)}
</div>`;
}
export function renderCompaction(v: ChatView, item: Extract<TranscriptItem, { role: "compaction" }>): Element | null {
return activityRow(v, {
icon: "fold-vertical",
label: item.summary || "Earlier messages were summarized to free context.",
});
}
export function renderItem(
v: ChatView,
chat: Chat,
t: Transcript,
item: TranscriptItem,
folded?: boolean,
): Element | null {
switch (item.role) {
case "user":
return html`
<div ...${column} padding="small">
<div ...${row} gap="small" padding="small" height=${12} />
${renderUser(v, chat.id, t, item)}
</div>`;
case "assistant":
return renderAssistant(v, chat, item);
case "reasoning":
return renderReasoning(v, t, item, folded);
case "tool":
return renderTool(v, t, item, chat, folded);
case "plan":
return renderPlan(v, item.plan);
case "notice":
return html`<text style="body" size="xs" tone="muted" text=${String(item.text ?? "")} />`;
case "task":
return item.task.workflow ? renderWorkflow(v, chat, t, item) : renderTask(v, chat, t, item);
case "compaction":
return renderCompaction(v, item);
default:
return null;
}
}
export function renderFold(v: ChatView, chat: Chat, t: Transcript, entry: ToolGroup, last?: boolean): Element | null {
const calls = entry.items.filter((item) => item.role === "tool").map((item) => item.call);
const failed = calls.some((call) => call.status === "failed");
const running = calls.some((call) => call.status === "pending" || call.status === "running");
const open = t.folds.get(entry.id) ?? last;
const stats = editStats(entry.items);
const status = failed ? html`<icon name="circle-alert" small tone="muted" />` : running ? html`<spinner />` : null;
// The title starts on the same line as the reply text (both inset 5).
return html`
<div ...${column} gap=${2} padding="small" paddingX=${0} paddingY=${4}>
${activityRow(v, {
label: groupTitle(entry.items),
extra: statsRow(v, stats),
status,
open,
onClick: () => {
t.folds.set(entry.id, !open);
v.controller.view.update();
},
})}
${
open
? html`
<div ...${column} id=${`fold-scroll-${entry.id}`} followEnd gap=${2} padding=${5} maxHeight=${400} background="card" borderRadius="md" overflowY="scroll">
${entry.items.map((item) => renderItem(v, chat, t, item, true))}
</div>`
: null
}
</div>`;
}
export function renderItems(
v: ChatView,
chat: Chat,
t: Transcript,
items: TranscriptItem[],
requests: readonly SecurityRequest[] = [],
): (Element | null)[] {
// "Group tool calls" is the plugin's own setting (plugin.json).
const shown = withoutSpawnRows(items);
const entries = groupTasks(
v.controller.api.settings.get("groupTools") === false
? shown.map((item) => ({ kind: "item", item }))
: foldItems(shown),
);
const rows = entries.map((entry, i) => {
if (entry.kind === "item") return renderItem(v, chat, t, entry.item);
if (entry.kind === "tasks") return renderTaskGroup(v, chat, t, entry);
return renderFold(v, chat, t, entry, i === entries.length - 1);
});
return requests.length ? placeRequests(v, entries, rows, requests) : rows;
}
export function renderTranscript(v: ChatView, chat: Chat): Element | null {
const chatId = chat.id;
const t = v.controller.transcript(chatId);
// The publish row, or in a chat that fixes a bug report the attach
// row, ends the transcript (§10.6, §14.2).
const endRow = (r: SecurityRequest) => r.kind === "publish" || r.kind === "attach_fix";
const publish = t.requests.filter(endRow);
const rows = renderItems(
v,
chat,
t,
t.items,
t.requests.filter((r) => !endRow(r)),
);
if (t.plan?.entries?.length) rows.push(renderPlan(v, t.plan));
if (!t.running) for (const req of publish) rows.push(renderPublishRow(v, chatId, req));
for (const a of t.approvals) rows.push(renderApproval(v, chatId, a));
for (const q of t.questions) rows.push(renderQuestion(v, chatId, t, q));
if (t.running && !t.approvals.length && !t.questions.some((q) => q.responseMode !== "message"))
rows.push(activityRow(v, { icon: html`<spinner />`, label: "Working…" }));
return html`<scroller id=${`transcript-${chatId}`}>${rows.map((row) => reading(v, [row]))}</scroller>`;
}
export function reading(_v: ChatView, children: readonly (Element | null)[]): Element | null {
return html`<div ...${column} ...${centred} gap="small" padding="small" paddingX=${22.5} paddingY=${0} maxWidth=${READING_WIDTH}>${children}</div>`;
}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.