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/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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.