Chat
A conversation about the workspace in the secondary side bar, answered by any chat extension
Chat is two extensions, and the split is the point: chat draws a conversation and never
answers one, and something else answers one and draws no pane. pi-agent is the one this package
ships (pi), and anything that registers a chat participant or a model provider
will do instead, including an extension written for VS Code.
import { Workbench, FileSystem } from "codelet/workbench";
import { chat } from "codelet/extensions/chat";
import { piAgent } from "codelet/extensions/pi-agent";
const workbench = new Workbench({
parent: document.getElementById("app")!,
fs: new FileSystem({ "/README.md": "# Hello" }),
extensions: [chat, piAgent()],
});chat takes no options: a host does not configure what answers a conversation, it imports an
extension that answers one. Mount it alone and the pane says so — No chat: nothing answers one.
#Opening it
Three buttons, and each is a place you are already looking:
- the chat icon in the activity bar, near the gear at its foot. It marks itself while the pane is showing, and closes it again — an activity bar icon for the secondary side bar is the extension's own, since the bar draws no container of that edge itself.
- the chat icon at the right of the tab strip, above whatever file is open, which is where a conversation about a file usually starts.
$(chat) Chatin the status bar, which spins while an answer is being written — so a turn still streaming says so with the pane shut.
From the command palette: Chat: Focus Chat, Chat: New Chat (which opens the bar too), or
Toggle Secondary Side Bar.
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.
Above the box are two controls: which model answers, over every model any provider published, and which tools the answer may reach, grouped the way the manifests that declared them grouped them. Ticking nothing means every tool, which is what a participant reads an empty list as.
At the head of the model list sits a row per provider that has a way of setting itself up —
Manage pi models… and the like. That is where a provider whose models only appear once you have
given it a key says so, so an empty or short list is never a dead end.
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.
An answer is drawn a part at a time, as the participant wrote it: prose, a line saying what a tool is doing, a link to a place, a tree of file names, a button that runs a command, and a folded strip of what the answer was worked out from. A tool the answer ran is a row of its own — the call on one line, and the first lines of what it answered under it, fading out where there is more. Pressing the row opens the whole of it in the editor.
A tool that writes shows what it wrote rather than the receipt it answered with: the text of a
new file, painted in that file's language, or the -/+ of an edit. It appears as the tool runs,
since it comes from what the tool was asked to do. Pressing the row opens the file at the line that
changed — and unless you turn chat.followEdits off, the editor goes there by itself as each write
lands, so you watch the file change instead of pressing to find out.
Two kinds of question can appear in the middle of an answer — one the participant asked you, and one a tool wants answering before it runs. Both are answered where they are, in the turn that raised them, rather than in a dialog over the workbench. Under a finished answer there are two thumbs, which reach the participant that gave it and nobody else — and only where that participant asked for a vote, since nothing here writes one down.
Answers render as markdown — fences, lists, tables, links — 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. Code fences are highlighted in the same colours as the
editor behind the pane, once the fence is closed.
#Settings
| Key | Type | Default | Description |
|---|---|---|---|
chat.model | string | "" | Which model answers, as vendor/id. Empty is the first model registered. |
chat.followEdits | boolean | true | Open the file a tool wrote, at the line it changed, as the answer runs. |
The pane writes the first when the reader picks a model, so the choice survives a reload. Both are settings rather than host options because they are the reader's, not the page's — the settings tab (Settings) edits them like any other.
chat.followEdits is worth knowing about if an answer writes several files: showing a file selects
its tab, so a long run of writes takes the editor with it. Off, nothing moves until you press a row.
#What answers
Whatever registers a ChatParticipant. pi-agent is the one this package ships — pi's loop, pi's
tools and pi's models, none of them bundled — and it draws no pane of its own:
import { piAgent } from "codelet/extensions/pi-agent";
piAgent({ instructions: "You are the assistant in the ACME playground." });A participant asks request.model — whatever the reader picked, from whatever provider — so what
answers and what it answers over are two separate choices, and neither is the host's.
#Writing your own
There is no codelet-shaped provider seam to implement: what answers a conversation registers through
vscode.chat and vscode.lm, the same calls a VS Code chat extension makes, and chat never
learns which kind of extension it is talking to.
import { defineExtension } from "codelet/extensions";
export const myChat = defineExtension({
manifest: {
name: "my-chat",
contributes: {
chatParticipants: [{ id: "my.chat", name: "mine", description: "Answers from my backend." }],
},
},
activate(context, vscode) {
context.subscriptions.push(
vscode.chat.createChatParticipant("my.chat", async (request, chat, response) => {
const answer = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt: request.prompt }),
});
response.markdown(await answer.text());
}),
);
},
});The participant is handed the request, what came before, a stream to write into, and a token that is
cancelled when the reader presses Stop. response.progress(line) says what is happening,
response.confirmation(title, message, data) asks the reader something mid-answer, and
vscode.lm.invokeTool(name, { input, toolInvocationToken: request.toolInvocationToken }) runs a tool
— threading that token is what puts a tool's own question in the conversation instead of in a dialog,
and what draws the call as a row of its own — the arguments, the first lines of the output, and a
press that opens the whole of it in the editor. You do not
narrate a tool call: the workbench saw the whole of it and says so.
A model provider is the other half, and just as much an ordinary extension:
vscode.lm.registerLanguageModelChatProvider(vendor, provider) with
contributes.languageModelChatProviders declaring the vendor. A participant asks
request.model, so a provider you register answers whatever participant the reader is talking to,
and a participant you register answers over whatever provider they picked.
#Tools
A tool belongs to no extension in particular: every participant sees every registered tool, so one
you register with vscode.lm.registerTool is one any participant's loop can call, and the tools
picker above the message box groups them the way the manifests that declared them did.
pi's four are the exception that proves it — they run inside pi's loop and are never registered, so
they are not offered to anybody else and a .vsix's tools are not offered to pi
(pi).