Skip to content

Native tabs

Most of what’s interesting about tmnl is in this page.

tmnl welcome → launching mnml as a native tab → Cmd+T opens another → Cmd+1/2 switches between them

A regular terminal is a byte sink for ANSI escape codes. The app inside writes \x1b[31mhi\x1b[0m, the terminal parses those bytes back into “set fg to red, emit h, emit i, reset”. Both sides have a cell grid in their head; they just communicate by serialising it and re-parsing.

A native tab is a tab where that round-trip doesn’t happen. The hosted app connects to tmnl over a Unix socket and ships typed Frame messages — { cells: [Cell { ch, fg, bg, attrs }, …] } — directly into the same Grid the GPU pipeline draws. No vt100. No escape codes. The cells the app intends are the cells that hit the screen.

Native and shell tabs live in the same window. You can have a shell tab next to a mnml tab next to a mixr tab; switch between them with Cmd+1..9; close them with Cmd+W. Native is just another kind of tab.

Concretely, a native tab is one process pair:

  • tmnl (server) owns the window, the wgpu cell pipeline, the glyph atlas, and a Grid of (ch, fg, bg, attrs) cells for the tab.
  • The backing app (client) is a separate process, launched by tmnl, that speaks the tmnl-protocol wire format over a Unix-domain socket.

The traffic on the socket is small and explicit:

| Message | Direction | Purpose | | --- | --- | --- | | Hello { version, caps } | both | Handshake; current version is 7. caps is an opt-in capability bitmask. | | Resize { cols, rows } | tmnl → app | Grid dimensions; sent on connect and on every resize. | | Frame { cols, rows, cursor, runs: [DiffRun] } | app → tmnl | One frame of cells, optionally partial. | | Input(Key \| Mouse) | tmnl → app | Keyboard / mouse, typed and decoded already. | | Title(String) | app → tmnl | Set the tab-chip label. | | Palette { bg, fg, accent } | tmnl → app | Tmnl’s chrome colors so the app can re-theme to match. | | OpenPane { command, args } | app → tmnl | Ask tmnl to open a sibling native tab (e.g. mnml’s mixr.show). | | OpenPaneTransfer { command, args } | app → tmnl | Same shape as OpenPane, but with a pty master fd attached via SCM_RIGHTS. | | OpenFile { path } | app → tmnl | Ask the host to open path in its editor (e.g. mnml). | | RunHostCommand(id) | app → tmnl | Fire a command from tmnl’s registry by id. | | ListClientCommands / ClientCommands / RunClientCommand(id) | both | Round-trip to aggregate the hosted app’s command registry into tmnl’s palette. Gated on Caps::CLIENT_COMMANDS. | | InlineImageCreate / InlineImageUpdate / InlineImageDestroy | app → tmnl | Overlay PNG images at cell coordinates inside the pane. Added in v7. Gated on Caps::INLINE_IMAGES. See SDK — Inline images. | | Quit | tmnl → app | Tear-down; sent on shutdown. |

A Frame carries a list of DiffRun { start, cells: [WireCell] } — runs of consecutive cells starting at a flat (row * cols + col) offset. Sending one giant run that covers the whole grid is fine for a first cut (that’s what hello_client.rs does). Once your app knows which cells changed, you ship only those — the protocol is fine with hundreds of small runs per frame.

The cursor lives on the Frame: cursor_col, cursor_row, cursor_shape (0 block, 1 underline, 2 bar), cursor_visible. Tmnl overlays it on the correct cell during render — the app doesn’t draw the cursor glyph.

Two practical wins, one design one.

Crisp text, GPU-native. Cells go from the app’s source-of-truth straight to a wgpu glyph atlas. No intermediate byte stream means no vt100 ambiguity (wide chars, malformed escapes, OSC parsing edge cases), no double-buffered terminal-state machine, and no surprises at the seam between what the app intended and what the terminal painted.

Resize is just a number. When the window resizes, tmnl sends one Resize { cols, rows } message. The app re-lays out for the new size and ships the next Frame. There’s no SIGWINCH plumbing, no ioctl(TIOCSWINSZ), no “wait for the inner app to notice the size has changed and stop drawing into nowhere”. It’s an inline message on the same socket.

The terminal stops being a translator. Every TUI today thinks in cells, then serialises to ANSI, then a terminal parses the ANSI back into cells. Native mode skips both halves. For a ratatui-shaped app (which already has a Buffer of cells internally) the leap is one socket and a BlitBackend that ships its frames over the wire instead of through stdout.

Two CLI flags spawn a native tab at startup:

Terminal window
tmnl --mnml [WORKSPACE] # opens mnml as a native tab
tmnl --mixr [ARGS…] # opens mixr as a native tab

What happens under the hood (see src/main.rs around which_app and editor_template):

  1. tmnl mints a Unix socket path — <tempdir>/tmnl-<pid>.sock for the first tab, <tempdir>/tmnl-<pid>-<nonce>.sock for additional ones.
  2. It binds the socket (the Server in src/server.rs) and starts listening.
  3. It walks up its own exe’s parent dirs looking for a sibling repo’s binary — <…>/mnml/target/{release,debug}/mnml or <…>/mixr-rs/target/{release,debug}/mixr. The TMNL_LAUNCH_CMD env var overrides the resolved path for --mnml.
  4. It spawns that binary with --blit <socket> appended, plus the per-app defaults — --input standard for mnml, --dashboard for mixr. TMNL_LAUNCH_ARGS overrides the defaults entirely.
  5. The child connects back over the socket, both sides exchange Hello, and the tab goes live.

If the binary isn’t found, the tab still opens — tmnl prints the socket path and waits. You can connect any client to that path manually, which is exactly what --no-launch is for:

Terminal window
tmnl --mnml --no-launch
# tmnl: --no-launch — start mnml manually with --blit /tmp/tmnl-12345.sock

Useful when you’re developing a backing app and want to attach a fresh build between iterations.

Tmnl remembers what flavour of tab the window launched with — an EditorTabTemplate capturing the binary, workspace, and extra args. When you hit Cmd+T:

  • Launched with --mnml? Cmd+T opens another mnml tab, same workspace, fresh socket.
  • Launched with --mixr? Cmd+T opens another mixr tab.
  • Launched as a plain shell? Cmd+T opens another shell tab.

This is “every new tab is the same kind”. If you want a different kind, you can mix flavours in one window by typing a command in a shell tab — tmnl-style apps usually accept a —blit flag — but the welcome screen’s recents list (~/.config/tmnl/recents.toml, 19 to re-open) is the intended way to bring up a familiar TUI on a future session.

Native and shell tabs are peers. The tab strip mixes them freely:

[ ● mnml — repo ] [ zsh ] [ mixr ] [ + ]

The keybindings are the same regardless of tab kind:

| Action | Chord | | --- | --- | | Switch to tab N | Cmd+1Cmd+9 | | Next / prev tab | Cmd+Shift+] / Cmd+Shift+[ | | New tab (same flavour) | Cmd+T | | Close tab | Cmd+W |

Cmd+W deserves a note. In a shell tab it kills the pty. In a native tab it isn’t intercepted by tmnl — it’s forwarded to the hosted app as Ctrl+W, so e.g. mnml closes its focused buffer instead of tearing the whole tab down. If you want an unambiguous escape, Cmd+Shift+[ / Cmd+Shift+] always switch tabs at the tmnl level regardless of who has focus.

The protocol is a single crate — tmnl-protocol — that you add as a dependency:

[dependencies]
tmnl-protocol = "0.0.9"

The crate ships a Client SDK that wraps the connection handshake, double-buffers a cell grid, and turns the raw Message stream into a small Event enum. A complete echo client fits in ~25 lines.

The full walkthrough — Client::connect / poll / flush / run_loop, the Event variants, when to drop to the raw protocol, and the v6 capability bitmask — lives on its own page: SDK — building a backing app.

For a ratatui app the integration is one extra step — wrap a TestBackend so its in-memory cell buffer ships over the wire after every draw. That’s the “BlitBackend pattern”: you keep using ratatui’s Frame / Layout / widgets exactly as before, the wrapper translates the backend’s Buffer into a tmnl_protocol::Frame on flush. mnml’s src/blit/mod.rs is the reference implementation; the same shape works for any ratatui-shaped TUI.

You can develop a backing app without ever launching the real tmnl. Two example binaries fake each side of the protocol:

Terminal window
# Terminal A — tmnl stub: binds the socket, scripts input, prints frames.
cargo run --example fake_server -- /tmp/dev.sock
# Terminal B — your app under development.
cargo run -- --blit /tmp/dev.sock

fake_server runs in stdout instead of a window, so the frames your app ships are printable. Pair it with fake_client to exercise tmnl’s server side without a backing app.

The full protocol walkthrough — handshake, frame semantics, diff runs, partial updates — lives in docs/sdk-guide.md.

There’s a second SCM_RIGHTS listener that exists for one specific trick: a backing app can hand a running pty session over to tmnl, and that pty keeps running inside a fresh shell tab.

The use case lives in mnml: a Pane::Pty running claude or codex or just a shell gets “popped out” into its own tmnl tab without losing scrollback or restarting the child. From inside mnml you run :tmnl.pop-pty (alias :tmnl.pop). Under the hood:

  1. tmnl exports TMNL_TRANSFER_SOCKET=<tempdir>/tmnl-<pid>-transfer.sock before any child exec, so backing apps inherit the path.
  2. tmnl binds that socket as a dedicated single-message listener (src/transfer.rs).
  3. mnml’s :tmnl.pop-pty opens the socket and calls send_message_with_fd(stream, OpenPaneTransfer { command, args }, Some(pty_master_fd)). The pty master fd rides via SCM_RIGHTS ancillary data on the same sendmsg(2).
  4. tmnl receives the message, takes ownership of the fd via ShellSession::adopt_fd, and surfaces a new shell tab on the next tick.
  5. mnml marks its sender side as released so the pty session isn’t killed when the source pane closes.

Why the dedicated socket? SCM_RIGHTS ancillary data can’t be read through a BufReader — the streaming protocol connection uses one — so the handoff gets its own single-message socket.

Platform note. SCM_RIGHTS is AF_UNIX-specific. The handoff helpers return Unsupported on Windows. The streaming protocol socket itself works on Windows via uds_windows, but pty-fd handoff is Unix-only.

Native mode isn’t a drop-in replacement for shell mode. There are real constraints.

  • Apps must be ratatui-shaped. Native mode is for apps that think in a bounded grid of (ch, fg, bg, attrs) cells — ratatui TUIs are the canonical fit. A generic CLI tool that emits scrolling text doesn’t have a grid to ship; it’d have to invent one. Use a shell tab and let vt100 do its job.
  • Inline images are PNG-only, cell-anchored. Protocol v7 adds InlineImageCreate / Update / Destroy for overlaying raster images at a cell coordinate inside a native pane — see SDK — Inline images. The image scrolls with the underlying cells (cell-anchored, not pixel-anchored). PNG today; JPEG + other formats are reserved on the ImageFormat enum but not shipped. The cell schema itself remains (ch, fg, bg, attrs) — images are a side-channel overlay, not a cell-level type.
  • Single client per tab. A given native tab’s socket accepts one connection at a time — a second connection is refused with a warning. The one-client-per-pane model is intentional; it keeps frame-ordering and resize semantics simple.

If your app fits the grid model and you want crisp text, GPU-native rendering, and no escape-code seam, native mode is the path. If you want to ship a sixel-rendering image viewer this week, shell mode is the path.

  • SDK — building a backing app — the published Client helper API, capability negotiation, and when to drop to the raw protocol.
  • examples/hello_client.rs — minimal, commented backing-app template.
  • tmnl-protocol — the wire-format crate.
  • mnml — the reference client; src/blit/mod.rs is the BlitBackend pattern for ratatui apps.
  • FEATURES.md — the shipped-feature inventory and what’s still on the roadmap.