Terminal
A terminal panel tab, driven by whatever shell profile you give it or register.
terminal adds a terminal to the panel, in a tab beside Problems and Logs. On its own it ships
with no shell: it draws tabs and runs xterm in them, but there is nothing to run inside one,
and the panel says so rather than sitting empty. Pair it with
bash — a shell over the workbench's own files, with real node and npm
behind it on a cross-origin isolated page — or bring your own shell.
import { Workbench, FileSystem } from "codelet/workbench";
import { terminal } from "codelet/extensions/terminal";
import { bash } from "codelet/extensions/bash";
const workbench = new Workbench({
parent: document.getElementById("app")!,
fs: new FileSystem({ "/README.md": "# Hello" }),
extensions: [terminal(), bash()],
});#Options
| Option | Type | Default | Description |
|---|---|---|---|
shells | ShellProvider[] | [] | Shells this workbench has that no extension brought. Offered alongside every contributed profile, not instead of them. |
cdn | string | "https://esm.sh" | Where xterm, its fit and web-links addons, and xterm.css are fetched from. |
scrollback | number | 100_000 | How many characters of each tab's output are kept, for a second reader — see below. Not what the tab itself shows. |
#Try it
The panel opens on the Terminal tab. Type ls to see the workbench's own files there, then
sh hello.sh to run one of them. xterm and its addons, plus the shell to run in the tab, load
from a CDN on first open, so the first open takes a moment.
#Bringing your own shell
A tab needs a Pseudoterminal: something with onDidWrite for what to draw, and optionally
handleInput for what the reader typed. There are two ways to hand the terminal one.
Here is a tiny shell, an echo terminal, to see the shape of one:
import { EventEmitter } from "codelet/extensions";
import type { Pseudoterminal } from "codelet/extensions/terminal";
function openEcho(): Pseudoterminal {
const written = new EventEmitter<string>();
let line = "";
return {
onDidWrite: written.event,
open: () => written.fire("echo shell\r\n$ "),
close: () => written.dispose(),
handleInput(data) {
if (data !== "\r") {
line += data;
written.fire(data);
return;
}
written.fire(`\r\n${line}\r\n$ `);
line = "";
},
};
}Pass shells. Each entry is a ShellProvider: a name and an open() that returns a
Pseudoterminal. This is the shape for a shell your page already has, such as a socket to your
own backend.
import { terminal, type ShellProvider } from "codelet/extensions/terminal";
const echoShell: ShellProvider = { name: "Echo", open: openEcho };
terminal({ shells: [echoShell] });Contribute a profile. An extension can offer a shell without the terminal extension knowing
anything about it: declare contributes.terminal.profiles in the manifest, and answer for that
id with window.registerTerminalProfileProvider. This is the route for a shell that ships as its
own extension, the way bash does.
import { defineExtension } from "codelet/extensions";
const echoTerminal = defineExtension({
manifest: {
name: "echo-terminal",
contributes: { terminal: { profiles: [{ id: "echo.shell", title: "Echo" }] } },
},
activate(context, vscode) {
context.subscriptions.push(
vscode.window.registerTerminalProfileProvider("echo.shell", {
provideTerminalProfile: () => new vscode.TerminalProfile({ name: "Echo", pty: openEcho() }),
}),
);
},
});Either way, what you implement is a Pseudoterminal:
open(dimensions?)— called once, when the tab is created.close()— called when the reader closes the tab, or the extension stops.handleInput?(data)— called with what the reader typed.onDidWrite— anEvent<string>you fire with what the shell has to say. This is the only thing that draws anything in the tab.onDidClose?— fire this if the shell ends on its own; the tab closes with it.setDimensions?(dimensions)— called when the tab is resized.
Both routes are read fresh every time a tab is opened, so an extension stopped from the Extensions view takes its shells out of the "New Terminal" menu, and started again puts them back.
#The tab strip
The tabs run down the right edge, as VSCode places its own, and across the foot of the panel on a
narrow screen. Both are one stop in the page's tab order: Tab reaches the strip, the arrow keys
walk it (up and down as a column, left and right across the foot) with Home and End, and each
tab's close button is reachable from the keyboard rather than appearing only under a pointer.
Note
The + at the head of the strip is labelled with the shell it will open, with a chevron beside
it for the others — see Which shell you get.
#Opening a terminal from the tree
Two gestures in the explorer, and they land in different places:
Right-click a directory →
Open Terminal in Panel. The panel opens on a new shell, already in that folder. It always opens a new tab rather than reusing the one you were in, since changing the directory under a shell you were working in is not what you asked for. The palette has the same command, where nothing was clicked and so it means the workspace root.The terminal button at the top of the explorer drops a
New Terminalmenu with both places in it: in the panel under your files, or as an editor tab beside them. It's over no row, so either way it's a plain new terminal at the workspace root.Open Terminal in Panel Open Terminal in New TabThe menu is about where, not about which shell — both rows open the one you last used.
Note
A page can't hand a shell a working directory — there's no process to spawn with one — so the
folder is typed in as cd './<path>', the first line the shell receives. Relative, because where
a shell keeps your files is its own business: bash mounts them at /home/workspace and a shell of
your own may put them anywhere, but both open at the workspace, so a path from the top of the tree
is a path from where the shell already is. A shell that doesn't take cd (a REPL contributed as
a profile, say) will just show that line back at you. VS Code does the same thing for the same
reason.
#Which shell you get
Anything that opens "a terminal" without naming one — the explorer's button, a directory's
right-click, the palette's New Terminal — opens the shell you last used, remembered across
reloads as VS Code remembers its default profile.
The first time, with nothing to remember and more than one shell to choose from, you're asked
once. After that nothing asks again: New Terminal With Profile… in the palette is there for
when you want a different one, and opening it makes that the remembered one.
The panel's own + never asks — it's a split button labelled with the shell it will open
(+ bash), with a chevron beside it for the rest.
#A terminal in the editor area
A shell does not have to live in the panel. Two commands, both VS Code's own, put one in a tab beside your files instead:
| In the palette | Command | What it does |
|---|---|---|
Open Terminal in New Tab | workbench.action.createTerminalEditor | Opens a new shell straight into an editor tab. |
Move Terminal to New Tab | workbench.action.terminal.moveToEditor | Moves the terminal you are in out of the panel, into one. |
The ids are VS Code's own, so anything written against it reaches them; the titles are shorter than VS Code's, which call these "Create New Terminal in Editor Area" and "Move Terminal into Editor Area".
Once it is a tab it is a tab like any other: drag it along the strip, drop it into another pane,
or take it out into a window floating over the shell with Move into New Window. The panel's own
strip loses the row the moment it moves — a terminal is drawn in one place, never two — and
closing the tab closes the shell, exactly as closing its row in the strip would. There is no way
back to the panel: open a new terminal there if that is where you want one.
"Kill Terminal", "Clear Terminal" and terminal.send all mean the terminal you are in,
whichever of the two places it is drawn.
#The footer item
The extension puts a terminal button in the status bar. Clicking it opens the panel on the Terminal tab, and clicking it again closes the panel — so a terminal is one click away without the panel being open to find the tab in. When more than one session is running, the count is shown beside the icon.
#What's fetched, and when
xterm, its two addons and xterm.css are fetched from cdn the first time the panel is opened
on the Terminal tab, not when the extension is added. A workbench nobody opens a terminal in
downloads none of it.
The stylesheet goes into a shadow root around the terminals rather than into your page. That
is deliberate: embedding codelet is one import and no CSS build, and a global .xterm rule set
in your document would break that promise. Nothing of xterm's styling — its own, or the
stylesheets it generates for itself at runtime — reaches your page, and nothing in your page's
reset or font stack reaches the terminal.
#Sessions survive everything but closing them
A tab keeps its live terminal — buffer, scrollback, selection, cursor, the lot — across the panel being closed and reopened, the panel strip moving to Problems and back, and a light/dark theme change. Nothing is rebuilt and nothing is replayed: the theme is applied to the running terminal in place. Only the reader closing the tab, or the shell ending itself, closes a session for good.
scrollback is therefore not what you get back. It caps a copy of each tab's output that the
extension keeps for a second reader — live replaying a shared shell to
a guest who joined an hour in — and for tabs opened by another extension before xterm had
finished loading. The cut is taken at a line break near the limit, so a colour never gets cut in
half.