A shell in a blog post
Compiling a Go TUI to WebAssembly so a reader can drive it without installing anything.
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.