1400 lines
71 KiB
Markdown
1400 lines
71 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, web and **code review**
|
||
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.
|
||
|
||
A tab can hold a GitHub-style review of the repository it is working in, and the
|
||
agent in the terminal beside it can read the comments you leave there and reply
|
||
inline. See [Code review](#code-review).
|
||
|
||
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 + node, no host toolchain needed
|
||
mise run web-deps # once: npm deps for the review pane's UI
|
||
zig build run
|
||
```
|
||
|
||
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
|
||
`web-deps` is separate because it is the one step that wants the network —
|
||
`zig build` runs the UI's production build itself, but it will not install its
|
||
dependencies for you.
|
||
|
||
## 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 color scheme: preference -> libadwaita, CSS, palette
|
||
Window.zig sidebar + GtkStack of views, tab management, reordering, 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
|
||
Review.zig the tab's review: a web view bound to its own server endpoint
|
||
webkit.zig hand-written bindings for the WebKitGTK calls we make
|
||
review.zig the review server's lifecycle, and the one place with threads
|
||
review/Server.zig HTTP + SSE, the tab registry, the embedded UI
|
||
review/git.zig what git is asked for: refs, commits, the patch itself
|
||
review/Store.zig one review's comments, as JSON in the repo's git dir
|
||
review/model.zig the wire format the UI and the API both speak
|
||
review/assets.zig the built UI, carried in the binary
|
||
web/ the review UI: React + Vite, built by build.zig and embedded
|
||
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
|
||
PathEntry.zig a directory field: completion popover plus a Browse button
|
||
paths.zig what a half-typed path completes to, GTK-free and tested
|
||
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
|
||
notify.zig desktop notifications on a finish, and the mutes that stop them
|
||
palette.zig every color by name, its default per scheme, and the CSS for it
|
||
theme.zig the palette resolved for the terminal renderer, which reads numbers
|
||
Settings.zig preferences: theme, palette overrides, startup tabs, JSON on disk
|
||
SettingsDialog.zig the settings page, including the startup list editor
|
||
PaletteEditor.zig the color editor inside it: a swatch per palette entry
|
||
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. Canceling 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.
|
||
|
||
**Tabs reorder the same way.** Dragging a sidebar row moves the tab, and the
|
||
rows shuffle under the pointer as you go rather than waiting for the drop; a
|
||
copy of the row travels with the cursor while the one being moved stays dimmed
|
||
where it currently sits. Dropping in the space below the last row puts the tab
|
||
at the end, and a drag that ends anywhere else — over a pane, off the window —
|
||
is a cancel, which puts the tab back at the index it started from.
|
||
|
||
The order is the tab list itself, which is the order everything else already
|
||
reads: `Ctrl+1`..`Ctrl+9`, next/previous tab, and the startup list that "use
|
||
current tabs" captures. The sidebar follows it through a sort function rather
|
||
than by moving rows around, since taking a row out of a `GtkListBox` to put it
|
||
back elsewhere would drop the selection and the focus with it.
|
||
|
||
## 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`, each web pane's current
|
||
page, and the repository a review pane is bound to. 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 canceling 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.
|
||
|
||
### Parameter types
|
||
|
||
A parameter has a **type**, picked beside its name in the layout editor. The
|
||
type only ever changes how the value is *asked for* — every value is text and is
|
||
substituted as text — so it is a better prompt rather than a different layout.
|
||
|
||
- **Text** is a plain entry, and what every parameter was before types existed.
|
||
- **Directory** is a path field: it completes directory names as you type, and
|
||
carries a **Browse…** button that opens the file chooser. Which is what most
|
||
layout parameters already are — a layout is usually aimed at a project.
|
||
|
||
The completion is shell-shaped rather than GTK-shaped. `Tab` completes as far as
|
||
the matches agree — `~/src/pl` to `~/src/playpen/`, and no further than the
|
||
common prefix when several match — the arrow keys walk the list, `Enter` takes
|
||
the highlighted one, and `Escape` puts the list away. With nothing highlighted,
|
||
`Enter` still opens the layout, so typing a path you already know stays
|
||
type-and-go. Only directories are offered, hidden ones only once you have typed
|
||
the `.`, and a long list says how many it is not showing rather than looking
|
||
like the whole answer. A value still holding `{{a parameter}}` or `$(a command)`
|
||
is left alone: it is not a path yet, so there is nothing to complete it against.
|
||
|
||
`GtkEntryCompletion` would have been the stock answer. It is deprecated as of
|
||
GTK 4.10, and its inline completion goes to the *first* match rather than to the
|
||
longest common prefix, which among sibling directories guesses wrong more often
|
||
than it helps. The list is a popover that deliberately does not autohide: an
|
||
autohiding popover takes a grab, and the grab would send the next keystroke —
|
||
the one that narrows the list — to the popover instead of the field.
|
||
|
||
In the file, a type is a string beside the parameter's name, and saying nothing
|
||
means `string`:
|
||
|
||
```json
|
||
{ "name": "path", "description": "Project directory", "type": "directory" }
|
||
```
|
||
|
||
A type this build doesn't recognize is asked for in a text box rather than
|
||
refusing the file — unlike a pane `kind`, which is a pane it cannot build. The
|
||
startup list asks for its values the same way, so a directory parameter gets the
|
||
same field there.
|
||
|
||
### 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": "~", "type": "directory" }
|
||
],
|
||
"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.
|
||
|
||
A leaf's `kind` is `terminal`, `web`, or `review`. A `review` leaf takes a `cwd`
|
||
and nothing else — a review has no shell to start and no page to load, so the
|
||
only thing to say about one is which repository to point it at:
|
||
|
||
```json
|
||
{ "kind": "review", "cwd": "{{path}}" }
|
||
```
|
||
|
||
The directory is expanded exactly like a terminal's, so `{{parameters}}`, a
|
||
leading `~` and `$(a command)` all work, and opening the same layout against two
|
||
worktrees gives two different reviews. Leave it out — `{ "kind": "review" }`, all
|
||
a layout saved before this field existed says — and the pane opens with nothing
|
||
bound and says so: a layout has to name the directory itself, because the
|
||
terminals it would otherwise be read off have not started yet.
|
||
See [Code review](#code-review).
|
||
|
||
## 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.
|
||
|
||
## Restoring a session
|
||
|
||
Quitting in the middle of something should not cost you the arrangement you were
|
||
in the middle of. Every exit writes a **session snapshot** — a photograph of the
|
||
window as it stood — and the next launch that finds one puts a banner at the foot
|
||
of the sidebar: *Restore session*, and how many tabs are in it. Click it and
|
||
those tabs come back; press the ✕ and it goes away. Doing nothing is the same as
|
||
dismissing it.
|
||
|
||
This is the counterpart to the [startup list](#startup-tabs), not a replacement
|
||
for it, and the difference is the whole point. A startup entry is a **recipe** you
|
||
maintain: open this layout against these values, written down once because you
|
||
want it every morning. A snapshot is a **photograph** nobody asked for, of
|
||
wherever the tabs had actually got to. A recipe cannot describe a shell that has
|
||
been `cd`-ed three directories deep, and a photograph is not something you would
|
||
ever sit down and edit — so the app keeps both.
|
||
|
||
What is in the photograph is each tab's split tree: the shape, the divider
|
||
ratios, every terminal's working directory, every web pane's URL, the repository
|
||
a review was bound to, and the name and emoji the row was wearing. What is
|
||
deliberately not in it is anything that was *running* — scrollback, shell
|
||
history, a half-typed command, the processes themselves. Restoring puts the
|
||
arrangement back and leaves the prompts empty. That is the honest version of the
|
||
feature: a snapshot that claimed to bring a build back would be lying about the
|
||
first thing you would check.
|
||
|
||
Restoring **replaces** the tabs the window opened for itself, rather than adding
|
||
to them — a window holding both your startup tabs and the session they were
|
||
standing in for is two of everything. That is also why the offer only stands
|
||
until the tab set changes: open or close a tab yourself and the banner goes,
|
||
because "the tabs the window opened" has stopped being a set anyone can point at,
|
||
and closing it would be closing your work. The banner is the launch-time gesture
|
||
it looks like.
|
||
|
||
Closing every tab by hand takes the snapshot with it. That route out is a
|
||
deliberate one — there is nothing left worth offering to put back, and a banner
|
||
at the next launch offering the session before that would be answering a question
|
||
nobody asked.
|
||
|
||
The file is `session.json`, and it lives in the **state** directory rather than
|
||
beside `layouts.json` and `settings.json`:
|
||
|
||
```
|
||
~/.local/state/playpen/session.json ($XDG_STATE_HOME/playpen/session.json)
|
||
```
|
||
|
||
Those two are files a person writes; this one is written behind your back on
|
||
every exit, and putting that much churn in a config directory — which plenty of
|
||
people keep in version control — would make every quit look like an edit. Its
|
||
per-tab `root` is the same split-tree grammar a saved layout's is, so the two can
|
||
never drift into dialects of one shape, with the difference that a snapshot is
|
||
taken *after* parameter substitution: every path in it is literal, because it is
|
||
the directory a shell was really sitting in.
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"tabs": [
|
||
{ "name": "signal", "emoji": "🚀", "layout": "Project",
|
||
"root": {
|
||
"split": "horizontal", "ratio": 0.35,
|
||
"first": { "kind": "terminal", "cwd": "/home/you/src/signal" },
|
||
"second": { "kind": "review", "cwd": "/home/you/src/signal" }
|
||
} },
|
||
{ "name": "scratch",
|
||
"root": { "kind": "terminal", "cwd": "/home/you" } }
|
||
]
|
||
}
|
||
```
|
||
|
||
The `layout` a tab came from rides along even though it is not what reopens it.
|
||
It is there so that a restored window can still be captured by *Use current
|
||
tabs* — without it, restoring a session and then asking to keep those tabs at
|
||
launch would quietly write down a window of plain shells.
|
||
|
||
## Quitting
|
||
|
||
Closing the window asks first, and does so by default. A window here is not one
|
||
shell but a whole arrangement of them, and under a tiling compositor the binding
|
||
that closes it sits a modifier away from the ones that move focus — so there is
|
||
otherwise nothing between a mistyped chord and every shell in every tab exiting.
|
||
The dialog names how many tabs are about to go, which is also the quickest way
|
||
to notice you are about to close the wrong window.
|
||
|
||
**Keep Working** is the default response, and Escape lands on it too: the guard
|
||
should not itself be a keystroke to fumble. Pressing the compositor's close
|
||
binding again while the question is up dismisses it, which counts as canceling
|
||
— leaning on the key never costs the window.
|
||
|
||
Two closes don't ask, because both are already an answer: accepting the dialog,
|
||
and closing the last tab, which is a decision to close the window made one tab
|
||
at a time.
|
||
|
||
**Settings → Quitting** turns the confirmation off, which puts the window back
|
||
to closing the moment the window manager says so:
|
||
|
||
```json
|
||
{
|
||
"confirm_quit": false
|
||
}
|
||
```
|
||
|
||
## 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, and reorder by dragging a sidebar row, with the
|
||
rows shuffling live as you drag; closing the last one closes the window
|
||
- **A resizable sidebar**: the gutter beside it is a real divider, so drag it
|
||
to taste. Dragging it all the way in — or `Ctrl+Shift+S`, or the chevron at
|
||
the sidebar's foot — collapses the column to just each tab's emoji or icon
|
||
(hover a row for its name; the status bar and wash still show); the same
|
||
toggle expands it back to the width it had
|
||
- 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)
|
||
- **Review panes**: a GitHub-style review of the repository the tab is working
|
||
in — split/unified diff with syntax highlighting, a folder-tree file rail,
|
||
viewed marks and a progress meter, line/range/file/review-level comments, and
|
||
a commit list for reading a branch one commit at a time. One per tab, served
|
||
by a local HTTP API so the agent in the next pane can read your comments and
|
||
reply inline. See [Code review](#code-review)
|
||
- **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. A parameter can be declared a **directory**, and
|
||
is then asked for with a completing path field and a file chooser. 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)
|
||
- **A session to pick up where you left off**: every exit photographs the
|
||
window, and the next launch offers it back from a banner at the foot of the
|
||
sidebar — click to reopen those tabs, or dismiss it and carry on. Unlike the
|
||
startup list this is the arrangement you actually had, directories and all.
|
||
See [Restoring a session](#restoring-a-session)
|
||
- **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)
|
||
- **A desktop notification when a pane finishes**, for the tabs you are not
|
||
looking at — on by default, switchable from **Settings → Notifications**, and
|
||
mutable one tab at a time from its right-click menu, for fifteen minutes, an
|
||
hour, eight hours, or until you say otherwise. See
|
||
[Notifications](#notifications)
|
||
- **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` / `Ctrl+Shift+F`. Nothing closes and nothing moves — hidden
|
||
panes keep running and the split comes back exactly as it was. See [Zoom](#zoom)
|
||
- **Moving around a split from the keyboard**: `Ctrl+Shift+H/J/K/L` moves focus
|
||
to the pane in that direction, `Alt+Shift+J/K` steps through tabs. Every chord
|
||
in the app can be rebound in `settings.json`. See
|
||
[Rebinding a shortcut](#rebinding-a-shortcut)
|
||
- **A confirmation before the window closes**, on by default, naming how many
|
||
tabs would go with it — so a window-manager close binding can't end a session
|
||
by accident. Switchable from **Settings → Quitting**. See
|
||
[Quitting](#quitting)
|
||
- **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)
|
||
- **Every color in the palette editable**, per scheme, from **Settings →
|
||
Appearance → Customize**: the surfaces, the text on them, the accents, the
|
||
status colors, and the terminal's own background, cursor, selection and 16
|
||
ANSI colors. Each change repaints as you make it. See
|
||
[Customizing the palette](#customizing-the-palette)
|
||
|
||
### Shortcuts
|
||
|
||
| | | |
|
||
|---|---|---|
|
||
| `Ctrl+Shift+T` | new tab | `new_tab` |
|
||
| `Ctrl+Shift+E` | new terminal in the current tab | `new_terminal` |
|
||
| `Ctrl+Shift+B` | new web view in the current tab | `new_web` |
|
||
| `Ctrl+Shift+D` | review this tab's changes | `new_review` |
|
||
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) | `close_pane` |
|
||
| `Ctrl+Shift+H/J/K/L` | move focus to the pane left / below / above / right | `focus_pane_left` and friends |
|
||
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view | `move_pane_left` and friends |
|
||
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) | `paste` |
|
||
| `Ctrl+Shift+C` | copy the terminal selection | `copy` |
|
||
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | `rename_tab` |
|
||
| `Ctrl+Shift+Z`, `Ctrl+Shift+F` | zoom the focused pane to fill the tab, and back | `toggle_zoom` |
|
||
| `Ctrl+Shift+S` | collapse the sidebar to its emoji column, and back | `toggle_sidebar` |
|
||
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) | `find` |
|
||
| `Ctrl+,` | settings | `open_settings` |
|
||
| `Ctrl+PageUp/PageDown`, `Alt+Shift+K/J` | previous / next tab | `prev_tab`, `next_tab` |
|
||
| `Alt+1`..`Alt+8` | jump to tab N | `select_tab_1`.. |
|
||
| `Alt+9` | jump to the last tab | `select_last_tab` |
|
||
|
||
The third column is the action's name in `settings.json`, which is where any of
|
||
these can be changed — see [Rebinding a shortcut](#rebinding-a-shortcut).
|
||
|
||
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.
|
||
|
||
### Moving around a split
|
||
|
||
`Ctrl+Shift+H/J/K/L` moves focus between panes; the arrow keys on the same
|
||
modifiers move the *pane* instead. Which pane is "the one to the left" is
|
||
answered by where the panes are on screen rather than by walking the split tree,
|
||
so it means what it looks like — a tree walk would have to choose between a
|
||
sibling and a cousin, and on screen there is no such distinction.
|
||
|
||
Nothing happens at the edges of a view, and the key is swallowed rather than
|
||
passed on: the chord was bound for navigating, and handing it to the shell in
|
||
the last pane of a split would send a control code nobody asked for. Zoom is the
|
||
other case where focus stays put — the other panes are not on screen, and the
|
||
arrangement is exactly what zoom is hiding.
|
||
|
||
### Rebinding a shortcut
|
||
|
||
Every chord in the table above is a default, not a fixture. A `shortcuts` object
|
||
in `settings.json` names the ones to change:
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"shortcuts": {
|
||
"focus_pane_left": "ctrl+alt+h",
|
||
"toggle_zoom": ["ctrl+shift+z", "super+f"],
|
||
"copy": []
|
||
}
|
||
}
|
||
```
|
||
|
||
A value is one chord or a list of them, and an entry **replaces** that action's
|
||
defaults rather than adding to them — which is what lets a chord be moved from
|
||
one action to another without the old owner still answering to it. An empty list
|
||
switches a shortcut off entirely and hands the key back to whatever is running
|
||
in the terminal. An action the file doesn't mention keeps the chords this build
|
||
ships with, so a later version can retune the defaults and still reach anyone
|
||
who never touched them.
|
||
|
||
A chord is modifiers and a key, `+`-separated, in any order and in any case:
|
||
`ctrl`, `alt`, `shift`, `super` (`control`, `option`, `meta`, `win` and `cmd`
|
||
also read), then a key named the way you would say it — `h`, `7`, `left`,
|
||
`page_up`, `f5`, `escape`, `space`, `comma`, `bracket_left`.
|
||
|
||
**Every shortcut needs `Ctrl`, `Alt` or `Super`.** A bare key, or one with only
|
||
`Shift`, belongs to whatever is running in the terminal — claiming `F1` for the
|
||
window would take it from every program that has ever drawn a help bar. A chord
|
||
that doesn't parse, or an action name this build doesn't have, costs that one
|
||
binding and is logged; the rest of the file is read normally.
|
||
|
||
The file is rewritten whole every time a setting changes, so what you write here
|
||
comes back out of the settings page unharmed — in the app's own canonical
|
||
spelling, which is why `Shift + CTRL + H` is read once and written back as
|
||
`ctrl+shift+h`.
|
||
|
||
## 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
|
||
`Ctrl+Shift+F`, 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 color 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.
|
||
|
||
## Code review
|
||
|
||
A tab can hold a **review pane**: a GitHub-style review of the repository that
|
||
tab is working in, on `Ctrl+Shift+D` or the ✎ button in any pane's header.
|
||
|
||
It exists because reading a diff and driving an agent are the same session. The
|
||
agent runs in a terminal in the tab; the review is a pane beside it; you leave
|
||
line comments, say "address the review", and the replies appear in the pane while
|
||
you watch. Nothing is copy-pasted out of a terminal, and nothing has to be
|
||
switched to.
|
||
|
||
The UI is a web page (React, in `web/`), built by `zig build` and carried inside
|
||
the binary. It is served by an HTTP server this process runs — which is how the
|
||
agent gets at it too.
|
||
|
||
### One review per tab, bound to a directory
|
||
|
||
A review pane takes its repository from **the directory the tab is working in**:
|
||
the focused terminal's current directory, or the first terminal's if the focused
|
||
pane is not one. That is resolved once, when the pane opens, and then it stays
|
||
put. Re-resolving on every fetch was the alternative, and it means a `cd` in a
|
||
terminal can swap the diff out from under you mid-read; a review you have to
|
||
reopen is the better failure.
|
||
|
||
A **layout** names the directory itself, with a `cwd` on its `review` leaf (see
|
||
[Layouts](#layouts)). It has to: a layout's review pane is built alongside its
|
||
terminals rather than after them, so there is no working directory to read off
|
||
yet. It is resolved before any pane in the tab exists, so the review is already
|
||
attached to its repository by the time the pane's page loads.
|
||
|
||
A tab holds at most one. Two review panes would each be publishing a different
|
||
diff selection to the server, so an agent asked to review "the diff I'm looking
|
||
at" would follow whichever wrote last — the second pane would quietly break the
|
||
first. Asking for a review you already have takes you to it instead.
|
||
|
||
Comments live in `<git-dir>/playpen-review/reviews.json`. Inside the git
|
||
directory, so they never show up in the diff being reviewed, and so a worktree's
|
||
comments belong to that worktree rather than to the repository it was cut from.
|
||
Closing the pane leaves them there; opening another one in the same tab picks the
|
||
review back up.
|
||
|
||
### The server, and how an agent finds it
|
||
|
||
One server for the whole window, on `127.0.0.1:8420` (the next free port up if
|
||
that one is taken; `PLAYPEN_REVIEW_PORT` overrides where it starts looking). Each
|
||
tab is its own endpoint:
|
||
|
||
```
|
||
/t/<tabId>/ the review UI for that tab
|
||
/t/<tabId>/api/... that tab's review
|
||
/api/tabs every tab and what it is reviewing
|
||
```
|
||
|
||
The tab id in the path *is* the addressing. There is no repository parameter on
|
||
any call, so a request cannot land on the wrong review.
|
||
|
||
Every terminal pane is handed its own tab's endpoint as **`PLAYPEN_REVIEW_URL`**,
|
||
from the moment the pane opens — before any review pane exists, so an agent never
|
||
has to be restarted because you opened one after it. That variable is the whole
|
||
of the discovery step:
|
||
|
||
```bash
|
||
curl -s "$PLAYPEN_REVIEW_URL/api/review/pending" # what is waiting for you
|
||
curl -s "$PLAYPEN_REVIEW_URL/api/diff?base=main&uncommitted=true"
|
||
curl -s -X POST "$PLAYPEN_REVIEW_URL/api/comments/<id>/replies" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"body":"Done — it returns the error now.","author":"claude"}'
|
||
curl -s -X POST "$PLAYPEN_REVIEW_URL/api/comments/<id>/resolve"
|
||
```
|
||
|
||
The endpoints, all under `/t/<tabId>/api`:
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| `GET repo` | the repository, its refs, the comment counts, and the diff selection on screen |
|
||
| `POST repo/context` | what the page publishes when you change the base ref |
|
||
| `GET diff` | `base`, `uncommitted`, `commit`, `force`, `ignoreWhitespace` |
|
||
| `GET diff/revision` | a digest of what that same selection resolves to now — one hash, so the page can poll it; 503 while git is busy with work someone is waiting on |
|
||
| `GET file` | a file's contents at a ref, for expanding collapsed context |
|
||
| `GET/POST comments` | list, or open a thread |
|
||
| `PATCH/DELETE comments/{id}` | edit or delete one |
|
||
| `POST comments/{id}/replies` | reply on a thread |
|
||
| `PATCH comments/{id}/replies/{replyId}` | edit a reply |
|
||
| `POST comments/{id}/resolve`, `.../reopen` | close or reopen |
|
||
| `POST review/submit` | flip every draft to submitted |
|
||
| `POST review/reset`, `review/delete-resolved` | throw the review away, or just the finished threads |
|
||
| `GET review/pending` | the submitted, unresolved queue — what an agent works |
|
||
| `GET events` | server-sent events, so the page updates live |
|
||
|
||
A comment's status follows from who wrote it, and that is the whole rule. You
|
||
compose drafts and decide when to submit them, so a comment posted without an
|
||
author starts as a draft. An agent has no drafting step — it posts a review it
|
||
has already decided on — so `"author":"claude"` is born submitted: an open thread,
|
||
with no **Submit review** click standing between it and being read.
|
||
|
||
### The diff never moves under you
|
||
|
||
An agent editing files while you read is the normal case here, so the pane has to
|
||
have an answer for "the diff you are looking at is no longer the diff". Reloading
|
||
itself is not that answer: it would lose your scroll position, your place in a
|
||
hunk, and whatever you had half-typed into a composer.
|
||
|
||
So the page asks `GET diff/revision` every few seconds — a hash of the patch plus
|
||
`HEAD`, which catches both an uncommitted edit and work being committed out from
|
||
under the range. When it stops matching the revision the diff came with, a banner
|
||
says so and offers the refresh. Dismissing it keeps the diff you are reading and
|
||
re-arms against what is there now, so the *next* change tells you too. Polling
|
||
stops while the pane is hidden, and starts again the moment it comes back.
|
||
|
||
The poll is a `git diff`, though, and the interesting case is a review open in
|
||
seven tabs on a repository that is being built in the terminal pane next door.
|
||
Left to a fixed interval, that is a git process running in this app most of the
|
||
time, and the agent's `GET diff` waits behind polls whose answers nobody is
|
||
reading.
|
||
|
||
So the server sorts git into two kinds of work and only ever holds one of them
|
||
up. Anything with somebody on the other end of it — a diff being opened, the
|
||
**Refresh** you just clicked, an agent fetching the patch it is about to review —
|
||
runs the moment it arrives, every time, and is never queued behind anything. A
|
||
poll runs only while nothing is being waited on and the app is not already busy
|
||
with a few of them; otherwise it is turned away with a 503 on the spot. That
|
||
costs nothing, because a poll has no answer anybody is waiting for, and it means
|
||
a click does not merely jump the queue — it clears the field of polls for as long
|
||
as it takes.
|
||
|
||
Each pane also spaces its own next poll by what the last one actually cost, so a
|
||
pane on a slow repository asks less often on its own, and a refused one waits
|
||
longer still. A pane on a small repository never notices any of it.
|
||
|
||
Comments are the other half, and they work the other way round: those arrive over
|
||
`GET events` and are applied live, because a thread appearing in the rail doesn't
|
||
move anything you were reading.
|
||
|
||
### Comments are markdown
|
||
|
||
A comment body is markdown, rendered where it is read: fenced code (highlighted
|
||
by the same theme as the diff beside it), `inline code`, bold, italic,
|
||
strikethrough, links, bare URLs, nested and numbered lists with `- [ ]` task
|
||
items, headings, quotes and rules. Soft line breaks are hard breaks, as they are
|
||
in a GitHub comment box — comments are typed in lines, not in paragraphs.
|
||
|
||
It matters most for the comments you did not type. An agent writes the way it
|
||
writes — a claim, a fenced snippet of the fix, a link — and before this that
|
||
arrived as a wall of asterisks and backticks. Nothing about the stored comment
|
||
changed: the body is still plain text, in `reviews.json`, and the editor still
|
||
edits the text you wrote. Tables and reference links are not rendered; they show
|
||
as the text they were typed as. The renderer is `web/src/lib/markdown.tsx`, five hundred
|
||
lines and no dependency — the pane already owns a syntax
|
||
highlighter, and the shapes a review comment actually uses are a short list.
|
||
|
||
The rail's cards and a collapsed thread's one-line preview strip the markup
|
||
instead of rendering it: three clamped lines have no room for a code block, and
|
||
raw `**syntax**` reads worse than none.
|
||
|
||
### The two skills
|
||
|
||
`mise run install-skills` installs them into `~/.claude/skills`, keeping whatever
|
||
was there under `~/.claude/playpen-skill-backups` (outside the skills directory,
|
||
since anything inside it is loaded as a skill). `mise run uninstall-skills` puts
|
||
the originals back.
|
||
|
||
- **`address-review`** — pull the comments you submitted, edit the code, reply on
|
||
each thread, resolve it.
|
||
- **`leave-review`** — the loop the other way: the agent reviews the diff you are
|
||
looking at and leaves its own comments, as **Claude**, anchored to real lines.
|
||
It is the delivery mechanism, not the judgment: point it at a review skill you
|
||
have already tuned for a repo and it posts that skill's findings as inline
|
||
comments.
|
||
|
||
Together they close the circle. `leave-review`'s comments are open threads, so
|
||
they land in the same queue yours do, and "address the review" fixes them the same
|
||
way.
|
||
|
||
### The server runs on threads. Nothing else does.
|
||
|
||
`review.zig` is the one place in playpen with threads in it: the listener and each
|
||
connection get their own. That is not gold-plating — a request spends most of its
|
||
life inside `git diff`, and doing that on the GTK main loop would freeze the
|
||
window for the length of every fetch. Nothing under `src/review/` touches a
|
||
widget, and nothing in the widget tree touches a socket; the two meet at the tab
|
||
registry, which is what the mutexes there are for.
|
||
|
||
### Working on the UI
|
||
|
||
```sh
|
||
mise run web-deps # npm install, once
|
||
mise run web-dev # Vite on :5173, proxying the API to a running playpen
|
||
```
|
||
|
||
Then open `http://localhost:5173/t/<tabId>/` — the path is what tells the page
|
||
which review it is. `zig build` runs the production build itself and embeds the
|
||
result, so the bundle in the binary can never be older than the source it came
|
||
from.
|
||
|
||
## 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 color 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 colors 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.
|
||
|
||
### Notifications
|
||
|
||
The dots answer "which of these needs me?" for a window that is in front of you.
|
||
When it isn't — behind an editor, on another workspace, or on the other monitor —
|
||
a pane finishing also posts a **desktop notification**, titled with the tab's
|
||
name and carrying that pane's title as its body. That title is the useful half:
|
||
the hook sets it from your prompt, so what the popup says is the task that just
|
||
finished rather than "playpen".
|
||
|
||
It is on by default and **Settings → Notifications** switches it off, which
|
||
leaves the sidebar dots as the only word you get:
|
||
|
||
```json
|
||
{
|
||
"notifications": false
|
||
}
|
||
```
|
||
|
||
Nothing is posted for a tab you were already looking at — the window has the
|
||
focus and that tab is the one on screen — because the dot has already said it
|
||
and a popup about the pane in front of you is the fastest way to make someone
|
||
switch the whole feature off. Coming back to a tab also takes down whatever
|
||
popup it left in the tray, the same visit that clears its row.
|
||
|
||
**One tab at a time can be muted** from its right-click menu, for 15 minutes, an
|
||
hour, 8 hours, or until you unmute it. That is the answer for the one session
|
||
that finishes every ninety seconds while the other three are worth hearing
|
||
about; reopening the menu says how long the mute has left and offers the way out
|
||
of it. A mute is session state and is not written to the settings file — a tab
|
||
does not outlive the app, and neither should a decision about the next hour.
|
||
Neither mute touches the dots. Muting is about being interrupted; the row is
|
||
still how you find out what happened while you were away.
|
||
|
||
The notification is sent as a `GNotification`, so GIO picks whichever backend
|
||
the session has and the popup gets Playpen's name and icon from the installed
|
||
desktop entry — which means it looks right once `mise run install` has put that
|
||
entry in place. Nothing is attached to a click: the app is deliberately
|
||
non-unique, so no process owns `dev.greyson.playpen` on the session bus and a
|
||
daemon calling an action back would find nobody home. Clicking closes the popup;
|
||
getting back to the window is the compositor's job, and it already has a binding
|
||
for that.
|
||
|
||
### 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.
|
||
|
||
Every color it resolves to can be changed — see
|
||
[Customizing the palette](#customizing-the-palette).
|
||
|
||
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",
|
||
"confirm_quit": true,
|
||
"notifications": true,
|
||
"colors": {},
|
||
"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 color systems have to agree for that to be true, which is what
|
||
`appearance.zig` exists to arrange:
|
||
|
||
- **libadwaita's style manager** colors the stock widgets — popovers, entries,
|
||
dialog chrome. It is told to force a scheme, or left on `default` to follow
|
||
the desktop.
|
||
- **`style.css`** colors everything the app draws itself. It is written
|
||
entirely against named colors; `palette.zig` writes the `@define-color`
|
||
block that defines them for the scheme in force, and the two are loaded as a
|
||
single `GtkCssProvider`. No rule in `style.css` may hardcode a color — a
|
||
literal hex is a rule that looks right in whichever scheme you happened to be
|
||
testing in, and one the color editor cannot reach.
|
||
- **`theme.zig`** holds what Cairo draws the terminal grid from: the default
|
||
background, foreground and cursor, plus the 16 ANSI colors. The style tree
|
||
is never consulted there, so a CSS reload alone would leave every terminal
|
||
painted in the scheme it started in. It resolves the same table `style.css`
|
||
is fed from, and caches the result — the renderer asks for the background
|
||
once a frame and for the palette once a cell, and neither should be going
|
||
through the settings for an answer.
|
||
|
||
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 colors 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.
|
||
|
||
### Customizing the palette
|
||
|
||
**Settings → Appearance → Customize** opens the palette itself: a swatch for
|
||
every color named above, in sections — surfaces, text, tab rows, accent,
|
||
status, then the terminal's own background, foreground, cursor and selection,
|
||
and the 16 ANSI colors as a strip. Beside each row is the hex it currently
|
||
resolves to, so a palette can be read off as text rather than only picked at,
|
||
and a button that puts that one color back. Each section heading has the same
|
||
button for the section, and the foot of the list has one for the whole scheme.
|
||
|
||
Three things about how it behaves, all following from the same decision — that
|
||
the editor edits **the palette you can see**:
|
||
|
||
- **A change applies as you make it.** Every color in the list paints something
|
||
behind the dialog, so the swatch is its own preview; there is no OK button
|
||
because there is nothing pending to confirm.
|
||
- **It edits the scheme in force.** Which is why the way to edit the other one is
|
||
the theme picker directly above: switch to light, and the section switches with
|
||
it. Both palettes are kept, so an afternoon spent pinned to light doesn't cost
|
||
you the dark palette you built.
|
||
- **The terminal follows the window.** Left alone, the terminal's background *is*
|
||
the pane surface, its foreground *is* the body text, and its selection *is* the
|
||
muted accent — so recoloring the app recolors the terminal drawn inside it,
|
||
and the frame around a terminal keeps matching its contents. Setting one of
|
||
those explicitly breaks the link for that color and only that color.
|
||
|
||
Only what has been changed is stored, per scheme, under `colors` in
|
||
`settings.json`:
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"theme": "dark",
|
||
"colors": {
|
||
"dark": { "accent": "#ff8800", "sidebar": "#3a0d0d" }
|
||
}
|
||
}
|
||
```
|
||
|
||
That is a deliberate choice over writing whole palettes out. It keeps the file
|
||
short and hand-editable, it is what lets a row know whether it has been changed,
|
||
and it means the shipped defaults can be retuned in a later version and still
|
||
reach everyone who never touched them — rather than only the people who had
|
||
never opened the editor. A name this build doesn't know, or a value that isn't
|
||
`#rrggbb`, costs that one color and is logged; the rest of the file is read
|
||
normally.
|
||
|
||
`palette.zig` is where the names, the defaults and the grouping live, which is
|
||
why there is no longer a `palette-dark.css`. An editor needs to know what the
|
||
colors are called, what they started as, and which ones belong together, and
|
||
none of that can be read back out of a stylesheet without parsing it — so the
|
||
table answers all of it and the CSS is generated from the table.
|
||
|
||
## 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.
|
||
|
||
**Typed text is unreliable, chords and named keys are not.** `wtype` uploads its
|
||
own keymap for the characters it needs, and GTK goes on interpreting keycodes
|
||
with the keymap it already had until it catches up — so `wtype hello` lands as
|
||
mangled characters or as nothing, while `-k Tab`, `-k Down` and
|
||
`-M ctrl -k comma` land. Drive the UI with chords and named keys; check anything
|
||
that depends on what was *typed* with `zig build test` instead.
|
||
|
||
**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. Neither does the virtual-pointer
|
||
protocol: `wlrctl pointer move/click` reports success and the client sees
|
||
nothing. 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. They cover layout parsing, the
|
||
parameter/`$(...)` substitution pipeline, what a half-typed directory completes
|
||
to, the palette, the settings file, the shortcut table, the emoji table and its
|
||
search, and — for the review server — git's own output formats, the rule for
|
||
picking a base ref, and the round trip a review file makes through disk. Each is its own binary with its own root, since a
|
||
test binary has exactly one, and none of those roots links GTK, so they all run
|
||
without a display.
|
||
|
||
What is *not* covered is anything that wants a socket and a browser: the HTTP
|
||
layer, the SSE stream, and the review UI. Those were checked by running the app
|
||
in the headless compositor and driving the API with `curl` — see the note about
|
||
synthetic clicks above for why the pane itself has to be reached by keyboard.
|
||
|
||
`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).
|