Using the Emulator
This tutorial walks you through building a web-based terminal application step by step. By the end, you'll have a page with four terminals running different processes—all powered by Hex1b's terminal emulator, WebSocket streaming, and xterm.js.
What You'll Build
A web app with four independent terminals in a grid:
- Star Wars — SSH to
starwarstel.netfor the ASCII movie - CMatrix — Matrix-style falling code
- Pipes — Animated pipes screensaver
- Asciiquarium — Underwater ASCII art
Each terminal runs its own process in a PTY, streams output over WebSocket, and renders in the browser with xterm.js.
Prerequisites
- .NET 10 SDK
- Docker (for CMatrix, Pipes, Asciiquarium)
- SSH client (for Star Wars)
Step 1: Create the Project
Start with a minimal ASP.NET Core web app:
dotnet new web -n QuadTerminalDemo
cd QuadTerminalDemo
dotnet add package Hex1bStep 2: Set Up the WebSocket Backend
The backend needs to:
- Accept WebSocket connections
- Create a terminal with a PTY process
- Bridge the terminal to the WebSocket using a presentation adapter
Here's the core pattern:
using System.Net.WebSockets;
using Hex1b;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseWebSockets();
app.UseDefaultFiles();
app.UseStaticFiles();
app.Map("/ws/terminal", async context =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
await using var presentation = new WebSocketPresentationAdapter(webSocket, 80, 24);
using var terminal = Hex1bTerminal.CreateBuilder()
.WithPresentation(presentation)
.WithPtyProcess("bash", "-i")
.Build();
await terminal.RunAsync(context.RequestAborted);
});
app.Run();Let's break this down:
WebSocketPresentationAdapter
This adapter bridges Hex1bTerminal to a WebSocket:
- Sends terminal output (ANSI sequences) to the WebSocket as text messages
- Receives user input from the WebSocket and forwards to the terminal
- Handles resize messages (JSON) to resize the PTY
WithPtyProcess
Creates a pseudo-terminal (PTY) and runs the specified command inside it. The PTY provides:
- Proper terminal semantics (line discipline, signals)
- Correct handling of interactive programs
- Support for terminal resize
Step 3: Create the Frontend
Create wwwroot/index.html with xterm.js:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Terminal Demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
html, body { height: 100%; margin: 0; background: #0a0a12; }
#terminal { height: 100%; padding: 8px; }
</style>
</head>
<body>
<div id="terminal"></div>
<script type="module">
import { Terminal } from 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/+esm';
import { FitAddon } from 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/+esm';
const term = new Terminal({ cursorBlink: true });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
const ws = new WebSocket(`ws://${location.host}/ws/terminal`);
// Terminal output from server
ws.onmessage = e => term.write(e.data);
// Send initial size on connect
ws.onopen = () => ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows
}));
// User input to server
term.onData(data => ws.send(data));
// Handle resize
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
window.onresize = () => fitAddon.fit();
</script>
</body>
</html>Key points:
- FitAddon automatically sizes the terminal to fill its container
- onResize fires when terminal dimensions change (after
fitAddon.fit()) - Resize messages are JSON:
{ "type": "resize", "cols": 80, "rows": 24 }
Step 4: Add Multiple Terminals
Now let's scale to four terminals with different processes.
Update Program.cs
Create a helper function and add endpoints for each terminal type:
using System.Net.WebSockets;
using Hex1b;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseWebSockets();
app.UseDefaultFiles();
app.UseStaticFiles();
// Helper to create WebSocket terminal endpoint
async Task HandleTerminal(HttpContext context, params string[] command)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
await using var presentation = new WebSocketPresentationAdapter(webSocket, 80, 24);
using var terminal = Hex1bTerminal.CreateBuilder()
.WithPresentation(presentation)
.WithPtyProcess(command[0], command[1..])
.Build();
await terminal.RunAsync(context.RequestAborted);
}
// Star Wars ASCII movie via SSH
app.Map("/ws/starwars", ctx => HandleTerminal(ctx, "ssh", "starwarstel.net"));
// CMatrix - Matrix-style falling code
app.Map("/ws/cmatrix", ctx => HandleTerminal(ctx,
"docker", "run", "-it", "--rm", "--log-driver", "none",
"--net", "none", "--read-only", "--cap-drop=ALL", "willh/cmatrix"));
// Pipes - animated pipes screensaver
app.Map("/ws/pipes", ctx => HandleTerminal(ctx, "docker", "run", "--rm", "-it", "joonas/pipes.sh"));
// Asciiquarium - underwater ASCII art
app.Map("/ws/asciiquarium", ctx => HandleTerminal(ctx, "docker", "run", "-it", "--rm", "vanessa/asciiquarium"));
app.Run();Update wwwroot/index.html
Create a grid layout with four terminal panes:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Quad Terminal Demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; background: #0a0a12; overflow: hidden; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 100%;
gap: 8px;
padding: 8px;
}
.terminal-pane {
background: #0f0f1a;
overflow: hidden;
border-radius: 6px;
padding: 8px;
}
.terminal-pane .label {
color: #666;
font-family: monospace;
font-size: 11px;
padding-bottom: 4px;
}
</style>
</head>
<body>
<div class="grid">
<div id="term1" class="terminal-pane"><div class="label">Star Wars (SSH)</div></div>
<div id="term2" class="terminal-pane"><div class="label">CMatrix</div></div>
<div id="term3" class="terminal-pane"><div class="label">Pipes</div></div>
<div id="term4" class="terminal-pane"><div class="label">Asciiquarium</div></div>
</div>
<script type="module">
import { Terminal } from 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/+esm';
import { FitAddon } from 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/+esm';
const theme = { background: '#0f0f1a', foreground: '#e0e0e0' };
const terminals = [];
function createTerminal(containerId, endpoint) {
const container = document.getElementById(containerId);
const termDiv = document.createElement('div');
termDiv.style.height = 'calc(100% - 20px)';
container.appendChild(termDiv);
const term = new Terminal({ cursorBlink: true, theme });
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(termDiv);
fitAddon.fit();
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${endpoint}`);
ws.onmessage = e => term.write(e.data);
ws.onopen = () => ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
term.onData(data => ws.send(data));
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
return { term, fitAddon, ws };
}
terminals.push(createTerminal('term1', '/ws/starwars'));
terminals.push(createTerminal('term2', '/ws/cmatrix'));
terminals.push(createTerminal('term3', '/ws/pipes'));
terminals.push(createTerminal('term4', '/ws/asciiquarium'));
window.onresize = () => terminals.forEach(({ fitAddon }) => fitAddon.fit());
</script>
</body>
</html>Step 5: Run It
dotnet runOpen http://localhost:5000 and you'll see four terminals, each running a different process. Resize the browser window and all terminals resize together.
How It Works
The architecture:
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ xterm.js │ │ xterm.js │ │ xterm.js │ ... │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼────────────────┘
│ WebSocket │ WebSocket │ WebSocket
┌─────────┼────────────────┼────────────────┼────────────────┐
│ ▼ ▼ ▼ ASP.NET │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ WebSocket │ │ WebSocket │ │ WebSocket │ │
│ │ Presentation│ │ Presentation│ │ Presentation│ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ Hex1bTerminal│ │ Hex1bTerminal│ │ Hex1bTerminal│ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ PTY │ │ PTY │ │ PTY │ │
│ │ Process │ │ Process │ │ Process │ │
│ │ (ssh) │ │ (docker) │ │ (docker) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘Each WebSocket connection gets:
- Its own
WebSocketPresentationAdapter— bridges terminal ↔ WebSocket - Its own
Hex1bTerminal— processes ANSI/VT sequences, maintains screen buffer - Its own PTY process — runs the actual command
The terminals are completely independent—each has its own process, its own screen buffer, and its own WebSocket connection.
Next Steps
- Presentation Adapters — Build custom adapters for other UIs
- Workload Adapters — Run TUI apps instead of child processes
- Terminal Emulator — Deep dive into terminal capabilities