Playpen
A proof-of-concept workspace built on libghostty-vt 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
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
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 for what each one is.
- Nix GC roots. The installed app depends on store paths that
nix-collect-garbagewould 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 everyhttps://page in a web pane fails with "TLS support is not available", whilehttp://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'slibgbm, but libglvnd falls back to/usr/share/glvnd/egl_vendor.dand loads the host's mesa as the EGL vendor — and a GBM device created by one mesa is rejected by the other. Nix'slibgbmalso looks for its backend under/run/opengl-driver, which only exists on NixOS. Either one aborts the web process at startup withCould 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: 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":
"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.
{
"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:
{
"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
- 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 - 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
- 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
- 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
- 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 - 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
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:
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:
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:
{
"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
defaultto follow the desktop. style.csscolours 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 singleGtkCssProvider. No rule instyle.cssmay hardcode a colour — a literal hex is a rule that looks right in whichever scheme you happened to be testing in.theme.zigholds 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. 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.
TERMis reported asxterm-256colorrather thanghostty, 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:
./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.
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.