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/questions.ts6.5 KB
// Questions rendering preserves the chat's retained tree and interaction keys.
import { html } from "convergence";
import type { Element, ApprovalRequest, SecurityRequest, ValueEvent } from "convergence";
import type { Transcript, Question } from "../state.ts";
import type { ChatView } from "../controller.ts";
import type { FoldEntry } from "../grouping.ts";
import { column, row, fill, HOVER, clickable, CARD_SPACE } from "./styles.ts";
export function renderApproval(_v: ChatView, chatId: string, req: ApprovalRequest): Element | null {
  return html`<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}><security_card kind="approval" id=${req.id} chatId=${chatId} /></div>`;
}

export function renderRequest(_v: ChatView, req: SecurityRequest): Element | null {
  return html`<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}><security_card kind="request" id=${req.id} /></div>`;
}

export function renderPublishRow(_v: ChatView, chatId: string, req: SecurityRequest): Element | null {
  return html`<div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}><security_card kind="publish" id=${req.id} chatId=${chatId} /></div>`;
}

export function placeRequests(
  v: ChatView,
  entries: FoldEntry[],
  rows: (Element | null)[],
  requests: readonly SecurityRequest[],
): (Element | null)[] {
  const at = new Map<string, number>();
  entries.forEach((entry, i) => {
    for (const item of entry.kind === "item" ? [entry.item] : entry.items) at.set(item.id, i);
  });
  const after: (Element | null)[][] = rows.map(() => []);
  const end: (Element | null)[] = [];
  for (const req of requests) {
    const index = req.afterItemId ? at.get(req.afterItemId) : undefined;
    (index === undefined ? end : (after[index] ?? end)).push(renderRequest(v, req));
  }
  return [...rows.flatMap((row, i) => [row, ...(after[i] ?? [])]), ...end];
}

export function renderQuestion(v: ChatView, chatId: string, t: Transcript, req: Question): Element | null {
  const answers = t.answers[req.id] ?? {};
  const respond = (cancelled: boolean) => v.ctx.run(v.controller.respondQuestion(chatId, req, cancelled));
  const fields = req.fields.map((f) => {
    const offered = f.options ?? [];
    const parts = [
      html`<text style="heading" text=${String(f.label ?? "")} />`,
      f.description ? html`<text style="body" size="xs" tone="muted">${f.description}</text>` : null,
    ];
    if (f.kind === "text") {
      parts.push(
        html`<input id=${`q-${req.id}-${f.id}`} value=${answers[f.id] ?? ""} placeholder="Type your answer" onChange=${(
          e: ValueEvent<string>,
        ) => {
          t.answers[req.id] = answers;
          answers[f.id] = e.value;
        }} />`,
      );
    } else {
      const options =
        f.kind === "boolean" && !offered.length
          ? [
              { value: "true", label: "Yes" },
              { value: "false", label: "No" },
            ]
          : offered;
      const chosen = answers[f.id];
      const isChosen = (v: string) =>
        Array.isArray(chosen) ? chosen.includes(v) : f.kind === "boolean" ? String(chosen) === v : chosen === v;
      const choose = (o: { value: string }) => () => {
        t.answers[req.id] = answers;
        if (f.kind === "multi_select") {
          const set = new Set(Array.isArray(chosen) ? chosen : []);
          set.has(o.value) ? set.delete(o.value) : set.add(o.value);
          answers[f.id] = [...set];
        } else if (f.kind === "boolean") answers[f.id] = o.value === "true";
        else answers[f.id] = o.value;
        v.controller.view.update();
      };
      // The label column takes the rest of the row so long options wrap
      // instead of pushing past both edges of the card.
      parts.push(
        html`
          <div ...${column} padding="small">
            ${options.map(
              (o, i) =>
                html`
              <div ...${row} ...${clickable} gap="medium" padding="small" paddingX="medium" paddingY="small" alignItems="start"
                background=${isChosen(o.value) ? "accent" : undefined} hover=${HOVER} borderRadius="md" onClick=${choose(o)}>
                <div ...${column} gap="small" padding="small" paddingX="small" alignItems="center" background=${isChosen(o.value) ? "accent" : "subtle"} borderRadius="sm">
                  <text style="body" size="xs" bold>${isChosen(o.value) ? "✓" : String(i + 1)}</text>
                </div>
                <div ...${column} ...${fill} padding="small">
                  <text style="body" size="sm" bold text=${String(o.label ?? "")} />
                  ${o.description ? html`<text style="body" size="xs" tone="muted">${o.description}</text>` : null}
                </div>
              </div>`,
            )}
          </div>`,
      );
      if (f.allowOther) {
        const other = typeof chosen === "string" && !options.some((o) => o.value === chosen) ? chosen : "";
        parts.push(
          html`<input id=${`q-${req.id}-${f.id}-other`} value=${other} placeholder="Something else…" onChange=${(
            e: ValueEvent<string>,
          ) => {
            t.answers[req.id] = answers;
            answers[f.id] = e.value;
          }} />`,
        );
      }
    }
    return html`<div ...${column} gap="small" padding="small">${parts}</div>`;
  });
  return html`
      <div ...${column} gap="small" padding="small" paddingY=${CARD_SPACE}>
        <div ...${column} gap="medium" padding="medium" borderRadius="lg" border=${{ width: 1, color: "info/35" }}>
          ${req.responseMode === "message" ? html`<text style="body" size="xs" tone="muted">Answer when ready. The agent can keep working.</text>` : null}
          ${req.message ? html`<markdown text=${req.message} />` : null}
          ${req.url ? html`<text style="body" size="xs" tone="muted" truncate text=${req.url} />` : null}
          ${fields}
          <div ...${row} gap="small" padding="small" justifyContent="end">
            <button id=${`q-cancel-${req.id}`} disabled=${req.submitting} label="Dismiss" variant="ghost" small onClick=${() => respond(true)} />
            ${
              req.url
                ? html`<button id=${`q-open-${req.id}`} disabled=${req.submitting} label="Open link" variant="primary" small
                  onClick=${() => {
                    return v.ctx.run(v.controller.openQuestionLink(chatId, req));
                  }} />`
                : html`<button id=${`q-submit-${req.id}`} disabled=${req.submitting} label="Answer" variant="primary" small onClick=${() => respond(false)} />`
            }
          </div>
        </div>
      </div>`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.