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
controller.ts16.1 KB
// The controller owns pane-scoped state and the Effect services used by actions.
// Views receive their own runner, so handlers always execute in the plugin scope.
import type * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Semaphore from "effect/Semaphore";
import type * as Scope from "effect/Scope";
import type { Api, Chat, ConfigOption, ViewHandle, Element } from "convergence";
import { App, Host, Kernel, Notify, Views } from "convergence/effect";
import type {
EffectClient,
EffectViewContext,
HostError,
KernelCallFailed,
ParseFailed,
PermissionNotGranted,
} from "convergence/effect";
import type { Tabs } from "../tabs/contract.ts";
import { ChatState, initialTranscript } from "./state.ts";
import type { Composer, Transcript } from "./state.ts";
import { step } from "./switcher.ts";
import { findTask } from "./subagents.ts";
import * as data from "./data.ts";
import * as composer from "./composer.ts";
import * as panes from "./panes.ts";
import * as requests from "./requests.ts";
import type { ModelPicker } from "./models.ts";
import { Scheduler } from "./scheduler.ts";
export type ChatError = HostError | KernelCallFailed | ParseFailed | PermissionNotGranted;
export type ChatView = { controller: Controller; ctx: EffectViewContext<object> };
export type Services = {
api: Api;
host: Context.Service.Shape<typeof Host>;
kernel: Context.Service.Shape<typeof Kernel>;
app: Context.Service.Shape<typeof App>;
notify: Context.Service.Shape<typeof Notify>;
views: Context.Service.Shape<typeof Views>;
scope: Scope.Scope;
};
export class Controller {
readonly state = new ChatState();
readonly scheduler = new Scheduler(this);
// App updates coalesce in the baseline render; serialize their asynchronous pane preparation.
private readonly navigation = Semaphore.makeUnsafe(1);
readonly api: Api;
readonly host: Services["host"];
readonly kernel: Services["kernel"];
readonly app: Services["app"];
readonly notify: Services["notify"];
readonly views: Services["views"];
readonly scope: Scope.Scope;
center: ViewHandle | null = null;
titleView: ViewHandle | null = null;
settingsView: ViewHandle | null = null;
modelPicker: ModelPicker | null = null;
// The convergence/tabs client, set while its provider runs; detail views
// open through it, and stay closed without it.
tabs: EffectClient<typeof Tabs> | undefined;
// The convergence/chat handle is set after the views register; queue and
// draft changes are emitted through it instead of shared app state.
emitQueued: ((queued: string[]) => void) | undefined;
emitDrafts: ((drafts: Record<string, string>) => void) | undefined;
readonly view = { update: () => this.refresh(this.api.app.state().chatId) };
constructor(services: Services) {
this.api = services.api;
this.host = services.host;
this.kernel = services.kernel;
this.app = services.app;
this.notify = services.notify;
this.views = services.views;
this.scope = services.scope;
}
composer(chatId: string): Composer {
return (this.state.composers[chatId] ??= {
text: this.state.restored?.[chatId] ?? "",
agentId: null,
options: [],
commands: [],
skills: [],
loading: true,
picker: null,
files: [],
queue: [],
attachments: [],
});
}
transcript(chatId: string): Transcript {
return (this.state.transcripts[chatId] ??= initialTranscript());
}
isRunning(chat: Pick<Chat, "status"> | undefined): boolean {
return !!chat && ["running", "waiting_approval", "waiting_question"].includes(chat.status);
}
/// A chat nothing was sent in yet: its first prompt starts the chat, so it
/// has the chat queue and no prompt queue.
isDraft(chat: Chat): boolean {
return !chat.sessionId && !this.isRunning(chat) && !this.state.transcripts[chat.id]?.items.length;
}
visibleOptions(c: Composer): ConfigOption[] {
const options = c.options.filter((o) => o.kind === "select");
const model = options.find((o) => o.category === "model");
const perModel = model?.choices?.some((choice) => choice.reasoningLevels?.length);
const levels = model?.choices?.find((choice) => choice.value === model.value)?.reasoningLevels ?? [];
const order = ["mode", "model", "reasoning", "approval"];
const rank = (o: ConfigOption) => {
const i = order.indexOf(o.category);
return i < 0 ? order.length : i;
};
return options
.map((o) => ({ ...o, choices: o.category === "reasoning" && perModel ? levels : (o.choices ?? []) }))
.filter((o) => o.choices.length)
.sort((a, b) => rank(a) - rank(b));
}
titleOf(chat: Chat): string {
return this.state.draftTitles[chat.id] ?? chat.title;
}
paneOf(chat: Chat) {
return this.state.panes[chat.id] ?? null;
}
refresh(chatId?: string) {
const chat = chatId ? this.state.chats[chatId] : null;
const pane = chat ? this.paneOf(chat) : null;
if (pane) pane.view.update();
else this.center?.update();
}
refreshSubagentTabs = (chatId: string) => {
for (const entry of Object.values(this.state.paneViews)) if (entry.chatId === chatId) entry.view.update();
};
redraw = (chatId: string) => {
this.view.update();
this.refreshSubagentTabs(chatId);
};
toggle = (set: Set<string>, id: string) => {
if (!set.delete(id)) set.add(id);
this.view.update();
};
switchBy = (delta: number) => {
const next = step(this.state.switcher, this.state.chats, this.state.recent, this.api.app.state().chatId, delta);
if (next) {
this.state.switcher = next;
this.titleView?.update();
}
};
closeSwitcher = () => {
if (this.state.switcher) {
this.state.switcher = null;
this.titleView?.update();
}
};
commitSwitcher = Effect.fn("Chat.commitSwitcher")(
function* (this: Controller) {
const chosen = this.state.switcher?.selected;
if (!this.state.switcher) return;
this.closeSwitcher();
const chat = chosen ? this.state.chats[chosen] : null;
if (chat && chat.id !== this.api.app.state().chatId) yield* this.app.select(chat.id, chat.workspaceId);
}.bind(this),
);
takesImages(chatId: string): boolean {
const chat = this.state.chats[chatId];
return (chat ? this.state.agents[chat.agentId]?.capabilities?.images : undefined) !== false;
}
canStopTask(chat: Chat): boolean {
return !!this.state.agents[chat.agentId]?.capabilities?.cancelTask;
}
inWorkflow(chatId: string, task: { parentTaskId?: string }): boolean {
return !!task.parentTaskId && !!findTask(this.transcript(chatId).items, task.parentTaskId)?.task.workflow;
}
matchingCommands(c: Composer, query: string) {
const plugin = this.api.slashCommands().map((command) => ({
name: command.name,
description: `${command.description} (${command.plugin})`,
inputHint: command.inputHint,
source: "plugin",
}));
return [...c.commands, ...plugin].filter((command) => command.name.startsWith(query)).slice(0, 10);
}
matchingSkills(c: Composer, query: string) {
return c.skills.filter((skill) => skill.name.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10);
}
plainText(blocks: readonly import("convergence").ContentBlock[]): string {
return blocks
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
}
imagesOf(item: import("./state.ts").UserItem): string[] {
const known = this.state.itemImages[item.id];
return known && known !== "pending" ? known : [];
}
renderModelPicker(v: ChatView, chat: Chat, model: ConfigOption | undefined, extras: ConfigOption[]): Element | null {
return this.modelPicker?.render(v, chat, model, extras) ?? null;
}
loadRecent(...args: Tail<Parameters<typeof data.loadRecent>>): ReturnType<typeof data.loadRecent> {
return data.loadRecent(this, ...args);
}
noteRecent(...args: Tail<Parameters<typeof data.noteRecent>>): ReturnType<typeof data.noteRecent> {
return data.noteRecent(this, ...args);
}
syncDraftTitle(...args: Tail<Parameters<typeof data.syncDraftTitle>>): ReturnType<typeof data.syncDraftTitle> {
return data.syncDraftTitle(this, ...args);
}
syncDraftTitles(...args: Tail<Parameters<typeof data.syncDraftTitles>>): ReturnType<typeof data.syncDraftTitles> {
return data.syncDraftTitles(this, ...args);
}
saveUnsent(...args: Tail<Parameters<typeof data.saveUnsent>>): ReturnType<typeof data.saveUnsent> {
return data.saveUnsent(this, ...args);
}
textChanged(...args: Tail<Parameters<typeof data.textChanged>>): ReturnType<typeof data.textChanged> {
return data.textChanged(this, ...args);
}
restoreUnsent(...args: Tail<Parameters<typeof data.restoreUnsent>>): ReturnType<typeof data.restoreUnsent> {
return data.restoreUnsent(this, ...args);
}
persistQueues(...args: Tail<Parameters<typeof data.persistQueues>>): ReturnType<typeof data.persistQueues> {
return data.persistQueues(this, ...args);
}
restoreQueues(...args: Tail<Parameters<typeof data.restoreQueues>>): ReturnType<typeof data.restoreQueues> {
return data.restoreQueues(this, ...args);
}
loadOptions(...args: Tail<Parameters<typeof data.loadOptions>>): ReturnType<typeof data.loadOptions> {
return data.loadOptions(this, ...args);
}
loadChat(...args: Tail<Parameters<typeof data.loadChat>>): ReturnType<typeof data.loadChat> {
return data.loadChat(this, ...args);
}
loadWorkspaces(...args: Tail<Parameters<typeof data.loadWorkspaces>>): ReturnType<typeof data.loadWorkspaces> {
return data.loadWorkspaces(this, ...args);
}
refreshHistory(...args: Tail<Parameters<typeof data.refreshHistory>>): ReturnType<typeof data.refreshHistory> {
return data.refreshHistory(this, ...args);
}
loadTranscript(...args: Tail<Parameters<typeof data.loadTranscript>>): ReturnType<typeof data.loadTranscript> {
return data.loadTranscript(this, ...args);
}
loadRequests(...args: Tail<Parameters<typeof data.loadRequests>>): ReturnType<typeof data.loadRequests> {
return data.loadRequests(this, ...args);
}
tickSubagents(...args: Tail<Parameters<typeof data.tickSubagents>>): ReturnType<typeof data.tickSubagents> {
return data.tickSubagents(this, ...args);
}
onChange(...args: Tail<Parameters<typeof composer.onChange>>): ReturnType<typeof composer.onChange> {
return composer.onChange(this, ...args);
}
acceptSkill(...args: Tail<Parameters<typeof composer.acceptSkill>>): ReturnType<typeof composer.acceptSkill> {
return composer.acceptSkill(this, ...args);
}
acceptFile(...args: Tail<Parameters<typeof composer.acceptFile>>): ReturnType<typeof composer.acceptFile> {
return composer.acceptFile(this, ...args);
}
acceptMention(...args: Tail<Parameters<typeof composer.acceptMention>>): ReturnType<typeof composer.acceptMention> {
return composer.acceptMention(this, ...args);
}
dropTag(...args: Tail<Parameters<typeof composer.dropTag>>): ReturnType<typeof composer.dropTag> {
return composer.dropTag(this, ...args);
}
acceptCommand(...args: Tail<Parameters<typeof composer.acceptCommand>>): ReturnType<typeof composer.acceptCommand> {
return composer.acceptCommand(this, ...args);
}
keepNewImages(...args: Tail<Parameters<typeof composer.keepNewImages>>): ReturnType<typeof composer.keepNewImages> {
return composer.keepNewImages(this, ...args);
}
refuseImages(...args: Tail<Parameters<typeof composer.refuseImages>>): ReturnType<typeof composer.refuseImages> {
return composer.refuseImages(this, ...args);
}
attachFiles(...args: Tail<Parameters<typeof composer.attachFiles>>): ReturnType<typeof composer.attachFiles> {
return composer.attachFiles(this, ...args);
}
onPasteFiles(...args: Tail<Parameters<typeof composer.onPasteFiles>>): ReturnType<typeof composer.onPasteFiles> {
return composer.onPasteFiles(this, ...args);
}
removeAttachment(
...args: Tail<Parameters<typeof composer.removeAttachment>>
): ReturnType<typeof composer.removeAttachment> {
return composer.removeAttachment(this, ...args);
}
pickAttachments(
...args: Tail<Parameters<typeof composer.pickAttachments>>
): ReturnType<typeof composer.pickAttachments> {
return composer.pickAttachments(this, ...args);
}
onSubmit(...args: Tail<Parameters<typeof composer.onSubmit>>): ReturnType<typeof composer.onSubmit> {
return composer.onSubmit(this, ...args);
}
sendBlocks(...args: Tail<Parameters<typeof composer.sendBlocks>>): ReturnType<typeof composer.sendBlocks> {
return composer.sendBlocks(this, ...args);
}
send(...args: Tail<Parameters<typeof composer.send>>): ReturnType<typeof composer.send> {
return composer.send(this, ...args);
}
queuePrompt(...args: Tail<Parameters<typeof composer.queuePrompt>>): ReturnType<typeof composer.queuePrompt> {
return composer.queuePrompt(this, ...args);
}
dropQueuedPrompt(
...args: Tail<Parameters<typeof composer.dropQueuedPrompt>>
): ReturnType<typeof composer.dropQueuedPrompt> {
return composer.dropQueuedPrompt(this, ...args);
}
sendText(...args: Tail<Parameters<typeof composer.sendText>>): ReturnType<typeof composer.sendText> {
return composer.sendText(this, ...args);
}
queueChat(...args: Tail<Parameters<typeof composer.queueChat>>): ReturnType<typeof composer.queueChat> {
return composer.queueChat(this, ...args);
}
unqueueChat(...args: Tail<Parameters<typeof composer.unqueueChat>>): ReturnType<typeof composer.unqueueChat> {
return composer.unqueueChat(this, ...args);
}
pumpChatQueue(...args: Tail<Parameters<typeof composer.pumpChatQueue>>): ReturnType<typeof composer.pumpChatQueue> {
return composer.pumpChatQueue(this, ...args);
}
setOption(...args: Tail<Parameters<typeof composer.setOption>>): ReturnType<typeof composer.setOption> {
return composer.setOption(this, ...args);
}
changeAgent(...args: Tail<Parameters<typeof composer.changeAgent>>): ReturnType<typeof composer.changeAgent> {
return composer.changeAgent(this, ...args);
}
moveDraft(...args: Tail<Parameters<typeof composer.moveDraft>>): ReturnType<typeof composer.moveDraft> {
return composer.moveDraft(this, ...args);
}
ensureComposer(...args: Tail<Parameters<typeof panes.ensureComposer>>): ReturnType<typeof panes.ensureComposer> {
return panes.ensureComposer(this, ...args);
}
ensureChat(...args: Tail<Parameters<typeof panes.ensureChat>>): ReturnType<typeof panes.ensureChat> {
return panes.ensureChat(this, ...args);
}
removePane(chatId: string) {
panes.removePane(this, chatId);
}
followApp(...args: Tail<Parameters<typeof panes.followApp>>): ReturnType<typeof panes.followApp> {
return this.navigation.withPermit(panes.followApp(this, ...args));
}
openWorkflow(...args: Tail<Parameters<typeof panes.openWorkflow>>): ReturnType<typeof panes.openWorkflow> {
return panes.openWorkflow(this, ...args);
}
openSubagent(...args: Tail<Parameters<typeof panes.openSubagent>>): ReturnType<typeof panes.openSubagent> {
return panes.openSubagent(this, ...args);
}
openAgents(...args: Tail<Parameters<typeof panes.openAgents>>): ReturnType<typeof panes.openAgents> {
return panes.openAgents(this, ...args);
}
respondQuestion(
...args: Tail<Parameters<typeof requests.respondQuestion>>
): ReturnType<typeof requests.respondQuestion> {
return requests.respondQuestion(this, ...args);
}
openQuestionLink(
...args: Tail<Parameters<typeof requests.openQuestionLink>>
): ReturnType<typeof requests.openQuestionLink> {
return requests.openQuestionLink(this, ...args);
}
submitEdit(...args: Tail<Parameters<typeof requests.submitEdit>>): ReturnType<typeof requests.submitEdit> {
return requests.submitEdit(this, ...args);
}
revert(...args: Tail<Parameters<typeof requests.revert>>): ReturnType<typeof requests.revert> {
return requests.revert(this, ...args);
}
stopTask(...args: Tail<Parameters<typeof requests.stopTask>>): ReturnType<typeof requests.stopTask> {
return requests.stopTask(this, ...args);
}
loadImages(...args: Tail<Parameters<typeof requests.loadImages>>): ReturnType<typeof requests.loadImages> {
return requests.loadImages(this, ...args);
}
}
type Tail<T extends readonly unknown[]> = T extends readonly [unknown, ...infer Rest] ? Rest : never;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.