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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.