2026-08-26 23:24:14 -04:00
2026-08-14 21:21:13 -04:00
2026-08-27 13:32:47 -04:00
2026-08-26 23:24:14 -04:00
2026-09-08 14:13:53 -04:00
2026-08-24 09:24:01 -04:00
2026-08-27 11:11:49 -04:00
2026-08-11 14:26:12 -04:00
2026-08-13 08:51:03 -04:00
2026-08-24 09:24:01 -04:00
2026-08-27 13:32:47 -04:00
2026-08-27 13:32:47 -04:00
2026-08-11 14:26:12 -04:00

Playpen

A proof-of-concept workspace built on libghostty-vt 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.

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 + 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

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-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_MODULESglib-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:

{ "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":

"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": "~", "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:

{ "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.

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.

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, 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.

{
  "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:

{
  "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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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 / Ctrl+Shift+F. Nothing closes and nothing moves — hidden panes keep running and the split comes back exactly as it was. See 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
  • 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
  • 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
  • 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

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
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.

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:

{
  "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). 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:

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

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:

{
  "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:

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.

Every color it resolves to can be changed — see Customizing the palette.

The preference lives in ~/.config/playpen/settings.json (or $XDG_CONFIG_HOME), beside layouts.json, alongside the startup tabs:

{
  "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:

{
  "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 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:

./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.

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.

S
Description
No description provided
Readme
875 KiB
Languages
Zig 74.4%
TypeScript 16.9%
CSS 6.1%
Python 1.4%
Shell 0.8%
Other 0.4%