691 lines
35 KiB
Markdown
691 lines
35 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.
|
|
|
|
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 + input handling
|
|
Browser.zig WebKitWebView plus a back/forward/reload/address 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: model, JSON on disk
|
|
SettingsDialog.zig the settings page
|
|
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.
|
|
|
|
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
|
|
- **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)
|
|
- **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+R` | rename the current tab (empty name = follow the terminal) |
|
|
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
|
|
| `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.
|
|
|
|
## 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.
|
|
|
|
## 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**, which currently holds one 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`:
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"theme": "system"
|
|
}
|
|
```
|
|
|
|
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 and an address bar and
|
|
nothing else: no bookmarks, history, downloads, devtools, or find-in-page,
|
|
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.** Layouts save arrangements you open deliberately;
|
|
nothing restores the tabs you happened to have open when the app closed.
|
|
- **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).
|