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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.