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:
import { Program, KeyPressMsg, Quit, type Model, type Msg, type Cmd } from '@oakoliver/bubbletea';
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
New to it? Start with Getting Started with Bubble Tea.
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.
FAQ
Is there a TypeScript TUI framework like Go's Bubble Tea?
Yes: @oakoliver/bubbletea is a port of Bubble Tea itself, not a look-alike. You write a model with init, update and view, and the framework handles raw mode, keyboard and mouse input, the 60fps renderer and process signals. It has zero runtime dependencies. Ready-made components (text inputs, lists, tables, spinners) come from @oakoliver/bubbles, and styling from @oakoliver/lipgloss.
Does it run on Bun as well as Node.js?
Yes. The package works on both, with no native addons.
How does a TypeScript port replace goroutines and channels?
With an async event loop over a promise-based message queue (section III). Every message, whether from input, a command, a signal or send(), is pushed onto the queue and wakes the loop. Commands are async functions started without await, so they run concurrently the way goroutines do; Batch starts them all at once and Sequence awaits each before the next.
The TypeScript Charm Series
Each package is a TypeScript port of one of Charm's Go tools, published on npm and running on Node.js and Bun.
| Package | What it does | Start here | How it was ported | Source |
|---|---|---|---|---|
| @oakoliver/lipgloss | Terminal styling | Getting started | Porting story | GitHub |
| @oakoliver/glamour | Markdown rendering | Getting started | Porting story | GitHub |
| @oakoliver/bubbletea | TUI framework | Getting started | this article | GitHub |
| @oakoliver/bubbles | TUI components | Getting started | Porting story | GitHub |
| @oakoliver/huh | Terminal forms | Getting started | Porting story | GitHub |
| @oakoliver/glow | Markdown reader | Getting started | Porting story | GitHub |
| @oakoliver/gum | Shell script prompts | Getting started | — | GitHub |
| @oakoliver/vhs | Terminal GIFs | Getting started | — | GitHub |
– Antonio