Porting Go's Bubble Tea to TypeScript: An Elm Architecture TUI Framework for Node.js
Bubble Tea is the library that makes Go's terminal UI ecosystem actually work. Lipgloss gives you styled text. Glamour gives you markdown rendering. But Bubble Tea is what turns them from output formatters into interactive applications.
It's an Elm Architecture framework for the terminal. Your application is a model. Messages flow in. The model updates. A view renders. That's it. The framework handles raw mode, signal handling, input parsing, screen management, and rendering — you just write init, update, and view.
TypeScript developers have had nothing like this. Until now.
I – What Shipped
@oakoliver/bubbletea — an Elm Architecture TUI framework for TypeScript. Event loop, keyboard/mouse input parser, standard renderer with 60fps flushing, batch and sequential command execution, AbortSignal integration, raw mode management, and full process signal handling. 46 tests, full parity with Go's tea_test.go.
npm install @oakoliver/bubbletea
Zero dependencies. Works on Node.js and Bun.
This is the third of five Charmbracelet ports. Lipgloss and Glamour are already published. Bubbles and Glow follow.
II – The Elm Architecture in a Terminal
The Elm Architecture is the best pattern for interactive applications with complex state. React borrowed from it. Elm proved it. Charmbracelet brought it to terminals.
Every Bubble Tea program has three methods:
class App implements Model {
init(): Cmd {
// Run an initial command (or null)
return null;
}
update(msg: Msg): [Model, Cmd] {
// Respond to messages, return new state + optional command
if (msg instanceof KeyPressMsg && msg.toString() === 'q') {
return [this, Quit];
}
return [this, null];
}
view(): string {
// Render the current state as a string
return 'Press q to quit.';
}
}
const p = new Program(new App());
await p.run();
The framework manages everything else: terminal raw mode, input reading, ANSI rendering, cursor management, signal handling, screen clearing, and the event loop that ties it all together.
III – Translating Go's Concurrency to TypeScript
This was the hard problem. Go's Bubble Tea is built on goroutines and channels. TypeScript has neither.
Go's Program.Run() starts a goroutine for the event loop, one for the renderer ticker, one for input reading, and spawns goroutines for every command. The event loop is a select statement reading from a msgs channel. Commands write results back to that channel.
In TypeScript, this becomes an async event loop with a Promise-based message queue:
private async _dequeueMsg(): Promise<Msg | undefined> {
while (this._msgQueue.length === 0 && !this._killed) {
await new Promise<void>((resolve) => {
this._msgResolve = resolve;
});
}
return this._msgQueue.shift();
}
When a message arrives — from input, a command, a signal, or an external send() call — _enqueueMsg pushes it onto the array and resolves the pending promise. The event loop wakes up, processes the message, and awaits again.
Commands are fire-and-forget async functions, equivalent to goroutines:
private _executeCmd(cmd: Cmd): void {
const run = async () => {
const result = cmd();
const msg = result instanceof Promise ? await result : result;
this.send(msg);
};
run(); // no await — runs concurrently
}
Batch fires all commands concurrently. Sequence awaits each one before running the next. This matches Go's BatchMsg and sequenceMsg semantics exactly.
IV – The Input Parser
Terminal input is a mess. Different terminals send different byte sequences for the same key. Bubble Tea needs to normalize all of this.
The input parser handles:
- ASCII printable characters (0x20–0x7E) — single bytes to
KeyPressMsg - Control characters (0x01–0x1A) — Ctrl+A through Ctrl+Z
- CSI sequences (ESC [ ...) — arrow keys, function keys, home/end, insert/delete, modifiers
- SS3 sequences (ESC O ...) — F1–F4 in some terminals
- Tilde sequences (ESC [ N ~) — F5–F12, insert, delete, page up/down
- Alt+key (ESC followed by a character)
- SGR mouse (ESC [ < ... M/m) — click, release, wheel, motion with coordinates
- Focus events (ESC [ I / ESC [ O)
- Bracketed paste (ESC [ 200~ ... ESC [ 201~)
- UTF-8 multi-byte — 2, 3, and 4-byte sequences
All of this happens in 614 lines of input.ts, with no dependencies and no parser generators. Just byte-by-byte state transitions.
V – The Renderer
Go's Bubble Tea renderer runs on a ticker goroutine. Every frame (default: 1/60th of a second), it checks if the view has changed and flushes to the terminal.
The TypeScript port uses setInterval for the same effect:
private _startRenderer(): void {
this._renderer.start();
const frameInterval = Math.round(1000 / this._fps);
this._renderTimer = setInterval(() => {
this._renderer.flush(false);
}, frameInterval);
this._renderTimer.unref(); // don't keep the process alive
}
The unref() call is important — without it, the interval timer would prevent Node.js from exiting even after the program has quit.
The StandardRenderer tracks the previous frame's output, computes the diff, and only sends the ANSI escape sequences needed to update what changed. It handles alt screen mode, cursor visibility, screen clearing, and line insertions above the viewport (for log messages during a running TUI).
VI – The Abort Signal Bug
Every project has a bug that teaches you something. This one's was in signal handling.
Three tests were timing out: TestTeaContext, TestTeaContextImplodeDeadlock, and TestTeaContextBatchDeadlock. All three tested AbortSignal cancellation — passing an AbortController.signal to the program and aborting it externally.
The debugging traced through the message queue, the event loop, the kill path. Everything looked correct. The abort handler set _killed = true, resolved the pending promise, the event loop should break.
Except _killed was staying false. The abort handler was never firing.
The root cause was one line:
private _setupSignalHandlers(): void {
if (this._disableSignalHandler) return; // <-- this
// ... sets up SIGINT, SIGTERM, SIGWINCH, and AbortSignal
}
The test used WithoutSignalHandler() to disable SIGINT/SIGTERM handling (standard practice in headless test mode). But this guard also prevented the AbortSignal handler from being registered.
In Go's bubbletea, WithoutSignalHandler only disables process signals. Context cancellation is set up separately — it's independent.
The fix separates the two concerns: process signals are gated by the flag, the AbortSignal handler always registers. Three lines changed. All 46 tests pass.
VII – The Full Stack Takes Shape
Three of five packages are now live:
| Package | What | Tests |
|---|---|---|
| @oakoliver/lipgloss | Style definitions | 328 tests, 595 expects |
| @oakoliver/glamour | Markdown rendering | 317 tests, 688 expects |
| @oakoliver/bubbletea | TUI framework | 46 tests, 73 expects |
What remains:
Bubbles (Phase 4) — Pre-built components. Text inputs, spinners, progress bars, viewports, lists, tables, file pickers. Each is a Bubble Tea model. This is where the ecosystem becomes practical — you stop building everything from scratch and start composing.
Glow (Phase 5) — A CLI markdown reader. The capstone: Glamour for rendering, Lipgloss for chrome, Bubble Tea for interaction, Bubbles for the viewport. If this works as well as the Go version, TypeScript will have a first-class terminal markdown experience.
VIII – Get It
npm install @oakoliver/bubbletea
Source code: github.com/oakoliver/bubbletea
MIT licensed with attribution to Charmbracelet, Inc. Original Go library: Bubble Tea by Charmbracelet.
691 tests across three packages. Zero dependencies. The Elm Architecture for your terminal.
– Antonio