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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.