Skip to content

Terminals

A terminal is a Linux process attached to a pseudo-terminal (PTY), the same way a shell runs in a desktop terminal window. Pass terminal: { columns, rows } to vm.spawn and you get a TerminalHandle: one stream of output to draw on screen, and one stream of input for what the user types.

Use a terminal for anything interactive: shells, REPLs, editors and programs that show a prompt or redraw the screen. When your own code reads a program’s output, use exec or a plain spawn from Run commands instead.

The shell below runs in this page through the same SDK calls this guide uses.

Linux shellUserspaceNot started
oxidevm:~$ uname -mx86_64oxidevm:~$ 
Downloads Linux into this tab when you click.
shell.ts
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });
export const shell = await vm.spawn({
command: "/bin/sh",
args: ["-l"],
env: { TERM: "xterm-256color" },
terminal: { columns: 80, rows: 24 },
});

columns and rows are whole numbers from 1 to 1000. -l starts a login shell, which reads the image’s /etc/profile. The SDK’s default environment has only PATH and HOME, so set TERM yourself: full-screen programs use it to learn what your terminal can display, and xterm-256color suits xterm.js.

The handle has:

Member What it does
output A ReadableStream<Uint8Array> of everything the terminal displays. A terminal has one output stream, not separate stdout and stderr.
stdin A WritableStream<Uint8Array> for what the user types, control keys included.
resize({ columns, rows }) Changes the terminal size that programs in it see.
interrupt() Interrupts the foreground program, as Ctrl+C does. The shell stays available when the program allows it.
kill(signal?, { scope? }) Sends a Linux signal, "SIGTERM" to the process by default.
exit A Promise<ProcessExit> that settles when the shell ends.
pid, kind The process ID, and "terminal".

xterm.js draws the terminal in the page. Install it with its fit addon, which sizes the terminal to its container:

Terminal window
npm install @xterm/xterm @xterm/addon-fit

This module opens a shell in a container element and returns the controls your UI needs:

terminal-view.ts
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import type { VM } from "@oxidevm/sdk";
export interface TerminalView {
interrupt(): Promise<void>;
close(): Promise<void>;
}
export async function openTerminal(vm: VM, container: HTMLElement): Promise<TerminalView> {
const term = new Terminal({ cursorBlink: true });
const fit = new FitAddon();
term.loadAddon(fit);
term.open(container);
fit.fit();
const shell = await vm.spawn({
command: "/bin/sh",
args: ["-l"],
env: { TERM: "xterm-256color" },
terminal: { columns: term.cols, rows: term.rows },
});
// Output: draw each chunk, and read the next one only after xterm has taken it.
const drawn = shell.output
.pipeTo(
new WritableStream<Uint8Array>({
write: (chunk) => new Promise<void>((resolve) => term.write(chunk, resolve)),
}),
)
.catch((error: unknown) => console.error(error));
// Input: keystrokes and pastes, kept in order.
const input = shell.stdin.getWriter();
const encoder = new TextEncoder();
let typed: Promise<void> = Promise.resolve();
const typing = term.onData((data) => {
typed = typed.then(() => input.write(encoder.encode(data))).catch((error: unknown) => console.error(error));
});
// Size: fit xterm to its container, and pass every new size to the terminal.
const resizing = term.onResize(({ cols, rows }) => {
void shell.resize({ columns: cols, rows }).catch((error: unknown) => console.error(error));
});
const observer = new ResizeObserver(() => fit.fit());
observer.observe(container);
// Tell the user when the shell ends, for example after they type `exit`.
void shell.exit.then(
(exit) => term.write(`\r\n[shell ended: ${exit.kind === "exited" ? `code ${exit.code}` : String(exit.signal ?? exit.signalNumber)}]\r\n`),
(error: unknown) => console.error(error),
);
return {
interrupt: () => shell.interrupt(),
async close() {
observer.disconnect();
typing.dispose();
resizing.dispose();
try {
await shell.kill("SIGHUP", { scope: "process-group" });
await Promise.allSettled([shell.exit, drawn, typed]);
} finally {
input.releaseLock();
term.dispose();
}
},
};
}

Each part maps a user action to the handle:

The user Your code
Types or pastes xterm’s onData gives you text; write it to stdin as bytes, in order.
Presses Ctrl+C Nothing extra. xterm sends it through onData as the byte \x03, and Linux handles it as on any terminal.
Clicks a Stop button Call interrupt().
Resizes the panel fit.fit() changes xterm’s size, and onResize passes it on with resize().
Closes the tab Call close(), below.

Waiting for xterm’s write callback before reading more output matters for programs that print a lot: the guest program waits for the screen to keep up, instead of output piling up in memory.

Call vm.spawn once for each terminal tab. Each call starts its own shell with its own PTY, and every shell runs in the same Linux system: the same /workspace, the same files and the same running programs. A file written in one terminal is there at once in the other. Don’t create a second VM for a second terminal.

main.ts
import { createVM } from "@oxidevm/sdk";
import { openTerminal } from "./terminal-view";
function pane(id: string): HTMLElement {
const element = document.getElementById(id);
if (element === null) throw new Error(`Missing #${id}`);
return element;
}
const vm = await createVM({
mode: "userspace",
workspaceId: "my-project",
runtime: { hosting: "frame" },
});
const first = await openTerminal(vm, pane("terminal-1"));
const second = await openTerminal(vm, pane("terminal-2"));
// When the user closes the whole panel:
await first.close();
await second.close();
await vm.dispose();

Closing one terminal leaves the others and the VM running. In Userspace, each shell and every program it starts count toward the VM’s process limit, limits.maxProcesses in createVM, which defaults to the image’s recommendation, or 32.

close() above sends SIGHUP to the shell’s process group: the hang-up signal a shell gets when its terminal window closes. It then waits for the shell to end and for its last output, and releases xterm. Use "SIGKILL" instead for a program that ignores SIGHUP. Sending a signal to a shell that has already exited does nothing, so close() is safe after the user typed exit.

When the page closes the whole VM, close its terminals first and then call vm.dispose(), which ends every process the VM still runs.

A restore point saves running shells with the rest of the VM. After a resume or a restore, they come back in start.terminals. Call attach() on an entry to get a handle that first replays the saved screen and then streams live output. Connect that handle to a new xterm view as above, instead of spawning a new shell, and send it the view’s current size with resize(). Handles from before a restore stop working. See Reattach terminals.

Every method and type on this page is listed in the VM reference.