Skip to content
posts

A shell in a blog post

Compiling a Go TUI to WebAssembly so a reader can drive it without installing anything.

shell — a sandboxed shell, running entirely in your browsershell

A Bubble Tea program compiled to WebAssembly. Nothing is downloaded until you start it.

~1.3 MB · runs locally · no server, no network

The terminal above is not a recording, and it is not a fake. It is a Go program — the same one that runs the terminal page — compiled to GOOS=js GOARCH=wasm and driven by xterm.js. There is no server behind it. The filesystem is a tree of structs in memory, and it disappears when you close the tab.

Why bother

The usual way to show someone a command-line tool is a screenshot, or an asciinema recording. Both are lies of omission: they show the happy path you rehearsed. What they cannot show is what happens when the reader types something you did not anticipate.

Handing over a real program removes that gap. The cost is that the reader has to install it, which almost nobody will do to satisfy mild curiosity about a blog post.

Compiling to WebAssembly collapses the cost to a click.

The part that is genuinely hard

Bubble Tea talks to a TTY. A browser does not have one. Upstream ships TTY and signal implementations for unix, windows, darwin, and plan9, and nothing for js/wasm, so the linker simply refuses:

tea.go:690:8: p.listenForResize undefined
tea.go:772:8: undefined: suspendSupported
tty.go:18:2:  undefined: suspendProcess
tty.go:28:11: p.initInput undefined

Four symbols. That is the entire distance between Bubble Tea and the browser, and the fix is stubs — there is nothing to listen to and nothing to suspend:

//go:build js && wasm

package tea

func (p *Program) initInput() error { return nil }

const suspendSupported = false

func suspendProcess() {}

The reason this works at all is that Bubble Tea already accepts tea.WithInput(io.Reader) and tea.WithOutput(io.Writer). Keystrokes arrive from JavaScript into a buffer; rendered ANSI leaves through another; Bubble Tea is happy to treat the pair as a terminal.

The failures that look like success

Most of the ways this goes wrong produce a program that compiles, boots, logs nothing, and renders either nothing or nonsense. Three that cost me real time:

Everything renders monochrome. A WASM module inherits no environment, so colorprofile.Detect finds no TERM, concludes the terminal is ASCII-only, and emits no escape sequences whatsoever. Nothing is logged. The fix is to set TERM and COLORTERM before anything touches Lip Gloss.

The terminal grows without bound. Give the host element min-height instead of a fixed height and you get a feedback loop: the frame grows the element, the fit addon reports more rows, the Go side renders a taller frame. It settles somewhere around 900 rows. No CSS trick fixes this from inside the terminal — the host element has to be told its size.

Text staircases diagonally across the screen. A line feed with no carriage return. One xterm.js option (convertEol) away from correct, and completely baffling until you know that.

What it costs

A minimal Bubble Tea and Lip Gloss program is about 5.6 MB of WebAssembly, which compresses to roughly 1.3 MB. That is far too much to put on a page someone might merely scroll past, so nothing here downloads until you press the button.

The counterintuitive part is what shrinks the finished artifact. Disabling inlining (-gcflags=all=-l) makes the .wasm bigger and the compressed download smaller, because duplicated call bodies are exactly what a compressor feeds on. Running wasm-opt -Oz does the reverse: it takes half a megabyte off the binary and adds to the download, because specialised code compresses worse.

Optimise the thing the reader actually downloads. The binary is an intermediate nobody ever sees.