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/composer.ts15.5 KB
// Composer rendering preserves the chat's retained tree and interaction keys.
import { ui, html } from "convergence";
import { findTags, matchMentions } from "../mentions.ts";
import { keywordsIn } from "../workflows.ts";
import type { Element, Chat, ConfigOption, ValueEvent, DropEvent, PasteEvent, SubmitEvent } from "convergence";
import type { Composer, AnchorProps } from "../state.ts";
import type { ChatView } from "../controller.ts";
import {
  FIELD_INSET_X,
  PILL_INSET_X,
  column,
  row,
  fill,
  full,
  centred,
  BORDER,
  headlineAnchor,
  chipAnchor,
  markOf,
} from "./styles.ts";
import { usageMeter, contextMeter } from "./usage.ts";
import { renderLimit, renderPending } from "./pending.ts";
import { steeringReason } from "../composition.ts";
import { pickerKey } from "../composer.ts";
export function renderPicker(v: ChatView, chatId: string, c: Composer): Element | null {
  if (!c.picker) return null;
  const note = (text: string) => html`<text style="body" size="xs" tone="muted">${text}</text>`;
  let items;
  if (c.picker.kind === "files") {
    const mentions = matchMentions(v.controller.api.mentions.list(), c.picker.query).map((mention) => ({
      id: `mention-${mention.plugin}-${mention.id}`,
      label: mention.label,
      sublabel: mention.detail ?? `@${mention.token}`,
      icon: mention.icon ?? "bot",
      onClick: () => v.ctx.run(v.controller.acceptMention(chatId, mention)),
    }));
    items = [
      ...mentions,
      ...c.files.map((path) => ({
        id: `file-${path}`,
        label: path,
        icon: "file",
        onClick: () => v.ctx.run(v.controller.acceptFile(chatId, path)),
      })),
    ];
    if (!items.length) return note(c.picker.query ? "No matching files" : "Type to search files");
  } else if (c.picker.kind === "skills") {
    items = v.controller.matchingSkills(c, c.picker.query).map((skill) => ({
      id: `skill-${skill.name}`,
      label: `$${skill.name}`,
      sublabel: skill.description || skill.source,
      icon: "sparkles",
      onClick: () => v.ctx.run(v.controller.acceptSkill(chatId, skill)),
    }));
    if (!items.length) return note("No matching skills");
  } else {
    items = v.controller.matchingCommands(c, c.picker.query).map((cmd) => ({
      id: `cmd-${cmd.name}`,
      label: `/${cmd.name}`,
      sublabel: cmd.description,
      onClick: () => v.ctx.run(v.controller.acceptCommand(chatId, cmd)),
    }));
    if (!items.length) return note("No matching commands");
  }
  return html`<div ...${column} gap="small" padding="small" background="popover" borderRadius="md">${ui.list({ id: `picker-${chatId}`, items: items.map((item, index) => ({ ...item, selected: index === (c.picker?.index ?? 0) })) })}</div>`;
}

