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