795 lines
40 KiB
Markdown
795 lines
40 KiB
Markdown
# Playpen
|
||
|
||
A proof-of-concept workspace built on
|
||
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
|
||
**vertical tabs**, each holding a split tree of terminal and web panes, with
|
||
**saved layouts** that open a whole arrangement — directories, scripts and all —
|
||
in one go, and a **startup list** that opens the ones you always want.
|
||
|
||
The sidebar holds the window controls, a new-tab button, one row per tab, and a
|
||
settings gear at its foot — the layout Zen Browser uses for vertical tabs —
|
||
with the content 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/playpen`, 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 a handful of things are found by
|
||
*path* at runtime rather than linked, and they have to be pointed at the Nix
|
||
copies or the app misbehaves in ways that look nothing like a packaging
|
||
problem. A desktop launcher starts the app with a minimal environment, so
|
||
those paths are baked into the launcher script, read out of the dev shell at
|
||
install time rather than hardcoded so they can't drift from the flake. See
|
||
[Running off-NixOS](#running-off-nixos) for what each one is.
|
||
- **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, plus the by-path ones above, which
|
||
never appear in an RPATH; uninstall releases them.
|
||
|
||
### Running off-NixOS
|
||
|
||
Everything here is set up by the dev shell and baked into the installed
|
||
launcher, so it should be invisible — but each one cost a debugging session, so
|
||
it is written down.
|
||
|
||
- **GSettings schemas and icon themes** (`GSETTINGS_SCHEMA_DIR`,
|
||
`XDG_DATA_DIRS`). Without them GTK aborts on a missing schema, or renders
|
||
broken-image icons.
|
||
- **A TLS backend** (`GIO_EXTRA_MODULES` → `glib-networking`). GIO has none
|
||
built in; it loads one as a module. Without it every `https://` page in a web
|
||
pane fails with "TLS support is not available", while `http://` still works.
|
||
- **One consistent mesa** (`__EGL_VENDOR_LIBRARY_DIRS`, `LIBGL_DRIVERS_PATH`,
|
||
`GBM_BACKENDS_PATH`). This is the subtle one. WebKit's web process is linked
|
||
against Nix's `libgbm`, but libglvnd falls back to
|
||
`/usr/share/glvnd/egl_vendor.d` and loads the *host's* mesa as the EGL
|
||
vendor — and a GBM device created by one mesa is rejected by the other.
|
||
Nix's `libgbm` also looks for its backend under `/run/opengl-driver`, which
|
||
only exists on NixOS. Either one aborts the web process at startup with
|
||
`Could not create default EGL display: EGL_BAD_PARAMETER`, so web panes open
|
||
fine, begin loading, and then go permanently blank — the terminal side is
|
||
completely unaffected, which makes it look like an app bug rather than a
|
||
graphics one. Pointing all three at the Nix mesa fixes it; the real GPU is
|
||
still used, via `/dev/dri`.
|
||
|
||
## 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 startup
|
||
appearance.zig the colour scheme: preference -> libadwaita, CSS, palette
|
||
Window.zig sidebar + GtkStack of views, tab management, shortcuts
|
||
View.zig one tab's content: its panes, their layout, and drag handling
|
||
Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
|
||
Pane.zig content plus its header, drag source, and drop target
|
||
Terminal.zig GtkDrawingArea: Cairo/Pango renderer, keyboard, mouse, selection
|
||
Browser.zig WebKitWebView plus a nav/address bar and a find bar
|
||
webkit.zig hand-written bindings for the WebKitGTK calls we make
|
||
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
|
||
OpenLayoutDialog.zig prompts for a layout's parameters
|
||
SaveLayoutDialog.zig turns the current tab into a saved layout
|
||
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
||
Pty.zig openpt/fork/exec, controlling terminal setup
|
||
script.zig runs a command for `$(...)` in a layout's directory
|
||
key.zig GDK keyval -> libghostty-vt key mapping
|
||
theme.zig colors libghostty-vt has no opinion about, per scheme
|
||
Settings.zig preferences: theme and startup tabs, JSON on disk
|
||
SettingsDialog.zig the settings page, including the startup list editor
|
||
TabSettingsDialog.zig one tab's own settings, and the emoji picker
|
||
emoji.zig generated: every emoji the picker offers, and the search over them
|
||
```
|
||
|
||
A tab is a **view**, and a view holds one or more **panes** arranged in a
|
||
**binary split tree**: every interior node is a split with an orientation,
|
||
every leaf is a pane. That is what allows the layouts a single shared
|
||
orientation can't express — three panes in a row, drag the rightmost to the
|
||
bottom, and you get two on top with one spanning the full width beneath them.
|
||
|
||
A pane holds either a terminal or a web view, behind a `Pane.Content` union.
|
||
Both kinds expose the same three operations (widget, focus, destroy) and report
|
||
back through the same three callbacks (title, exit, focus), so the split tree,
|
||
drag and drop, and focus tracking are written once and never branch on which
|
||
kind of pane they are moving. A web pane carries its own navigation bar rather
|
||
than putting an address entry in the pane header, because the header is the
|
||
drag handle and a text entry there would swallow the drags that rearrange the
|
||
view.
|
||
|
||
Each split node owns a `GtkPaned`, so dividers are draggable and every split
|
||
remembers its position as a **ratio** rather than a pixel count. The ratio is
|
||
the source of truth: it is re-applied whenever the available space changes, so
|
||
proportions survive window resizes, and only user drags update it.
|
||
|
||
Where a drop lands depends on how close it is to the view's own border. Near an
|
||
edge of the window the pane is placed against the whole layout and spans it;
|
||
anywhere else it splits just the pane under the pointer. That single rule gives
|
||
both "put this across the bottom" and "split this one in half".
|
||
|
||
Panes and split nodes each hold a strong reference to their own widget, so
|
||
detaching and reattaching during a rearrangement never finalizes anything.
|
||
Running terminals keep their scrollback and processes straight through a move.
|
||
|
||
**Drags rearrange live.** Each time the drop target changes, the move is
|
||
applied for real, so the layout under the cursor is always the layout you will
|
||
get — there is no separate drop indicator because the view itself is the
|
||
preview. Cancelling a drag puts the pane back: only the dragged pane ever
|
||
moves, so the rest of the tree is unchanged and re-inserting it beside its
|
||
original sibling restores the original shape.
|
||
|
||
## Layouts
|
||
|
||
A **layout** is a saved tab: an arrangement of panes, each with a directory and
|
||
a script, that opens in one go. Layouts take **parameters**, so one layout
|
||
serves any number of projects.
|
||
|
||
The layout button in the sidebar lists them. Picking one asks for its
|
||
parameters — prefilled with their defaults — and opens a new tab. A layout with
|
||
no parameters skips the prompt. The plain new-tab button is untouched: it still
|
||
opens one shell, immediately.
|
||
|
||
**You author layouts by arranging a tab.** Split it, drag panes around, drag the
|
||
dividers, then *Save tab as layout…*. That captures the tree, the split
|
||
orientations and the ratios exactly as they are on screen, and asks only for
|
||
what it can't infer: a name, the parameters, and each pane's script. There is
|
||
deliberately no separate layout builder — the split tree already is one.
|
||
|
||
The save dialog prefills what it can read off the live tab: each terminal's
|
||
current directory, straight out of `/proc/<pid>/cwd`, and each web pane's
|
||
current page. So the usual flow is to get a tab set up the way you like,
|
||
save it, and replace the literal paths with `{{parameters}}`.
|
||
|
||
**Editing** a saved layout opens the same dialog on the stored one, so its
|
||
name, parameters and per-pane scripts can be changed without opening it.
|
||
Renaming moves the layout rather than copying it, and renaming onto a name
|
||
another layout already has is refused instead of quietly replacing it. Nothing
|
||
is written until you confirm, so cancelling leaves the layout untouched. To
|
||
change the *shape* of a layout, open it, rearrange the tab, and save over it
|
||
under the same name — the same tools you used to build it in the first place.
|
||
|
||
Every `cwd`, `command` and `url` goes through `{{name}}` substitution, and a
|
||
leading `~` is expanded afterwards. An unknown `{{name}}` is left as written
|
||
rather than blanked, so a typo shows up in the pane instead of silently
|
||
producing an empty path.
|
||
|
||
### Directories from a script
|
||
|
||
A **directory** can also be `$(a command)`, and what the command prints becomes
|
||
the path. A parameter can say *which* project; only a command can answer
|
||
"wherever that branch is checked out":
|
||
|
||
```json
|
||
"cwd": "$(git -C ~/src/{{repo}} worktree list | awk '/{{branch}}/ {print $1}')"
|
||
```
|
||
|
||
It runs under `/bin/sh -c`, so pipes and `&&` work as written, and `$(...)` may
|
||
appear anywhere in the field — `~/src/$(pick-project)/api` is fine, as are
|
||
several in one path. Its stdout is the value, trimmed of surrounding
|
||
whitespace; its stderr stays attached to Playpen's own, so a script that
|
||
complains complains somewhere you can read it.
|
||
|
||
**Parameters reach the script two ways.** They are substituted into it first,
|
||
as `{{name}}` above, and every one is also in its environment as
|
||
`PLAYPEN_<NAME>` — uppercased, with anything that isn't a shell identifier
|
||
character replaced by `_`, so `repo-path` arrives as `PLAYPEN_REPO_PATH`. Reach
|
||
for the environment form whenever a value might contain a space: `{{name}}`
|
||
splices text straight into the command line, where a path with a space in it
|
||
silently becomes two arguments, and `"$PLAYPEN_NAME"` cannot.
|
||
|
||
Order is `{{name}}` → `$(...)` → leading `~`, so a script is free to print a
|
||
`~/...` path and land where it meant to.
|
||
|
||
Two limits worth knowing. **A script blocks the window while it runs**, because
|
||
layouts are built by a synchronous walk of the split tree; it is killed after
|
||
five seconds, so a mistake costs a visible pause and an error rather than a
|
||
window that never comes back. And **a script that fails leaves an empty string**
|
||
rather than aborting the tab — the rest of the layout is usually fine, and a
|
||
pane that opened in the wrong directory is both obvious and recoverable, which
|
||
a tab that refused to open is not. Failures are logged with the command that
|
||
caused them.
|
||
|
||
This applies to the directory only. A pane's *script* field is already typed
|
||
into a shell, which does its own command substitution; running it here first
|
||
would evaluate it twice, at two different moments.
|
||
|
||
**Scripts are typed into the shell, not run instead of it.** A pane starts your
|
||
login shell as usual, and the script is fed to it once it is ready. The shell
|
||
is still there when the script finishes, with your environment loaded and the
|
||
command in history. The wait matters: writing at spawn time loses the input,
|
||
because shells that set up line editing discard whatever was buffered while
|
||
they were initializing. The shell's first output is the signal that it is
|
||
reading, so that is when the script goes in.
|
||
|
||
Layouts live in `~/.config/playpen/layouts.json` (or `$XDG_CONFIG_HOME`), and the
|
||
file is meant to be edited by hand as well — *Reload from disk* picks up
|
||
changes. It is JSON because the app writes it too, and a format that
|
||
round-trips without a hand-written emitter is worth more here than a prettier
|
||
one.
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"layouts": [
|
||
{
|
||
"name": "Project",
|
||
"parameters": [
|
||
{ "name": "path", "description": "Project directory", "default": "~" }
|
||
],
|
||
"root": {
|
||
"split": "horizontal",
|
||
"ratio": 0.55,
|
||
"first": {
|
||
"kind": "terminal",
|
||
"cwd": "{{path}}",
|
||
"command": "git status"
|
||
},
|
||
"second": {
|
||
"split": "vertical",
|
||
"ratio": 0.5,
|
||
"first": { "kind": "terminal", "cwd": "{{path}}", "command": "nvim ." },
|
||
"second": { "kind": "web", "url": "https://github.com" }
|
||
}
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
A node is a split if it has a `split` key and a leaf otherwise. Saves are
|
||
atomic — written to a temporary and renamed — so an interrupted write leaves
|
||
the previous layouts intact rather than a file that won't parse.
|
||
|
||
## Startup tabs
|
||
|
||
Opening the same three layouts against the same three worktrees every morning is
|
||
the kind of thing the app should be doing for you. **Settings → Startup** is a
|
||
list of tabs to open at launch: one row per tab, each naming a saved layout, the
|
||
values to fill its parameters in with, and optionally a name and an emoji to pin
|
||
on the row.
|
||
|
||
The fastest way to fill it in is *Use current tabs*: arrange the window you want,
|
||
then say that this is the window you always want. It writes down one entry per
|
||
open tab, in sidebar order, with whatever each was opened with.
|
||
|
||
What is saved is the **recipe, not the session**. An entry is the layout's name
|
||
and a handful of values, so it is a few lines you can read, edit and keep — and
|
||
the layout itself stays the thing you maintain. Nothing here restores a
|
||
scrollback, a running command, or a shell that has since wandered into another
|
||
directory; launching re-runs the arrangement, and that was the tedious part.
|
||
|
||
Rows in the list can be reordered, and the first one is the tab you land in. An
|
||
entry with no layout picked opens a plain shell, which is worth having — a
|
||
startup list is often two configured tabs and one ordinary shell to work in. An
|
||
entry naming a layout that has since been renamed or deleted is skipped with a
|
||
warning rather than silently costing you a tab, and the settings page keeps
|
||
showing it — with *This layout is no longer saved* under it — so the values you
|
||
typed for it survive until you deal with it.
|
||
|
||
Parameters that the entry doesn't mention open at the layout's own default, which
|
||
is the same rule the parameter prompt follows. So an entry only has to carry the
|
||
values that differ from what the layout already suggests.
|
||
|
||
The list lives in `settings.json` beside the theme:
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"theme": "dark",
|
||
"startup": [
|
||
{ "layout": "Project", "name": "signal", "emoji": "🚀",
|
||
"parameters": { "path": "~/src/signal" } },
|
||
{ "layout": "Project", "parameters": { "path": "~/src/playpen" } },
|
||
{ "name": "scratch" }
|
||
]
|
||
}
|
||
```
|
||
|
||
With no `startup` list the app opens a single shell, exactly as it did before
|
||
this existed — and if a list opens nothing at all, that is what you get too,
|
||
since a window you can't type in is not an outcome worth being faithful for.
|
||
|
||
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 panes per tab in an arbitrary split tree, rearranged by keyboard
|
||
or by dragging a pane's header, with draggable dividers between them;
|
||
closing the last pane in a view closes its tab
|
||
- **Web panes**, on WebKitGTK, sitting in the split tree beside terminals and
|
||
dragging around exactly like they do: back/forward/reload, an address bar
|
||
that takes a URL or falls back to a search, a load-progress indicator in the
|
||
entry, and the page title feeding the pane header and tab label, plus
|
||
find-in-page on `Ctrl+F`. See [Find in page](#find-in-page)
|
||
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
|
||
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
||
arranging a tab and saving it. See [Layouts](#layouts)
|
||
- **A startup list**: the tabs to open at launch, each a layout with its
|
||
parameters filled in, fillable from the tabs you have open now. See
|
||
[Startup tabs](#startup-tabs)
|
||
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
|
||
it is working, waiting on you, or finished and still unanswered. See
|
||
[Agent status](#agent-status)
|
||
- **Renaming a tab**: `Ctrl+Shift+R`, double-click a tab row, or **Rename** in
|
||
its right-click menu. A typed name pins the label; clearing it hands the
|
||
label back to the panes
|
||
- **An emoji in place of a tab's icon**, from **Settings** in the same
|
||
right-click menu, picked out of the full emoji set with keyword search. See
|
||
[Tab settings](#tab-settings)
|
||
- **Zooming a pane** to fill its tab and back, from the header button or
|
||
`Ctrl+Shift+Z`. Nothing closes and nothing moves — hidden panes keep running
|
||
and the split comes back exactly as it was. See [Zoom](#zoom)
|
||
- **Light and dark schemes**, following the desktop by default and pinnable
|
||
from the settings page, applying to open tabs immediately — terminal palette
|
||
included. See [Theme](#theme)
|
||
|
||
### Shortcuts
|
||
|
||
| | |
|
||
|---|---|
|
||
| `Ctrl+Shift+T` | new tab |
|
||
| `Ctrl+Shift+E` | new terminal in the current tab |
|
||
| `Ctrl+Shift+B` | new web view in the current tab |
|
||
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) |
|
||
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
|
||
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
|
||
| `Ctrl+Shift+C` | copy the terminal selection |
|
||
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
|
||
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
|
||
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) |
|
||
| `Ctrl+,` | settings |
|
||
| `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 content's own mouse handling.
|
||
|
||
Both add-pane shortcuts are also buttons in every pane header, which is how you
|
||
open a web view without remembering the chord.
|
||
|
||
## The mouse in a terminal
|
||
|
||
A terminal's pointer has two possible owners, and which one has it depends on
|
||
what is running.
|
||
|
||
Ordinarily it is the window's. Dragging selects text, double-click takes a
|
||
word and triple-click a line, `Ctrl` while dragging selects a rectangle, and
|
||
the wheel moves through the scrollback. Selecting fills the primary selection,
|
||
so middle-click pastes what was last highlighted; `Ctrl+Shift+C` puts it on the
|
||
clipboard proper.
|
||
|
||
But a full-screen program can ask for the mouse itself (DEC modes 1000-1003),
|
||
and from then on clicks, drags and wheel notches are encoded and handed
|
||
straight to it. That is what makes a TUI's buttons clickable and what makes
|
||
Claude Code scroll. While a program holds the pointer the cursor goes back to
|
||
an arrow, as a reminder that a drag will not select anything. **Holding shift
|
||
takes it back** for the length of that drag, so there is always a way to copy
|
||
text out of a full-screen application.
|
||
|
||
One case sits between the two. Pagers — `less`, `man`, anything built on them —
|
||
run on the alternate screen, which has no scrollback for the wheel to move
|
||
through, and most of them never ask for the mouse. There the wheel becomes
|
||
arrow keys (DEC mode 1007), which is what those programs are waiting for
|
||
anyway.
|
||
|
||
## Zoom
|
||
|
||
A split that was the right shape for watching two things at once is usually the
|
||
wrong shape for actually working in one of them. `Ctrl+Shift+Z`, or the
|
||
fullscreen button in a pane's header, gives that pane the whole tab; the same
|
||
again puts the split back.
|
||
|
||
This is not the window manager's fullscreen. The window keeps its decorations,
|
||
the sidebar stays put, and only the tab's own content area is involved.
|
||
|
||
Nothing closes and nothing is rearranged. The split tree is left exactly as it
|
||
was and only the rendering changes, which is what lets the restore be exact
|
||
rather than an approximation — ratios, orientations and ordering all come back
|
||
untouched, because they were never taken apart. Hidden panes are unparented but
|
||
still alive: their shells keep running, output produced while they were off
|
||
screen is waiting when they return, and their grids keep the size they had
|
||
rather than reflowing to nothing.
|
||
|
||
The toggle only appears once a tab holds more than one pane, since zooming the
|
||
only pane in a view would be an invisible state change. Splitting a pane or
|
||
moving one leaves zoom, because both of those actions are about the arrangement
|
||
that zoom is hiding. Closing the zoomed pane leaves zoom; a *different* pane
|
||
closing — a background shell exiting, say — does not, since what you are looking
|
||
at is still there.
|
||
|
||
## Find in page
|
||
|
||
`Ctrl+F` in a web pane opens a find bar under its address bar. Typing searches
|
||
as you go — case-insensitively, wrapping at the end of the document — and the
|
||
bar reports how many matches there are, or says there are none and reddens the
|
||
box. `Enter` steps to the next match, `Shift+Enter` to the previous, and the
|
||
two arrow buttons do the same. `Escape` or the × closes the bar, which is also
|
||
what clears the highlighting.
|
||
|
||
The chord is the browser one rather than this app's usual `Ctrl+Shift`, and it
|
||
is claimed only while a web pane has focus. In a terminal `Ctrl+F` is an
|
||
ordinary control character that the program running there is waiting for —
|
||
forward-a-character in a readline prompt, the prefix key in tmux — so the key
|
||
is left alone there.
|
||
|
||
WebKit does the searching, and it will only tell you the total number of
|
||
matches, not which one you are on; the count stops at a thousand, on the
|
||
grounds that a page with more matches than that is one where the exact number
|
||
tells you nothing.
|
||
|
||
## Tab settings
|
||
|
||
Right-clicking a tab row opens a small menu: **Rename**, which is the same
|
||
one-field popover `Ctrl+Shift+R` opens, and **Settings…**, which opens a window
|
||
belonging to that one tab. Double-clicking a row still goes straight to renaming
|
||
— it is the thing you do far more often than the other.
|
||
|
||
The window holds one setting today, **Emoji Label**: a glyph that stands in for
|
||
the pane icon in that tab's sidebar row. It replaces the icon rather than joining
|
||
it, since a row has one slot for "what is this tab" and the label needs the rest
|
||
of the width. **Use the pane icon** puts the icon back.
|
||
|
||
Picking one is a search box over a grid of the whole emoji set — 1906 glyphs, in
|
||
Unicode's own order and grouping, so it reads the way any other emoji keyboard
|
||
does. A glyph's tooltip is its name, for the ones you can't quite make out at
|
||
grid size.
|
||
|
||
Every whitespace-separated word in the query has to match, in any order, so
|
||
`green circle` and `circle green` both land on 🟢 while `circle` alone brings back
|
||
the whole family. Keywords are CLDR's, which is where `grin` finding 😀 comes
|
||
from, plus the group and subgroup each glyph belongs to — `fruit`, `arrow` and
|
||
`flags` each bring back a shelf — plus a table of synonyms for the words a
|
||
terminal user actually types: `deploy` finds 🚀, `hotfix` finds 🩹, `docker` finds
|
||
🐳, `rust` finds 🦀.
|
||
|
||
`src/emoji.zig` is generated by `tools/gen-emoji.py` from Unicode's
|
||
`emoji-test.txt` and CLDR's English annotations; run that rather than editing the
|
||
table, and add to its `SYNONYMS` when a glyph should answer to a word the data
|
||
files don't know. Two things it leaves out. Skin-tone variants, because just over
|
||
half of Unicode's 3944 sequences are the same gesture in five tones and a grid of
|
||
them is longer to look through rather than more complete — every base glyph is
|
||
there. And anything newer than emoji 16.0, because colour fonts trail Unicode by
|
||
a year or two and a glyph the font has never heard of draws as a hex-digit box,
|
||
which in a picker reads as a bug; `--max-version` raises the cutoff once fonts
|
||
have caught up.
|
||
|
||
The choice lives on the tab and not on disk, the same as a typed name: it lasts
|
||
as long as the tab does. Saving the tab as a layout does not carry it, since a
|
||
layout describes an arrangement of panes rather than what a row looks like.
|
||
|
||
## Agent status
|
||
|
||
Playpen is mostly used to keep several Claude Code sessions side by side, and
|
||
the question a sidebar full of them has to answer is "which of these needs me?".
|
||
So a tab can carry a dot:
|
||
|
||
| | |
|
||
|---|---|
|
||
| purple, pulsing | working |
|
||
| amber | waiting for you — a permission prompt, or a question |
|
||
| green | **finished, and you haven't answered it** |
|
||
| red | stopped on an error |
|
||
| none | idle |
|
||
|
||
Each colour is carried at three sizes, so it survives being glanced at rather
|
||
than read: an 11px dot on the row, a bar down the row's leading edge, and a wash
|
||
behind the whole row. Working gets the bar without the wash — it is the resting
|
||
state of a busy afternoon, and tinting half the sidebar all day would only teach
|
||
you to stop looking. Inside a tab, a pane that is asking colours its own frame
|
||
and header the same way, which is what picks it out of a four-way split.
|
||
|
||
The green one is the point of the feature. A session that has gone back to idle
|
||
looks exactly like one that never ran, so a pane that finishes latches green and
|
||
stays that way until you deal with it. Nothing else tells you a tab is worth
|
||
going back to.
|
||
|
||
**Opening the tab clears the row.** A pane's own dot clears when you go to that
|
||
pane or type in it. The two answer slightly different questions: the row's is
|
||
"should I go there?", which visiting settles whether or not you then deal with
|
||
everything inside, and a pane's is "have you dealt with me?", which only you can
|
||
answer. In a split that difference is the whole point — you open a flagged tab,
|
||
the row goes quiet, and the panes you haven't been to yet are still marked.
|
||
|
||
Work that finishes while you are sitting in the tab flags it too, deliberately.
|
||
Having a tab on screen when a session stopped says only that the pixels were in
|
||
front of you; watching it finish and then moving on to something else is the
|
||
case this is most needed for. It goes quiet as soon as you answer that pane, or
|
||
the next time you come back to the tab.
|
||
|
||
Green also outranks purple. A tab holding three sessions goes green as soon as
|
||
any one of them finishes rather than waiting for the last one to stop, because
|
||
"one of these is ready for you" is the news. The two states that actually want
|
||
something from you — amber and red — outrank it in turn.
|
||
|
||
Panes report this individually and the tab shows the most urgent of them, so a
|
||
four-pane tab still reduces to one dot. The pane headers carry their own dots,
|
||
so once the row has brought you to the tab, they say which pane inside it was
|
||
the one asking; the row stays lit until every finished pane in it has been
|
||
answered.
|
||
|
||
### How it gets there
|
||
|
||
Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same
|
||
one Windows Terminal and Ghostty use for taskbar progress — and their title with
|
||
OSC 0/2. Nothing about this is Claude-specific: a build that reports progress
|
||
lights the same dot.
|
||
|
||
Claude Code has no idea about any of this, so a hook tells it:
|
||
|
||
```sh
|
||
mise run install-claude-hooks # merges into ~/.claude/settings.json
|
||
mise run uninstall-claude-hooks
|
||
```
|
||
|
||
That installs `hooks/playpen-status.sh` into `~/.claude/hooks/` and wires it to
|
||
five events: `UserPromptSubmit` → working (and sets the tab title from your
|
||
prompt), `Notification` → waiting, `Stop` / `SessionStart` / `SessionEnd` →
|
||
idle. The red state is deliberately not wired to anything: the obvious
|
||
candidate, `PostToolUseFailure`, fires for tool errors Claude then goes on to
|
||
recover from, so a tab would turn red constantly during ordinary work. The
|
||
script still accepts `error` for anything of your own worth flagging that way.
|
||
|
||
Existing hooks are left alone — every entry is tagged with a marker
|
||
comment, so re-running replaces Playpen's own entries rather than stacking
|
||
duplicates, and the previous file is kept at `settings.json.playpen-backup`.
|
||
Hooks are read at startup, so open a new session to pick them up.
|
||
|
||
The hook writes to a pty, not to a socket or a daemon: the bytes land in that
|
||
pane and no other, with nothing to configure and no way for two concurrent
|
||
sessions to be mistaken for each other. It writes there rather than to stdout
|
||
because Claude Code parses hook stdout as the hook's JSON result; an escape
|
||
sequence written there would corrupt the hook protocol instead of reaching the
|
||
terminal.
|
||
|
||
Finding that pty is the one genuinely fiddly part, and `/dev/tty` — the obvious
|
||
answer, and what this used to do — is the wrong one. **Claude Code starts each
|
||
hook in its own session**, so a hook has no controlling terminal at all and
|
||
opening `/dev/tty` fails with `ENXIO`. It fails invisibly, too: `[ -w /dev/tty ]`
|
||
returns true regardless, because it stats a path whose mode is `0666` rather
|
||
than opening it, so guarding on that reports success and then writes into
|
||
nothing. Every state change was being dropped on the floor with no error
|
||
anywhere.
|
||
|
||
The pty is only one hop away, though — it is on Claude Code's own standard file
|
||
descriptors. So the script tries its controlling terminal by *opening* it, and
|
||
failing that walks up `/proc` for the nearest ancestor holding a pty, with a
|
||
`ps -o tty=` fallback for systems without `/proc`. The nearest ancestor is the
|
||
right answer even with something in between: a Claude running inside tmux inside
|
||
a pane finds tmux's pty, which is where its output actually goes.
|
||
|
||
A tab's title follows your prompt for as long as Claude holds the foreground.
|
||
Once it exits, the shell's own prompt sets the title back, which is the right
|
||
answer — there is no task any more. A name you type yourself outranks both.
|
||
|
||
### Inside a VM
|
||
|
||
Claude in a microVM (`smolvm`, `krunvm`, anything libkrun-based) still works,
|
||
because the guest console passes these bytes through to the host pty unchanged:
|
||
|
||
```
|
||
$ # written to the guest's console, observed on the host pty:
|
||
b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07'
|
||
```
|
||
|
||
This is the reason for choosing escape sequences over a socket. A unix socket on
|
||
the host is not reachable from inside a guest, and matching a message to a pane
|
||
by cwd breaks as soon as guest paths stop corresponding to host ones. A console
|
||
you already have is the one channel guaranteed to cross that boundary.
|
||
|
||
The guest has its own `$HOME` and so its own `~/.claude/settings.json`. Install
|
||
the hook inside the image rather than on the host — the script is dependency-free
|
||
POSIX `sh` and behaves identically on both sides:
|
||
|
||
```sh
|
||
smolvm machine exec --name NAME -- mkdir -p /root/.claude/hooks
|
||
# copy hooks/playpen-status.sh to /root/.claude/hooks/ via a mount or a heredoc,
|
||
# then merge the same five events into the guest's settings.json.
|
||
```
|
||
|
||
`jq` is used for the title when present and a `sed` fallback covers a minimal
|
||
image that has no `jq`; if the title can't be read the state still reports and
|
||
the tab simply keeps the name it had.
|
||
|
||
## Theme
|
||
|
||
Deep navy surfaces with Signal's ultramarine (`#3a76f0`) as the accent, in a
|
||
light and a dark scheme. `Ctrl+,` or the gear at the foot of the sidebar opens
|
||
**Settings**, whose Appearance section holds the choice: light, dark, or
|
||
**system**, which is the default and follows the desktop.
|
||
|
||
The preference lives in `~/.config/playpen/settings.json` (or
|
||
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
|
||
[startup tabs](#startup-tabs):
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"theme": "system",
|
||
"startup": []
|
||
}
|
||
```
|
||
|
||
Switching applies immediately to every open tab — nothing needs restarting, and
|
||
a shell that has been running all day repaints along with everything else.
|
||
|
||
Three separate colour systems have to agree for that to be true, which is what
|
||
`appearance.zig` exists to arrange:
|
||
|
||
- **libadwaita's style manager** colours the stock widgets — popovers, entries,
|
||
dialog chrome. It is told to force a scheme, or left on `default` to follow
|
||
the desktop.
|
||
- **`style.css`** colours everything the app draws itself. It is written
|
||
entirely against named colours, with one palette file per scheme
|
||
(`palette-dark.css`, `palette-light.css`); the matching palette is prepended
|
||
and the pair loaded as a single `GtkCssProvider`. No rule in `style.css` may
|
||
hardcode a colour — a literal hex is a rule that looks right in whichever
|
||
scheme you happened to be testing in.
|
||
- **`theme.zig`** holds what Cairo draws the terminal grid from: the default
|
||
background, foreground and cursor, plus the 16 ANSI colours. The style tree
|
||
is never consulted there, so a CSS reload alone would leave every terminal
|
||
painted in the scheme it started in.
|
||
|
||
The ANSI palette is the part that is easy to skip and shouldn't be. The
|
||
standard xterm yellow is `#cdcd00`, which on a white background is close to
|
||
invisible — and prompts and build tools use it constantly, so a light scheme
|
||
without a light palette is a light scheme you can't read. The 240 colours above
|
||
index 16 are fixed by spec and left alone; only the 16 named ones change.
|
||
|
||
They are swapped through libghostty-vt's `DynamicPalette.changeDefault`, which
|
||
changes what the palette *defaults* to. Anything a program set for itself with
|
||
OSC 4 survives the switch, and a later OSC 104 reset returns to the current
|
||
scheme's palette rather than the one the app happened to start in.
|
||
|
||
Whether "system" currently means light or dark is libadwaita's answer, not
|
||
ours: it already watches the desktop for the setting. So the stylesheet follows
|
||
its `dark` property rather than the stored preference, and the preference only
|
||
decides what the style manager is told. A desktop that switches at sunset takes
|
||
this app with it, with no extra machinery and no second source of truth.
|
||
|
||
## 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.
|
||
- **Moving panes between tabs.** Panes can only be rearranged within their
|
||
own view.
|
||
- **Browser furniture.** Web panes get navigation, an address bar and
|
||
find-in-page, and nothing else: no bookmarks, history, downloads or devtools,
|
||
and each pane uses WebKit's default context, so nothing is persisted between
|
||
runs. Links that ask for a new window are ignored rather than opening a pane.
|
||
- **Restoring a session.** [Startup tabs](#startup-tabs) reopen the
|
||
arrangement you asked for, but nothing captures the state a session got itself
|
||
into: no scrollback, no running commands, no directories a shell wandered off
|
||
to. A tab comes back as its layout, not as you left it.
|
||
- **Kitty graphics, hyperlinks, tab reordering.**
|
||
- **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. Note that this applies per invocation, so a trailing
|
||
`wtype -k Return` in its own call is swallowed entirely — pass it as part of
|
||
the same `wtype` command as the text it submits.
|
||
|
||
**Modifier chords do work**, but only with that same caveat applied to the
|
||
chord itself. `wtype -M ctrl -k comma -m ctrl` on its own is unreliable — the
|
||
dropped first event is the *modifier press*, leaving a bare comma — so the
|
||
throwaway key has to go inside the same invocation:
|
||
|
||
```sh
|
||
./shot.sh out.png "-k Shift_L -M ctrl -k comma -m ctrl"
|
||
```
|
||
|
||
Note that `shot.sh` quotes `$KEYS`, so a chord needs the quoting relaxed for
|
||
the words to reach `wtype` as separate arguments.
|
||
|
||
**Synthetic clicks do not work.** `swaymsg seat seat0 cursor set/press` is
|
||
accepted and reports success, but the headless backend has no pointer device to
|
||
emit from and the client never sees it. Anything reachable only by clicking has
|
||
to be reached another way — a keyboard shortcut, or `zig build test` if the
|
||
thing being checked is logic rather than pixels.
|
||
|
||
`zig build test` runs the unit tests, which cover layout parsing, the
|
||
parameter/`$(...)` substitution pipeline, and the emoji table and its search.
|
||
They build as two binaries, rooted at `Layouts.zig` and `emoji.zig`, so neither
|
||
links GTK — a test binary has one root, and those are the two files worth
|
||
testing in isolation that have nothing to do with each other.
|
||
|
||
`tools/gen-emoji.py` regenerates `src/emoji.zig` from Unicode's `emoji-test.txt`
|
||
and CLDR's annotations. It is not part of the build — its output is committed, so
|
||
a build never needs the network — and `--check` reports whether what is committed
|
||
is what it would write. See [Tab settings](#tab-settings).
|
||
|
||
Web panes need an EGL display and the headless compositor has no GPU, so
|
||
`shot.sh` forces the dev shell's mesa down to its software rasterizer. The mesa
|
||
paths themselves come from the dev shell and are needed on a real session too —
|
||
see [Running off-NixOS](#running-off-nixos).
|