Codelet logoCodelet

Chat

A conversation about the workspace in the secondary side bar, answered by a pluggable ChatProvider

chat adds a conversation about the workspace to the secondary side bar. The file tree, the open file and the conversation stay on screen together, instead of swapping the sidebar for a chat view.

import { Workbench, FileSystem } from "codelet/workbench";
import { chat } from "codelet/extensions/chat";

const workbench = new Workbench({
  parent: document.getElementById("app")!,
  fs: new FileSystem({ "/README.md": "# Hello" }),
  extensions: [chat()],
});

#Opening it

chat has no icon of its own in the activity bar — nothing in the secondary side bar does. Open it from the command palette (Toggle Secondary Side Bar, or Chat: Focus Chat), or click the chat icon at the right of the tab strip, above whatever file is open. Chat: New Chat is also in the palette, and opens the bar too.

Another extension can ask a question on the reader's behalf:

vscode.commands.executeCommand("chat.ask", "What does this file do?");

#In the pane

The message box takes focus when the pane opens, and again after you send. Enter sends; Shift+Enter starts a new line. The box grows with what you type, up to a few lines, then scrolls.

While an answer streams in, the transcript follows it to the bottom — until you scroll up yourself, so a new token can't drag you back down. A ↓ Latest button appears while you're scrolled away, and jumps you back.

Send disables on an empty box, and turns into Stop while a turn is being written. A model that needs downloading says which one, and what it costs, before you ask anything. A failure shows what happened with a Try again button next to it, which re-asks the same question rather than leaving it twice in the transcript.

Answers render as markdown — fences, lists, tables, links. It's parsed to elements, not to HTML, so nothing a model writes can become markup in your page. A link is only followed for http:, https: or mailto: URLs, and opens in a new tab.

#Options

OptionTypeDefaultDescription
providerChatProviderwebllm()What answers the conversation. See The default provider and Writing your own provider.
instructionsstringa short built-in system promptWhat the model is told it is, before its tools are described. Which file is open, and where bash starts, is appended either way.
shellstringthe profile that looks like a shell, or the first one there isWhich contributed terminal profile the bash tool spawns, by id or title. See Tools.

#The default provider

webllm() runs a model on the reader's own GPU. Nothing is fetched until the first question, and there's no server behind it. Weights are cached in the browser's Cache Storage, so a second question — even after a reload — reuses what already downloaded.

import { chat, webllm } from "codelet/extensions/chat";

chat({ provider: webllm({ model: "Qwen2.5-Coder-3B-Instruct-q4f16_1-MLC" }) });
OptionTypeDefaultDescription
modelsreadonly { id: string; label: string; size?: string; note?: string }[]a shortlist of 7 modelsWhat the model picker offers. Ids are WebLLM's own.
modelstring"Qwen3.5-4B-q4f16_1-MLC"Which model loads before the reader picks another.
cdnstring"https://esm.sh"Where the WebLLM engine is fetched from.

Warning

webllm() needs WebGPU — Chrome, Edge or Safari 26. Without it, the pane shows a plain "not supported" status instead of an answer. The default model is around 2.4 GB to download on the first question, and wants roughly 3.9 GB of VRAM to run.

#Writing your own provider

Pass provider to run the conversation through your own backend. A ChatProvider is asked once for a ChatSession, and never asked where it runs:

interface ChatProvider {
  name: string;
  open(context: ChatContext): ChatSession | PromiseLike<ChatSession>;
}

interface ChatContext {
  tools: readonly ChatTool[];
  instructions(): string;
}

interface ChatSession {
  ask(messages: readonly ChatMessage[], reply: ChatReply, signal: AbortSignal): Promise<void>;
  models?: readonly ChatModel[];
  model?(): string;
  select?(id: string): void;
  close?(): void;
}

ask answers one turn through reply: reply.text(delta) for each piece of the answer as it arrives, reply.call(index, call) once as a tool call is made and again once its output is filled in, and reply.status(status) for { phase: "loading" | "ready" | "unsupported" | "error", ... }.

A skeleton that calls a backend running its own tool loop:

import type { ChatProvider } from "codelet/extensions/chat";

export const myApi = (): ChatProvider => ({
  name: "My API",
  open(context) {
    return {
      async ask(messages, reply, signal) {
        const res = await fetch("/api/chat", {
          method: "POST",
          signal,
          body: JSON.stringify({ messages, instructions: context.instructions() }),
        });
        const { text, call } = await res.json();
        reply.text(text);
        if (call) {
          const tool = context.tools.find((one) => one.name === call.name);
          const output = tool ? await tool.run(JSON.parse(call.input)) : "error: no such tool";
          reply.call(0, { ...call, output });
        }
      },
    };
  },
});

A provider whose model can call more than one tool per turn loops over context.tools and feeds each result back to the model, the way webllm() does — ask only has to resolve once the turn is over.

#Tools

The model can act on the workspace through five tools:

  • read_file — read a text file.
  • list_dir — list a directory's entries.
  • find_files — find paths by glob, for when the model shouldn't guess at one.
  • write_file — write a whole file, creating missing directories.
  • bash — run one shell command and read what it printed. The shell stays open for the whole conversation, so cd and exported variables carry across calls.

The first four work over workspace.fs, so a file the model writes shows up in the explorer immediately — nothing is worked on in a copy off to one side. They take absolute workspace paths. bash starts in /workspace, where those same files are mounted, and the model is told so along with which file the reader has open.

bash spawns a contributed terminal profile — whatever this workbench registered through contributes.terminal.profiles, such as justBash. shell picks which one, by id or title, when there's more than one; without it, the profile that looks like a shell is picked, or whichever there is. A workbench that contributes no terminal profile has no bash tool at all, rather than one that always fails.

Read more in Extensions > Terminal.