Files
playpen/README.md
T
Greyson Parrelli 5b00c98e03 Multiple terminals per tab, rearrangeable by drag or keyboard
A tab is now a view holding an ordered list of panes rather than a single
terminal. New panes open side by side; moving one against a top or bottom
edge restacks the view, and against a side edge returns it to a row.

Layout is a flat list with one orientation per view rather than a split
tree. That covers a shell beside an agent without the structure a tree
needs, and it can be replaced once mixed layouts actually matter.

Panes live in a GtkBox and are reordered in place, so rearranging never
unparents a terminal and running processes and scrollback survive the move.

Dragging a pane uses its header as the handle so it never competes with the
terminal's own mouse handling, and drops go through the same moveRelative
path as the Ctrl+Shift+arrow shortcuts.

Also fixes an ordering bug this surfaced: View.create used to add its first
pane immediately, firing the title callback into a Tab that had not been
initialized yet. The view is now created empty and the caller adds the pane
once it has finished wiring itself up.
2026-08-11 10:59:21 -04:00

182 lines
8.2 KiB
Markdown

# vtabs
A proof-of-concept terminal emulator with **vertical tabs**, built on
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita.
The sidebar holds the window controls, a new-tab button, and one row per tab —
the layout Zen Browser uses for vertical tabs — with the terminal inset to its
right.
## Quick start
```sh
nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed
zig build run
```
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
## Installing
```sh
mise run install # builds release, installs into ~/.local
mise run uninstall
```
That puts the binary in `~/.local/libexec/vtabs`, a launcher on `~/.local/bin`,
a desktop entry in `~/.local/share/applications`, and the icon in the hicolor
theme, then refreshes the desktop and icon caches. `mise tasks` lists the rest
(`build`, `run`, `fmt`, `screenshot`).
Two things the installer has to handle that a normal `install` wouldn't:
- **A launcher, not a symlink.** The binary links against GTK in the Nix store
and has an RPATH, so it runs anywhere — but it still needs to be pointed at
GTK's GSettings schemas and icon themes, or it aborts on a missing schema.
A desktop launcher starts the app with a minimal environment, so those paths
are baked into the launcher script. They are read out of the dev shell at
install time rather than hardcoded, so they can't drift from the flake.
- **Nix GC roots.** The installed app depends on store paths that
`nix-collect-garbage` would otherwise be free to delete, which would break it
later with no obvious connection to the command that did it. Install pins
every store path in the binary's RPATH; uninstall releases them.
## Which "libghostty" this uses, and why
Ghostty ships two different C APIs, and only one of them is usable here:
| | `include/ghostty.h` | `include/ghostty/vt.h` |
|---|---|---|
| name | "libghostty-internal" | **libghostty-vt** |
| scope | whole terminal incl. renderer | VT core only |
| platforms | `GHOSTTY_PLATFORM_MACOS`, `GHOSTTY_PLATFORM_IOS` | any |
| intended for | Ghostty's own macOS app | external embedders |
`ghostty.h` is the one that would hand you a ready-made terminal surface, but
it has no Linux platform tag at all — its `ghostty_platform_e` enum only knows
macOS and iOS, and its own header says it is "tailored to the needs of the
macOS app". On Linux, Ghostty does not embed itself through that API; it builds
its GTK apprt directly into the binary.
So this project uses **libghostty-vt**, the API Ghostty documents for external
embedders. That means we get, for free and battle-tested:
- escape sequence parsing and full terminal state
- screen, scrollback, line wrapping, and reflow on resize
- key and mouse event encoding (including the Kitty keyboard protocol)
- paste safety checking and bracketed paste encoding
and we supply everything above it: process management, rendering, and the UI.
We consume it as a **Zig module** rather than through the C ABI. Ghostty's
`build.zig` detects that it is being used as a dependency and, in that mode,
builds only libghostty-vt — no GTK, no executable — so `zig build` pulls in
just the VT core.
## Architecture
```
main.zig AdwApplication, CSS loading
Window.zig sidebar + GtkStack of views, tab management, shortcuts
View.zig one tab's content: an ordered list of panes + their layout
Pane.zig a terminal plus its header, drag source, and drop target
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
Session.zig libghostty-vt Terminal + parser, fed by the PTY
Pty.zig openpt/fork/exec, controlling terminal setup
key.zig GDK keyval -> libghostty-vt key mapping
theme.zig colors libghostty-vt has no opinion about
```
A tab is a **view**, and a view holds one or more terminal **panes**. New panes
open side by side; moving a pane to a top or bottom edge restacks the view, and
moving it to a side edge puts it back in a row.
**Layout is a flat list, not a split tree.** A view has one orientation shared
by all its panes, so it can express "three side by side" or "three stacked" but
not "two beside a stack of three". A tree would handle the general case and is
where this goes if mixed layouts turn out to matter; it just brings a lot of
structure that a shell-next-to-an-agent view doesn't need yet.
Panes are held in a `GtkBox`, which can reorder its children in place. Nothing
is ever unparented, so rearranging a view never disturbs the running
terminals — their scrollback and processes carry straight through the move.
Two design choices worth calling out:
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
watch (`g_unix_fd_add`). Terminal state is therefore only ever touched from the
main thread, so the renderer reads the screen with no locking. Ghostty itself
uses a dedicated IO thread; that is the right answer for a real terminal, but
this is dramatically simpler and is not a bottleneck at interactive speeds.
**No GPU renderer.** Ghostty rasterizes glyphs into an atlas and draws on the
GPU. Here, each frame walks the visible rows, groups cells into runs of
identical style, and hands each run to Pango. That is far more work per frame
in principle, but a terminal grid is small.
## What works
- Your login shell (from the passwd database, not `$SHELL`) on a real PTY,
started as a login shell, with a controlling terminal so job control,
Ctrl-C and SIGWINCH behave
- Full SGR rendering: 16/256/true color, bold, italic, underline,
strikethrough, inverse, and the bright-on-bold convention
- Block / bar / underline / hollow cursor styles
- Scrollback via mouse wheel; typing snaps back to the prompt
- Resize reflows the grid and notifies the child
- Window title (OSC 0/2) becomes the pane header and tab label; the tab shows
its pane count once a view holds more than one
- Tabs: create, close, switch; closing the last one closes the window
- Multiple terminals per tab, rearranged by keyboard or by dragging a pane's
header; closing the last pane in a view closes its tab
### Shortcuts
| | |
|---|---|
| `Ctrl+Shift+T` | new tab |
| `Ctrl+Shift+E` | new terminal in the current tab |
| `Ctrl+Shift+W` | close the focused terminal (closes the tab with its last one) |
| `Ctrl+Shift+←/→/↑/↓` | move the focused terminal within its view |
| `Ctrl+Shift+V` | paste (bracketed-paste aware, refuses unsafe pastes) |
| `Ctrl+PageUp/PageDown` | previous / next tab |
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
Dragging a pane by its header does the same thing as the arrow shortcuts: the
edge you drop against decides both the order and the view's orientation. The
header is the drag handle rather than the whole pane so that dragging never
competes with the terminal's own mouse handling.
## Not implemented
This is a proof of concept, and the following are deliberately absent:
- **Mouse selection and copy.** libghostty-vt provides the selection and
mouse-encoding primitives, but nothing here is wired to them yet, so there is
no way to copy text. Paste works.
- **Ligatures and complex shaping.** Each run is drawn independently at a
fixed grid offset, so text that needs shaping across cell boundaries won't
look right.
- **Resizable splits.** Panes in a view divide the space evenly; there are no
draggable dividers. Moving to `GtkPaned` would add them.
- **Mixed layouts and moving panes between tabs.** See the flat-list note
above; panes can only be rearranged within their own view.
- **Kitty graphics, hyperlinks, tab reordering, config file.**
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
`ghostty`, since we don't install a terminfo entry.
## Development
`./b.sh` wraps `nix develop --command zig build` and strips the enormous
command line Zig prints on failure.
`mise run screenshot out.png "text to type"` (or `./shot.sh` directly) runs the
app inside a throwaway **headless Sway** and screenshots it. This keeps UI checks entirely out of your real
Wayland session — nothing appears on screen, and it works while the session is
locked.
One caveat when driving it: `wtype` loses the first keystroke of every
invocation while the compositor adopts its freshly uploaded keymap, so scripted
input should begin with a throwaway key. That is a quirk of the injection tool,
not of the terminal.