Porting Go's Bubblezone to TypeScript: Mouse Hit-Testing for Bubble Tea
A terminal tells your program that the mouse was released at column 31, row 7. That's all. Whether column 31, row 7 is the Save button, a tab, or the gap between two panels is something your program has to work out, and in Bubble Tea your view() returns one string built from nested, padded, joined, wrapped boxes. Working backwards from that string to "which box was this?" is the kind of arithmetic that breaks the day someone adds a border.
Liam Stanley's bubblezone avoids the arithmetic. You wrap each clickable thing in a name while you draw it, and a single scan of the finished view tells you where each name ended up. It's 720 lines of Go and it's the library that makes mouse support in a Bubble Tea app tolerable.
Now it's available in TypeScript. It's the ninth package in the series, and the first that wasn't written by Charm.
I – What Shipped
@oakoliver/bubblezone 1.0.0, a port of lrstanley/bubblezone at commit d3a8f5f. That's master, eight commits after the v2.0.0 tag; the Go sources haven't changed since the tag, only CI, lint and docs. It targets Bubble Tea v2, which @oakoliver/bubbletea 1.2.x matches.
npm install @oakoliver/bubblezone @oakoliver/bubbletea
import * as zone from '@oakoliver/bubblezone';
import { newStyle } from '@oakoliver/lipgloss';
zone.NewGlobal();
const button = newStyle().padding(0, 3).render('Save');
const view = zone.scan('\n ' + zone.mark('save', button) + '\n'); // the string to show
const save = zone.get('save');
console.log(save.startX, save.startY, save.endX, save.endY); // 2 1 11 1
console.log(save.inBounds({ x: 5, y: 1 }), save.pos({ x: 5, y: 1 })); // true [ 3, 0 ]
549 lines of TypeScript in 7 files. 122 tests. No runtime dependencies: Bubble Tea is a peer dependency (the single function the package imports from it is Batch), Lip Gloss an optional one. The ESM build is 11.1 kB (3.8 kB gzipped) and the CommonJS build 12.8 kB.
II – The Idea
The trick is small enough to describe in one sentence: put invisible characters around a piece of text before it's drawn, then find them in the finished output.
What makes the characters invisible is a corner of the ANSI standard. A control sequence that starts with ESC [ and ends with a byte from p to ~ is "private": terminals are free to give it a meaning of their own, and most ignore it. Bubblezone uses ESC [ 1001 z, then 1002 z, and so on, one number per name. Because it is an escape sequence, Lip Gloss's width functions skip it, so a button wrapped in markers is still exactly as wide as the button:
flowchart LR
A["mark('save', button)"] --> B["ESC[1001z + button + ESC[1001z"]
B --> C["joined, padded, bordered, wrapped by Lip Gloss"]
C --> D["scan(view)"]
D --> E["view without markers"]
D --> F["zone 'save': startX, startY, endX, endY"]
F --> G["get('save').inBounds(mouseMsg)"]scan() walks the finished string. It counts newlines for the row and measures the printable width since the last newline for the column. The first time it meets a marker it records a start; the second time, an end. Everything between the markers is the zone, and the zone is the box from the start cell to the end cell.
for (let m = tokens.exec(input); m !== null; m = tokens.exec(input)) {
line += input.slice(last, m.index);
last = m.index + m[0].length;
if (m[0] === '\n') { out += line + '\n'; line = ''; rows++; continue; }
const open = tracked.get(m[0]);
if (open) {
open.endX = printableWidth(line) - 1; // the last cell of the zone, not one past it
open.endY = rows;
host.emit(open);
tracked.delete(m[0]);
} else {
tracked.set(m[0], new ZoneInfo({ /* … */ startX: printableWidth(line), startY: rows }));
}
}
That's the core of src/scanner.ts. In the Go original it's a hand-written state machine, scanMain and scanID, stepping through runes; in TypeScript a regular expression that matches either a newline or ESC [ <digits> z does the same job in one pass over the string.
III – The Hard Parts
A goroutine that wasn't needed
Upstream's Scan doesn't store the zones it finds. It sends each one down a buffered channel to a worker goroutine, which writes them into a map. The docs say so: "Scan buffers the zone info to be stored, so an immediate call to Get(id) may not return the correct information." The tests live with it. Upstream's three test files call time.Sleep 12 times, 11 of them for 100 ms and one, inside the scan table, for 15 ms on each of 16 cases: 1,340 ms of sleeping in a test run that takes 1.4 s.
I measured how stale Get is. In the Go original, a loop of 2,000 Scan calls, each followed immediately by Get for the zone it had just scanned, got a zero zone back 2,000 times in four runs and 1,999 times in the fifth, on an otherwise quiet machine. The worker hadn't run yet.
JavaScript has no second thread in the way. The port stores the zones before scan() returns, so get() straight after scan() is correct, none of the tests sleep, and close(), which in Go stops the worker, only has to turn storing off. The cost is that the scan holds the event loop for as long as it runs; section VII has the numbers. The ordering rule upstream keeps is kept here too: zones from one scan are stored in order, so when a name is marked twice the last one wins, and zones that weren't seen in the latest scan are dropped.
Measuring the text in front of a marker
A zone's column is the printable width of everything on its line before the marker. Upstream does that with a loop that skips ANSI sequences and asks go-runewidth for the width of every other rune. The skipping rule ends a sequence at the first letter it meets. That works for color codes, ESC [ 38;2;1;2;3 m, but it's wrong for a hyperlink, ESC ] 8 ; ; https://example.com/path BEL, which has letters in the URL.
I ran upstream's own width function on ESC ] 8 ; ; https://example.com/path BEL link ESC ] 8 ; ; BEL. The visible text is "link", 4 cells. It returned 27. Any zone to the right of a hyperlink on the same line would be found 23 cells too far to the right.
printableWidth() in this port treats OSC, DCS, APC, PM and SOS strings as strings: it skips them up to BEL or ST. Everything else is as upstream: control characters are zero wide, combining marks zero, East Asian wide characters and emoji with emoji presentation two, and the count is per code point. That last part is a known gap: an emoji built with a zero-width joiner counts as the sum of its parts. On the samples in the tests (plain text, color codes, CJK, box-drawing characters, Cyrillic and Greek, hyperlinks) it agrees with Lip Gloss's stringWidth every time.
A nil that still answers
Go lets you call a method on a nil pointer, and upstream relies on it: Get returns nil for an unknown name, and zone.Get("x").InBounds(msg) still works because InBounds checks its receiver. In TypeScript, null.inBounds() throws. get() returns a zero ZoneInfo instead: isZero() is true, inBounds() is false and pos() is [-1, -1]. The README's one-liner works unchanged.
One copy of Bubble Tea
anyInBoundsAndUpdate() combines the commands that come back from each update() with Bubble Tea's Batch. Bubble Tea's program recognises a batch with instanceof BatchMsg, so the Batch has to come from the same copy of Bubble Tea as the program. If bubblezone bundled its own, it would work in the tests and silently fail in an app.
I checked what silently fails means. With a Batch imported from a second copy of the same package, a program's init() returned two commands, and neither ever ran: update() received the BatchMsg itself, as an ordinary message, 1.5 seconds passed and no message from either command arrived. The package builds with --external:@oakoliver/bubbletea and declares it a peer dependency. The same reason is behind the peer dependencies of Bubbles.
What the compositor does to markers
Upstream's README has a caution box: "bubblezone v2 may not work when using the lipgloss v2 canvas/compositor". It doesn't say what happens. I tested it: marker characters put inside a Lip Gloss layer don't survive newCompositor(...).render(). The text is there, the markers are gone, and get() returns a zero zone. The port cannot fix this from the outside, and there is a test that documents it. The examples work around it by putting dialogs over the page with a small splice() function that cuts and joins lines, and by drawing the page under a dialog without zones.
The other cuts are better than the warning suggests. maxWidth(), truncate() and sliceAnsi() keep the markers where they were, so a cut zone shrinks to what's visible, and a zone cut off completely becomes empty. Wrapping is the real limit: a zone that wraps onto a second line ends to the left of where it starts, and a box with its end before its start contains nothing.
IV – Test Parity
Upstream has three test files. All of them are ported:
| Upstream | Here | Tests |
|---|---|---|
manager_test.go: the scan table (37 cases, including nested, repeated and malformed markers), the same table with the manager disabled, Mark, Clear, Close, worker clearing, global init, FuzzScan |
tests/manager.test.ts |
90 |
zoneinfo_test.go: position, bounds, the zero zone, Pos |
tests/zoneinfo.test.ts |
10 |
messages_test.go: AnyInBounds, AnyInBoundsAndUpdate |
tests/messages.test.ts |
6 |
Tests that upstream doesn't have: tests/lipgloss.test.ts (9; layout through joinHorizontal, borders, place, wrapping, cutting, and the compositor), tests/width.test.ts (6; hyperlinks and wide characters), and tests/readme.test.ts (1; the README's Quick Start, run as written). 122 in total.
Upstream's go test -v prints 163 passing lines, counting subtests and the 74 fuzz seeds. The fuzz target itself, FuzzScan, needs Go's fuzzer; here it's a seeded random-input test that scans 2,000 strings made of escape fragments, digits, newlines and multi-byte characters, and checks that none throws and none grows. Upstream's Go benchmarks are replaced by bench/scan.ts.
A few of the tests needed changes that aren't cosmetic. Upstream's TestClose waits for the worker to stop and checks that nothing is stored; here, close() is a flag, and the test checks the same observable thing. The AnyInBounds test uses a goroutine and a channel to wait for delivery; here the call is synchronous and the test just looks at the model.
V – Where It Differs
The README in the repository has the complete list. These are the differences that can surprise someone coming from the Go version:
scan()is synchronous, as described above.get()never returns null. CheckisZero().- Global
setEnabled(false)clears the zones. Upstream's package-level function only flips a flag, so zones stay stored; its method on a manager clears them. I ran both. The port uses the manager's behavior for both. - A marker that this manager didn't create is stripped and ignored. Upstream stores it under the empty name: I scanned a view marked by one manager with another and
Get("")found a zone. printableWidth()handles hyperlinks. 27 against 4, above.inBounds()andpos()also take a plain{ x, y }, which makes tests short, andZoneInfohasid,widthandheight.- Naming. Package functions exist in both spellings,
Markas in Go andmark; methods and fields are camelCase.
Not ported: upstream's _examples (replaced by the ones below), its fuzz target and benchmarks (replaced, as above), and its repository tooling. Nothing in the public API is missing.
VI – Zones Doing the Work
A library like this is only believable on programs that are bigger than a button, so there are two full-screen examples in the repository, at the level of the larger Bubble Tea examples.

examples/files.ts is a file manager on an invented file system. The tabs, the path segments, the sidebar places, the three sortable headers, every row, the preview buttons, the context menu and the dialog buttons are all zones, inside bordered panes with wrapped text. Double-click, right-click and the wheel work.

examples/console.ts is a dashboard where each card is a zone with buttons nested inside it. A click on + lands in two zones, the card and the button, and anyInBoundsAndUpdate() hands both to the program in alphabetical order of their names.
Both recordings are made without a mouse. VHS, the tool that records the GIFs for all of these ports, has no mouse commands (and its scroll commands didn't arrive as wheel events in the earlier ports). So every example takes --demo: a script sends real MouseMotionMsg, MouseClickMsg, MouseReleaseMsg and MouseWheelMsg values to the running program, and it picks each target by asking the zone manager where a named zone is and aiming at its middle. The click goes through the same inBounds() check as a real one. A yellow cell is the scripted pointer, and a badge in the last row says demo: scripted mouse.
What --demo doesn't test is the terminal's side: how clicks are encoded. For that, I ran the four programs from the getting-started guide in a pseudo-terminal, wrote real SGR mouse sequences (ESC [ < 0 ; 6 ; 2 M and the matching m for a release) to it, and read the screen back through a terminal emulator library. 18 checks, on Bun 1.4, Node 20, 22 and 26, and Deno 2.8.
VII – What It Costs
scan() runs on every frame, so it has to be cheap. These numbers are from an Apple M5 Max, alone on the machine (the lock that queues timing runs on this one checked that it was quiet), with Bun 1.4.4. The views are built with Lip Gloss: a rounded border, styled labels, and a marked zone per label. Each figure is the median of five rounds of thousands of calls.
| View | Zones | Size of the view | scan() |
The same view with no markers |
|---|---|---|---|---|
| 80×24 | 10 | 3,673 bytes | 4.8 µs | 2.0 µs |
| 120×40 | 60 | 9,921 bytes | 24.0 µs | 4.8 µs |
| 200×60 | 600 | 40,889 bytes | 436.6 µs | 15.7 µs |
The last column is a scan of the same view with no markers in it, so it's the price of walking the string. A screen with 10 zones costs about 5 µs per frame, far below the 16.7 ms of a 60 fps frame, and an absurd 600-zone screen costs 0.44 ms.
Test time is where removing the goroutine shows:
| Upstream (Go 1.26.4) | This port (Bun 1.4.4) | |
|---|---|---|
| Whole test suite | 1.39 s, 1.39 s, 1.39 s, 1.39 s, 1.40 s | 19–22 ms (122 tests) |
| Sleeps written into the tests | 1.34 s in 12 calls to time.Sleep (adding up the arguments) |
none |
Each figure is five runs of the whole suite, after the Go test binary was built, so neither includes compiling. The Go suite is not slow because Go is: it waits, because it has to wait for its own worker.
Package size: the ESM build is 11.1 kB (3.8 kB gzipped), the CommonJS build 12.8 kB, and both leave @oakoliver/bubbletea for your application to provide.
VIII – What's Next
Bubble Tea v2 has native mouse hooks too (View.onMouse), and upstream's README says its author plans a separate library for layered layouts with better mouse tracking. If that appears, this port will follow it.
To start using zones, see Getting Started with Bubblezone.
The TypeScript Charm Series
Each package is a TypeScript port of one of Charm's Go tools (or, for bubblezone, of a library built on them), 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 | Porting story | GitHub |
| @oakoliver/bubbles | TUI components | Getting started | Porting story | GitHub |
| @oakoliver/bubblezone | Mouse zones | Getting started | this article | 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