export function renderComposer(v: ChatView, chat: Chat): Element | null {
  const chatId = chat.id;
  v.ctx.run(v.controller.ensureComposer(chatId));
  const c = v.controller.composer(chatId);
  const running = v.controller.isRunning(chat);
  const reason = steeringReason(v.controller, chat);
  const supportedSimulation = running && v.controller.state.agents[chat.agentId]?.capabilities?.simulatedSteer;
  const enabled = v.controller.state.agentList.map((a) => ({ value: a.id, label: a.name, svg: a.icon }));
  // A pill ellipsizes past its width; the model's names a model and its
  // reasoning, so it gets more room than one value.
  const compact = (select: Element | null, maxWidth = 220) =>
    html`<div ...${row} gap="small" padding="small" maxWidth=${maxWidth}>${select}</div>`;
  // A strip over or under the prompt box: a keyword, a queued prompt.
  const strip = (...children: (Element | null)[]) =>
    html`
      <div ...${row} gap="small" padding="small" paddingX=${FIELD_INSET_X} paddingY="small" background="muted" borderRadius="md">${children}</div>`;
  const pills = [];
  // Agent: a picker before the first prompt, a label afterwards. A draft
  // whose agent was turned off asks for another.
  if (!chat.sessionId && enabled.length) {
    pills.push(
      compact(
        html`<select id=${`agent-${chatId}`} value=${chat.agentId} placeholder="Choose an agent" small ghost options=${enabled} onChange=${(e: ValueEvent<string>) => v.ctx.run(v.controller.changeAgent(chatId, e.value))} />`,
      ),
    );
  } else {
    pills.push(
      html`
        <div ...${row} gap="medium" padding="small" paddingX=${PILL_INSET_X}>
          <icon name="bot" small tone="muted" svg=${v.controller.state.agents[chat.agentId]?.icon} />
          <text style="body" size=${12.2} weight=${500} tone="muted" text=${String(v.controller.state.agents[chat.agentId]?.name ?? chat.agentId ?? "")} />
        </div>`,
    );
  }
  if (c.loading) pills.push(html`<spinner />`);
  // Model, mode, effort, speed and the other settings share one menu;
  // approval (what the agent may do unasked) stays its own pill.
  const options = v.controller.visibleOptions(c);
  const alone = (o: ConfigOption) => o.category === "approval";
  const model = options.find((o) => o.category === "model");
  const together = options.filter((o) => o !== model && !alone(o));
  if (model || together.length) pills.push(compact(v.controller.renderModelPicker(v, chat, model, together), 340));
  for (const option of options.filter(alone)) {
    pills.push(
      compact(
        html`
        <select id=${`opt-${chatId}-${option.id}`} value=${option.value ?? ""} placeholder=${option.name} small ghost
          options=${(option.choices ?? []).map((ch) => ({ value: ch.value, label: ch.name, svg: ch.icon }))}
          onChange=${(e: ValueEvent<string>) => v.ctx.run(v.controller.setOption(chatId, option.id, e.value))} />`,
      ),
    );
  }
  const queuedChat = v.controller.state.chatQueue.find((q) => q.chatId === chatId);
  // A word the agent reacts to (Claude's "ultracode") says what it will
  // do with this prompt while it is in the text.
  // Each mention tag in the prompt is a segment with a chip that says
  // what it asks for and takes it out again.
  const mentionTags = findTags(c.text, v.controller.api.mentions.list()).map((tag, i) =>
    strip(
      html`<icon name=${tag.mention.icon ?? "bot"} small tone="primary" />`,
      html`<text style="body" size="xs" weight=${600} tone="primary" text=${`@${tag.mention.token}`} />`,
      html`<text style="body" size="xs" tone="muted" truncate text=${String(tag.mention.detail ?? tag.mention.label ?? "")} />`,
      html`<spacer />`,
      html`<button id=${`tag-drop-${chatId}-${i}`} icon="x" variant="ghost" small tooltip="Remove this tag" onClick=${() => v.ctx.run(v.controller.dropTag(chatId, tag))} />`,
    ),
  );
  const keywords = keywordsIn(c.text, v.controller.state.agents[chat.agentId]?.promptKeywords).map((keyword) =>
    strip(
      html`<icon name="workflow" small tone="primary" />`,
      html`<text style="body" size="xs" weight=${600} tone="primary">${keyword.label}</text>`,
      keyword.description
        ? html`<text style="body" size="xs" tone="muted" truncate>${keyword.description}</text>`
        : null,
    ),
  );
  const attach = html`<button id=${`attach-${chatId}`} icon="paperclip" variant="ghost" small tooltip="Attach an image" onClick=${() => v.ctx.run(v.controller.pickAttachments(chatId))} />`;
  // A draft queues the chat, a started chat its prompts. While the agent
  // works, what was typed turns Stop into Send, which steers.
  const draft = v.controller.isDraft(chat);
  const typed = !!c.text.trim() || c.attachments.length > 0;
  const action = html`
        <div ...${row} gap="small" padding="medium">
          ${attach}
          ${
            draft
              ? queuedChat
                ? null
                : html`<button id=${`queue-chat-${chatId}`} icon="list-plus" variant="ghost" small tooltip="Queue this chat: it starts when no other chat runs (⌘Enter / Ctrl+Enter)" onClick=${() => v.ctx.run(v.controller.queueChat(chatId))} />`
              : html`<button id=${`queue-${chatId}`} icon="list-plus" variant="ghost" small disabled=${c.loading || c.submitting} tooltip="Hold this prompt in FIFO order (⌘Enter / Ctrl+Enter), even while idle" onClick=${() => v.ctx.run(v.controller.queuePrompt(chatId))} />`
          }
          ${
            running && !typed
              ? html`<button id=${`stop-${chatId}`} icon="square" variant="danger" small tooltip="Stop (⌘⇧C); retain and pause follow-ups" onClick=${() => v.ctx.run(v.controller.scheduler.stop(chatId))} />`
              : html`<button id=${`send-${chatId}`} icon="corner-down-left" variant="primary" small disabled=${!!reason || c.loading || c.submitting} tooltip=${reason ?? (supportedSimulation ? "Interrupt tools, settle and send in the same session (Enter)" : running ? "Steer ongoing work (Enter)" : "Send (Enter)")} onClick=${() => v.ctx.run(v.controller.send(chatId))} />`
          }
        </div>`;
  const queued = [
    ...c.queue.map(({ text, attachments, blocks }, i) =>
      strip(
        html`<icon name="list-plus" small tone="muted" />`,
        html`<text style="body" size="xs" tone="muted">${`Held on older app · ${text || `${blocks?.filter((block) => block.type === "image").length ?? attachments.length} images`}`}</text>`,
        html`<spacer />`,
        html`<button id=${`drop-${chatId}-${i}`} icon="x" variant="ghost" small tooltip="Remove" onClick=${() => v.ctx.run(v.controller.dropQueuedPrompt(chatId, i))} />`,
      ),
    ),
  ];

  // Images waiting to go with the prompt, each with the thumbnail the
  // kernel draws from the bytes it is holding.
  const attachments = c.attachments.length
    ? html`
        <div ...${row} gap="small" padding="small" paddingX=${FIELD_INSET_X} paddingY="small" flexWrap="wrap">
          ${c.attachments.map(
            (image) =>
              html`
            <div ...${row} gap="small" padding=${4} maxWidth=${240} background="subtle" borderRadius="md">
              <image id=${image.id} width=${36} height=${36} radius=${6} />
              <div ...${column} ...${fill} padding="small">
                <text style="body" size="xs" truncate text=${String(image.name ?? "")} />
                <text style="body" size="xs" tone="muted">${`${Math.max(1, Math.round(image.bytes / 1024))} KB`}</text>
              </div>
              <button id=${`attach-drop-${chatId}-${image.id}`} icon="x" variant="ghost" small tooltip=${`Remove ${image.name}`}
                onClick=${() => v.ctx.run(v.controller.removeAttachment(chatId, image.id))} />
            </div>`,
          )}
        </div>`
    : null;

  // Images dropped from Finder or a browser, and images pasted into the
  // prompt, become attachments; other files become @ mentions. Typing
  // with nothing focused lands in the prompt.
  return html`
      <div ...${column} id=${`composer-${chatId}`} gap="small" padding="small" position="relative"
        onKeyDown=${(event: import("convergence").KeyEvent) => { if (!event.modifiers.meta && !event.modifiers.ctrl && !event.modifiers.alt && !event.modifiers.shift) pickerKey(v.controller, chatId, event.key); }}
        drop=${{ files: true, onDrop: (event: DropEvent) => v.ctx.run(v.controller.attachFiles(chatId, "paths" in event ? event.paths : [])) }}
        onPaste=${(event: PasteEvent) => v.ctx.run(v.controller.onPasteFiles(chatId, event))}>
        ${renderPicker(v, chatId, c)}
        ${mentionTags}
        ${keywords}
        ${queued}
        ${renderPending(v, chat)}
        ${renderLimit(v, chat)}
        ${reason ? html`<text style="body" size="xs" tone="muted">${reason}</text>` : null}
        ${c.submitting && !c.dispatching ? html`<div ...${row} gap="small"><spinner /><text tone="muted" size="xs">Saving input…</text></div>` : null}
        ${c.error || v.controller.scheduler.errors[chatId] ? html`<text style="body" size="xs" tone="danger">${c.error ?? v.controller.scheduler.errors[chatId]}</text>` : null}
        ${v.controller.scheduler.available === false && c.queue.length ? html`<text style="body" size="xs" tone="muted">Follow-ups are retained. Automatic release and active steering require an updated app.</text>` : null}
        <div ...${column} background="card" borderRadius="xl">
          ${attachments}
          <div ...${row}>
            <div ...${column} ...${fill} gap="small" padding="small" paddingY=${0}>
              <input id=${`prompt-${chatId}`} value=${c.text} multiline ghost submitOnEnter catchTyping
                placeholder=${`${v.controller.api.mentions.list().length ? "@ files and agents" : "@ files"}, / commands${c.skills.length ? ", $ skills" : ""}`}
                onChange=${(e: ValueEvent<string>) => v.ctx.run(v.controller.onChange(chatId, e.value))}
                onSubmit=${(event?: SubmitEvent) => v.ctx.run(v.controller.onSubmit(chatId, event))} />
            </div>
            ${action}
          </div>
        </div>
        <div ...${row} gap="small" padding="small" paddingX=${FIELD_INSET_X - PILL_INSET_X}>
          <div ...${row} ...${fill} gap="small" flexWrap="wrap">${pills}</div>
          ${usageMeter(v, chat)}
          ${contextMeter(v, chat)}
        </div>
      </div>`;
}

