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/panes.ts4 KB
// Panes rendering preserves the chat's retained tree and interaction keys.
import { html } from "convergence";
import { VISIBLE, reconcile as reconcileCycle, visible } from "../switcher.ts";
import type { Element } from "convergence";
import type { ChatView } from "../controller.ts";
import { renderTranscript, reading } from "./rows.ts";
import { renderComposer, renderLanding } from "./composer.ts";
import { column, row, fill, full } from "./styles.ts";
export function switcherRow(v: ChatView, id: string, chosen: boolean): Element | null {
  const chat = v.controller.state.chats[id];
  if (!chat) return null;
  const workspace = v.controller.state.workspaces[chat.workspaceId]?.name ?? "";
  return html`
      <item id=${`switch-${id}`} label=${v.controller.titleOf(chat)} sublabel=${workspace || undefined} selected=${chosen}
        busy=${v.controller.isRunning(chat)} icon="bot" iconSvg=${v.controller.state.agents[chat.agentId]?.icon}
        onClick=${() => {
          v.controller.state.switcher = { list: [id], selected: id };
          v.ctx.run(v.controller.commitSwitcher());
        }} />`;
}

export function renderSwitcher(v: ChatView): Element | null {
  // The list is checked against the chats the app still has: one closed
  // while the cycle is open must not leave a row behind.
  const open = reconcileCycle(v.controller.state.switcher, v.controller.state.chats);
  v.controller.state.switcher = open;
  const { rows } = open ? visible(open.list, open.selected, VISIBLE) : { rows: [] };
  const keys = {
    up: () => v.controller.switchBy(-1),
    left: () => v.controller.switchBy(-1),
    down: () => v.controller.switchBy(1),
    right: () => v.controller.switchBy(1),
    enter: () => v.ctx.run(v.controller.commitSwitcher()),
    escape: v.controller.closeSwitcher,
  };
  // The overlay draws the card around the column; only the rows are ours.
  return html`
      <overlay id="chat-switcher" open=${!!open} width=${544} hold="ctrl" onRelease=${() => v.ctx.run(v.controller.commitSwitcher())} onDismiss=${v.controller.closeSwitcher} keys=${keys}>
        <div ...${column} gap=${2} padding="small">
          <div ...${row} gap="small" padding="small" paddingX=${8} paddingY=${2}>
            <text style="section" size=${9.84} weight=${700} tone="muted">Recent chats</text>
          </div>
          ${rows.map((id) => switcherRow(v, id, id === open?.selected))}
        </div>
      </overlay>`;
}

// One chat: the landing page while it is fresh, else its transcript with
// the composer under it.
export function renderPane(v: ChatView, chatId: string): Element | null {
  const chat = v.controller.state.chats[chatId];
  if (!chat) return null;
  v.ctx.run(v.controller.ensureChat(chat.id));
  const t = v.controller.transcript(chat.id);
  const landing = t.loaded && !t.items.length && !t.running;
  const body = !t.loaded
    ? html`<empty>Loading…</empty>`
    : landing
      ? renderLanding(v, chat)
      : html`
          <div ...${column} ...${fill} ...${full} padding="small">
            ${renderTranscript(v, chat)}
            <div ...${column} gap="small" padding="small" paddingY="medium">${reading(v, [renderComposer(v, chat)])}</div>
          </div>`;
  // The landing page's stage runs to the pane's edges; the transcript keeps
  // the pane's inset.
  return html`
      <div ...${column} ...${fill} ...${full} id=${`pane-${chat.id}`} padding=${landing ? undefined : "small"} position="relative">
        ${body}
      </div>`;
}

// The shown chat's pane view, made the first time it shows.
export function renderCenter(v: ChatView): Element | null {
  const app = v.ctx.app;
  if (app.route !== "chat") return null;
  if (!app.chatId) return html`<empty>Select a chat or press ⌘N to start one.</empty>`;
  const chat = v.controller.state.chats[app.chatId];
  if (!chat) {
    v.ctx.run(v.controller.loadChat(app.chatId));
    return html`<empty>Loading…</empty>`;
  }
  v.ctx.run(v.controller.followApp());
  const pane = v.controller.state.panes[chat.id];
  return pane ? html`<view view=${pane.view.id} />` : html`<empty>Loading…</empty>`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.