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
ui-for-tests.test.ts9.1 KB
// Test views use the actual built-in element implementation and real controller methods.
// The local fake records host/kernel effects without opening a desktop window.
import * as Exit from "effect/Exit";
import * as Effect from "effect/Effect";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import type {
Api,
AppState,
Chat,
Element,
HostMethods,
KernelMethods,
Mention,
ToolRendererInfo,
TranscriptItem,
} from "convergence";
import type { TaskInfo } from "convergence/protocol";
import type { HostError } from "convergence/effect";
import { ui, html } from "convergence";
import { Controller } from "./controller.ts";
import type { ChatView } from "./controller.ts";
import { initialTranscript } from "./state.ts";
export { ui, html };
export type TestNode = {
type: string;
id: string;
text: string;
name: string;
label: string;
background: string;
children: TestNode[];
items: TestNode[];
onClick: () => void;
onPress: () => void;
onChange: (event: { value: string }) => void;
onSubmit: () => void;
celebrate: { token: string; onDone: () => void };
[key: string]: unknown;
};
// These are our locally constructed element trees, inspected in test assertions.
// The runtime keeps additional props outside Element's discriminated builder types.
export function tree(node: Element | null | undefined): TestNode {
return node as unknown as TestNode;
}
export function nodes(node: unknown, out: TestNode[] = []): TestNode[] {
if (!node || typeof node !== "object") return out;
if (Array.isArray(node)) {
for (const child of node) nodes(child, out);
return out;
}
const value = node as TestNode;
out.push(value);
nodes(value.children, out);
nodes(value.items, out);
return out;
}
export function chat(fields: Partial<Chat> = {}): Chat {
return {
id: "c",
workspaceId: "w",
agentId: "agent",
title: "New Chat",
createdAt: "",
updatedAt: "",
archived: false,
options: {},
status: "idle",
...fields,
};
}
export function task(id: string, fields: Partial<TaskInfo> = {}): TaskInfo {
return { id, title: `task ${id}`, status: "running", ...fields };
}
export function transcript(items: TranscriptItem[] = []) {
return { ...initialTranscript(), items, running: true };
}
/// `host` answers a host method itself (a failure, a reply the default
/// does not give); `undefined` leaves it to the default reply.
export function harness(host?: (method: string, params: unknown) => Effect.Effect<unknown, HostError> | undefined) {
const calls: { method: string; params: unknown }[] = [],
opened: string[] = [],
notices: string[] = [],
focused: string[] = [];
const renderers: ToolRendererInfo[] = [],
mentions: Mention[] = [];
const stored: Record<string, unknown> = {};
const pending: Promise<unknown>[] = [];
let appState: AppState = {};
let nextView = 0;
const scope = Effect.runSync(Scope.make());
// Only metadata is read from this local fake API; all effects go through the services below.
const api = {
app: { state: () => appState },
toolRenderers: () => renderers,
mentions: { list: () => mentions },
slashCommands: () => [],
settings: { get: () => null },
} as unknown as Api;
function reply(method: string, params: unknown): unknown {
calls.push({ method, params });
if (method === "host/storage.get") return { value: stored[(params as { key: string }).key] };
if (method === "host/storage.set") {
const { key, value } = params as { key: string; value: unknown };
stored[key] = value;
return {};
}
if (method === "open_url") opened.push((params as { url: string }).url);
return {};
}
const controller = new Controller({
api,
scope,
host: {
call: <M extends keyof HostMethods>(method: M, params: HostMethods[M]["params"]) =>
(host?.(method, params) ?? Effect.sync(() => reply(method, params))) as Effect.Effect<
HostMethods[M]["result"],
HostError
>,
},
kernel: {
call: <M extends keyof KernelMethods>(method: M, params: KernelMethods[M]["params"]) =>
Effect.sync(() => reply(method, params) as KernelMethods[M]["result"]),
},
app: {
state: Effect.sync(() => appState),
changes: Stream.empty,
update: (patch) =>
Effect.sync(() => {
appState = { ...appState, ...patch, shared: { ...appState.shared, ...patch.shared } } as AppState;
}),
select: (chatId, workspaceId) =>
Effect.sync(() => {
appState = { ...appState, chatId, workspaceId };
}),
route: () => Effect.void,
},
notify: {
info: (m) =>
Effect.sync(() => {
notices.push(m);
}),
warning: (m) =>
Effect.sync(() => {
notices.push(m);
}),
error: (m) =>
Effect.sync(() => {
notices.push(m);
}),
},
views: {
slot: () => Effect.succeed({ id: ++nextView, update() {}, remove() {} }),
focus: (id) =>
Effect.sync(() => {
focused.push(id);
}),
},
});
const v: ChatView = {
controller,
ctx: {
state: {},
app: appState,
plugin: "chat",
update() {},
run: (effect) => {
pending.push(Effect.runPromise(effect as Effect.Effect<unknown, unknown>));
},
},
};
return {
controller,
v,
calls,
opened,
notices,
focused,
renderers,
mentions,
stored,
settle: async () => {
while (pending.length) await Promise.all(pending.splice(0));
await new Promise<void>((resolve) => setImmediate(resolve));
},
close: () => Effect.runPromise(Scope.close(scope, Exit.void)),
};
}
// Complete small local fixtures with protocol defaults; every assertion still exercises real imports.
import type { ToolCall, ToolKind, AgentEventKind } from "convergence";
import type { TaskItem, FoldEntry } from "./grouping.ts";
import assert from "node:assert/strict";
type TaskFields = Omit<Partial<TaskInfo>, "usage" | "toolUses"> & {
usage?: { usedTokens?: number | bigint };
toolUses?: number | bigint;
};
export function fixtureTask(id: string, fields: TaskFields = {}): TaskInfo {
const { usage, toolUses, ...rest } = fields;
return {
id,
title: `task ${id}`,
status: "running",
...rest,
...(fields.usage ? { usage: { ...fields.usage, usedTokens: Number(fields.usage.usedTokens ?? 0) } } : {}),
...(fields.toolUses !== undefined ? { toolUses: Number(fields.toolUses) } : {}),
};
}
type ItemFields = {
id: string;
role: TranscriptItem["role"];
text?: string;
blocks?: import("convergence").ContentBlock[];
call?: Omit<Partial<ToolCall>, "kind"> & { kind?: string };
task?: TaskFields;
items?: TranscriptItem[];
plan?: import("convergence").Plan;
summary?: string;
};
export function item<R extends TranscriptItem["role"]>(
fields: ItemFields & { role: R },
): Extract<TranscriptItem, { role: R }>;
export function item(fields: ItemFields): TranscriptItem {
switch (fields.role) {
case "task":
return {
id: fields.id,
role: "task",
task: fixtureTask(fields.task?.id ?? fields.id, fields.task),
items: fields.items ?? [],
};
// The future_kind fixture intentionally checks projection of a kind newer than this client.
case "tool":
return {
id: fields.id,
role: "tool",
call: {
id: fields.id,
name: "test",
title: "Test",
status: "completed",
content: [],
locations: [],
...fields.call,
kind: (fields.call?.kind ?? "other") as ToolKind,
},
};
case "user":
return { id: fields.id, role: "user", blocks: fields.blocks ?? [{ type: "text", text: fields.text ?? "" }] };
case "assistant":
return { id: fields.id, role: "assistant", text: fields.text ?? "" };
case "reasoning":
return { id: fields.id, role: "reasoning", text: fields.text ?? "" };
case "notice":
return { id: fields.id, role: "notice", text: fields.text ?? "" };
case "plan":
return { id: fields.id, role: "plan", plan: fields.plan ?? { entries: [] } };
case "compaction":
return { id: fields.id, role: "compaction", ...(fields.summary ? { summary: fields.summary } : {}) };
}
}
export function taskItemAt(items: TranscriptItem[], index: number): TaskItem {
const found = items[index];
assert.ok(found?.role === "task");
return found;
}
export function entryItems(entry: FoldEntry): TranscriptItem[] {
assert.ok(entry.kind !== "item");
return entry.items;
}
export function taskEvent(fields: TaskFields & { id: string }): Extract<AgentEventKind, { event: "task" }> {
return { event: "task", ...fixtureTask(fields.id, fields) };
}
// These local security doubles carry only fields the placement renderer reads.
export function security(fields: {
id: string;
kind?: string;
afterItemId?: string;
plugin?: { name: string };
fix?: { reportId: string; number: number };
}): import("convergence").SecurityRequest {
return { createdAt: "", stale: false, kind: "enable", ...fields } as import("convergence").SecurityRequest;
}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.