The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.
Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:
config export the config, plus limits like max_tiles
config apply (stdin) merge cameras/layouts/active_layout, validate, save
atomically, reload the daemon
discover --json Protect discovery, writing nothing
Two properties of that split are deliberate:
- The controller password never crosses the bridge. It's json:"-" on the way
out, and apply only merges the three keys the TUI edits, so it can't be
clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
no longer overwrite a `views import`, a `layout set`, or a hand edit made in
the meantime.
Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.
Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.
Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.
The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.
Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.
Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rtsp-streamer configurator (opentui)
The interactive configurator, built with opentui. It
replaces the Bubble Tea TUI that lived in internal/tui.
Launch it the usual way:
rtsp-streamer tui
Why this is a separate executable
opentui is a native core written in Zig with TypeScript bindings — there are
no Go bindings. So this half of the tool is TypeScript compiled by Bun, and it
ships as rtsp-streamer-tui next to the Go binary. rtsp-streamer tui finds and
execs it.
The trade-off is size: the Go binary is ~13 MB, and this one is ~120 MB because
Bun embeds its own runtime plus opentui's native library. Nothing needs to be
installed on the target, but it is a large artifact for an SD card. (make tui-pi
comes out ~108 MB — defining OPENTUI_LIBC lets Bun embed only the glibc native
package instead of both glibc and musl.)
If it is missing or misbehaving, the previous Go implementation is still there:
rtsp-streamer tui --legacy
Division of labour
Everything that is not presentation stays in Go. This process holds no
credentials and never writes the config file itself — it shells back to
rtsp-streamer for three things:
| Command | Purpose |
|---|---|
config export |
read the config, plus limits like max_tiles |
config apply (JSON on stdin) |
merge edits, validate, save atomically, reload the daemon |
discover --json [--enable-rtsp=high,low] |
Protect discovery, writing nothing |
Two properties of that split are worth keeping:
- The controller password never crosses the bridge. It is
json:"-"on the way out, andconfig applyonly mergescameras,layoutsandactive_layout, so it cannot be clobbered on the way back in either. config applyre-reads the file before merging. A configurator left open for an hour can no longer overwrite aviews import, alayout set, or a hand edit made in the meantime — it only replaces the keys it owns.
Discovery is previewed rather than applied: discover --json writes nothing, the
merge happens here, and nothing reaches disk until you press Save.
Layout
| Path | Contents |
|---|---|
src/index.ts |
entry point: arg parsing, renderer setup, event loop |
src/keys.ts |
keyboard routing (bindings carried over from the Go version) |
src/state.ts |
the store — all state and mutations, ported from internal/tui |
src/ui.ts |
renders the store into an opentui tree; grid mouse handling |
src/bridge.ts |
subprocess calls into the Go binary |
src/types.ts |
config schema + geometry helpers mirroring internal/config |
src/theme.ts |
the lipgloss palette, resolved to hex |
src/fixtures.ts |
sample config used by tests and the preview script |
scripts/preview.ts |
render every screen to stdout as text |
Working on it
make run-tui # run from source against /tmp/rtsp-streamer.yaml, no compile
make test-tui # bun test + tsc --noEmit
make tui # compile bin/rtsp-streamer-tui for this machine
make tui-pi # cross-compile bin/rtsp-streamer-tui-arm64 for a 64-bit Pi
bun run scripts/preview.ts # dump every screen as text, no terminal needed
preview.ts is the quickest way to see a layout change — it uses opentui's test
renderer, so it prints the screens without needing a TTY.
Two things to know before changing the grid editor
Both were bugs during the port, and neither is obvious from the code:
Do not overlap cell borders. Sharing a border between neighbouring cells
looks like it should work, but a box draws its own corners, so every shared edge
renders as ┌ where a lattice needs ┬. Each cell is a self-contained box that
tiles edge-to-edge.
Do not rebuild the tree during a drag. The renderer captures the renderable
a drag started on and routes the rest of the gesture to it. render() destroys
and recreates the whole subtree, so a rebuild mid-drag silently ends the drag
after the first resize. Instead, suppressRender is set for the duration and the
dragged tile's box is resized in place, with one full rebuild on release. For the
same reason the grid's mouse handlers live on the persistent mount box, not on
the grid box, and the grid's screen origin is captured on press rather than read
live (a rebuilt box has no computed layout until the next frame, so screenX
reads as 0).