export function workspacePicker(
  v: ChatView,
  chat: Chat,
  place: string,
  anchor: (props: AnchorProps) => Element,
): Element | null {
  const id = `ws-${place}-${chat.id}`;
  const list = Object.values(v.controller.state.workspaces);
  const can = !chat.sessionId && list.length > 1;
  const current = v.controller.state.workspaces[chat.workspaceId];
  const trigger = anchor({ name: current?.name ?? "this workspace", mark: markOf(current), can });
  if (!can) return trigger;
  return html`
      <combobox id=${id} look="toolbar" value=${chat.workspaceId} width=${320}
        options=${list.map((w) => ({ value: w.id, label: w.name, description: w.path, ...markOf(w) }))}
        onChange=${(e: { value?: string }) => {
          if (e.value && e.value !== chat.workspaceId) v.ctx.run(v.controller.moveDraft(chat, e.value));
        }}>
        ${trigger}
      </combobox>`;
}

// A queued draft says so on the right of the workspace chip; the prompt
// below stays editable until the queue sends it.
function queuedBadge(v: ChatView, chat: Chat): Element[] | null {
  const index = v.controller.state.chatQueue.findIndex((queued) => queued.chatId === chat.id);
  if (index < 0) return null;
  const total = v.controller.state.chatQueue.length;
  return [
    html`<spacer />`,
    html`
      <div ...${row} gap="small" paddingX=${PILL_INSET_X}>
        <icon name="list-plus" small tone="info" />
        <text style="body" size="xs" weight=${500} tone="muted" text=${total > 1 ? `Queued · ${index + 1} of ${total}` : "Queued"} />
        <button id=${`unqueue-${chat.id}`} icon="x" variant="ghost" small tooltip="Remove from the queue" onClick=${() => v.ctx.run(v.controller.unqueueChat(chat.id))} />
      </div>`,
  ];
}

export function renderLanding(v: ChatView, chat: Chat): Element | null {
  const headline = { size: "xxl", bold: true };
  // The chip is the same picker, not a label: it sits where the eye is
  // already going, so it has to answer a press like the headline.
  return html`
      <div ...${column} ...${fill} ...${full} gap="large" padding="large" alignItems="center" justifyContent="center" background="stage">
        <div ...${row} gap="small" padding="small" justifyContent="center">
          <text style="body" ...${headline}>What should we do in</text>
          ${workspacePicker(v, chat, "landing", headlineAnchor(headline))}
          <text style="body" ...${headline}>?</text>
        </div>
        <div ...${column} ...${centred} gap="small" padding="small" maxWidth=${768}>
          <div ...${row} gap="small" padding="small">
            ${workspacePicker(v, chat, "chip", chipAnchor)}
            ${queuedBadge(v, chat)}
          </div>
          ${renderComposer(v, chat)}
        </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.