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

mentions.ts5.4 KB
// Composer mentions that other plugins offer after `@` (`api.mentions`),
// such as the subagents plugin's agent/model/effort tags. Pure: the chat
// plugin draws and sends what this returns.
//
// A mention is `{ plugin, id, token, label, detail?, icon?, keywords?,
// prompt }`. In the text a chosen one is `@<token>`, a segment of its own:
// deleting into it deletes all of it, and on send its `prompt` is added to
// the message with `{part}` (the text the tag belongs to) and `{token}`
// filled in.
import type { Mention as HostMention } from "convergence";
export type Mention = Pick<HostMention, "token" | "label" | "prompt"> & Partial<HostMention>;
export type MentionTag = { start: number; end: number; mention: Mention };

// The tags in `text`: `@token` of a known mention, between whitespace or
// the ends of the text. In order, as `{ start, end, mention }` (`end` is
// just after the token).
export function findTags(text: string, mentions: readonly Mention[] | undefined) {
  if (!mentions?.length || !text) return [];
  const byToken = new Map(mentions.map((m) => [m.token, m]));
  const tags: MentionTag[] = [];
  const pattern = /(^|\s)@(\S+)/g;
  let match;
  while ((match = pattern.exec(text))) {
    const mention = byToken.get(match[2] ?? "");
    if (!mention) continue;
    const start = match.index + (match[1] ?? "").length;
    tags.push({ start, end: start + 1 + (match[2] ?? "").length, mention });
  }
  return tags;
}

// The tokens of the tags in `text`, for telling them from `@path` file
// mentions.
export function tagTokens(text: string, mentions: readonly Mention[] | undefined) {
  return new Set(findTags(text, mentions).map((tag) => `@${tag.mention.token}`));
}

// Mentions for the `@` picker: those whose token, label or keywords hold
// `query`, the ones that start with it first.
export function matchMentions(mentions: readonly Mention[] | undefined, query: string | null | undefined, limit = 8) {
  if (!mentions?.length) return [];
  const wanted = String(query ?? "").toLowerCase();
  const scored = [];
  for (const mention of mentions) {
    const token = mention.token.toLowerCase();
    const words = `${mention.label ?? ""} ${mention.keywords ?? ""}`.toLowerCase();
    let score;
    if (!wanted || token.startsWith(wanted)) score = 0;
    else if (token.includes(wanted)) score = 1;
    else if (words.includes(wanted)) score = 2;
    else continue;
    scored.push({ score, mention });
  }
  scored.sort((a, b) => a.score - b.score);
  return scored.slice(0, limit).map((entry) => entry.mention);
}

// The text with its last `@query` token replaced by the chosen tag.
export function acceptMention(text: string, mention: Mention) {
  const at = text.lastIndexOf("@");
  return `${at >= 0 ? text.slice(0, at) : text}@${mention.token} `;
}

// The text with one tag taken out, and a space it leaves doubled.
export function removeTag(text: string, tag: Pick<MentionTag, "start" | "end">) {
  const before = text.slice(0, tag.start);
  let after = text.slice(tag.end);
  if ((before === "" || before.endsWith(" ")) && after.startsWith(" ")) after = after.slice(1);
  return before + after;
}

// An edit as a tag segment takes it: when `next` is `prev` with a stretch
// deleted that cuts into a tag without taking all of it (a backspace at its
// end, a delete at its start), the whole tag goes. Any other edit is kept
// as it is.
export function keepSegments(prev: string, next: string, mentions: readonly Mention[] | undefined) {
  if (prev === next || next.length >= prev.length) return next;
  const tags = findTags(prev, mentions);
  if (!tags.length) return next;
  let head = 0;
  while (head < next.length && prev[head] === next[head]) head++;
  let tail = 0;
  while (tail < next.length - head && prev[prev.length - 1 - tail] === next[next.length - 1 - tail]) tail++;
  // Something was typed as well as deleted: not a plain deletion.
  if (head + tail !== next.length) return next;
  let start = head;
  let end = prev.length - tail;
  let cut = false;
  for (const tag of tags) {
    const overlaps = start < tag.end && end > tag.start;
    const whole = start <= tag.start && end >= tag.end;
    if (overlaps && !whole) {
      start = Math.min(start, tag.start);
      end = Math.max(end, tag.end);
      cut = true;
    }
  }
  if (!cut) return next;
  return removeTag(prev, { start, end });
}

// The text a tag stands for: from the tag to the next tag, or, when
// nothing follows it, back to the tag before it. The whole message when
// both are empty.
export function partOf(text: string, tags: MentionTag[], index: number) {
  const tag = tags[index];
  if (!tag) return text.trim();
  const after = text.slice(tag.end, tags[index + 1]?.start ?? text.length).trim();
  if (after) return after;
  const before = text.slice(tags[index - 1]?.end ?? 0, tag.start).trim();
  return before || text.trim();
}

// What the tags in `text` add to the message: each mention's prompt with
// `{part}` and `{token}` filled in, once each, joined by blank lines. Empty
// when the text has no tags.
export function mentionInstructions(text: string, mentions: readonly Mention[] | undefined) {
  const tags = findTags(text, mentions);
  const out: string[] = [];
  tags.forEach((tag, index) => {
    const prompt = String(tag.mention.prompt ?? "");
    if (!prompt) return;
    const filled = prompt
      .replaceAll("{token}", `@${tag.mention.token}`)
      .replaceAll("{part}", partOf(text, tags, index));
    if (!out.includes(filled)) out.push(filled);
  });
  return out.join("\n\n");
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.