520 lines
26 KiB
Markdown
520 lines
26 KiB
Markdown
# Playpen
|
|
|
|
A proof-of-concept workspace built on
|
|
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
|
|
**vertical tabs**, each holding a split tree of terminal and web panes, with
|
|
**saved layouts** that open a whole arrangement — directories, scripts and all —
|
|
in one go.
|
|
|
|
The sidebar holds the window controls, a new-tab button, and one row per tab —
|
|
the layout Zen Browser uses for vertical tabs — with the content inset to its
|
|
right.
|
|
|
|
## Quick start
|
|
|
|
```sh
|
|
nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed
|
|
zig build run
|
|
```
|
|
|
|
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
|
|
|
|
## Installing
|
|
|
|
```sh
|
|
mise run install # builds release, installs into ~/.local
|
|
mise run uninstall
|
|
```
|
|
|
|
That puts the binary in `~/.local/libexec/playpen`, a launcher on `~/.local/bin`,
|
|
a desktop entry in `~/.local/share/applications`, and the icon in the hicolor
|
|
theme, then refreshes the desktop and icon caches. `mise tasks` lists the rest
|
|
(`build`, `run`, `fmt`, `screenshot`).
|
|
|
|
Two things the installer has to handle that a normal `install` wouldn't:
|
|
|
|
- **A launcher, not a symlink.** The binary links against GTK in the Nix store
|
|
and has an RPATH, so it runs anywhere — but a handful of things are found by
|
|
*path* at runtime rather than linked, and they have to be pointed at the Nix
|
|
copies or the app misbehaves in ways that look nothing like a packaging
|
|
problem. A desktop launcher starts the app with a minimal environment, so
|
|
those paths are baked into the launcher script, read out of the dev shell at
|
|
install time rather than hardcoded so they can't drift from the flake. See
|
|
[Running off-NixOS](#running-off-nixos) for what each one is.
|
|
- **Nix GC roots.** The installed app depends on store paths that
|
|
`nix-collect-garbage` would otherwise be free to delete, which would break it
|
|
later with no obvious connection to the command that did it. Install pins
|
|
every store path in the binary's RPATH, plus the by-path ones above, which
|
|
never appear in an RPATH; uninstall releases them.
|
|
|
|
### Running off-NixOS
|
|
|
|
Everything here is set up by the dev shell and baked into the installed
|
|
launcher, so it should be invisible — but each one cost a debugging session, so
|
|
it is written down.
|
|
|
|
- **GSettings schemas and icon themes** (`GSETTINGS_SCHEMA_DIR`,
|
|
`XDG_DATA_DIRS`). Without them GTK aborts on a missing schema, or renders
|
|
broken-image icons.
|
|
- **A TLS backend** (`GIO_EXTRA_MODULES` → `glib-networking`). GIO has none
|
|
built in; it loads one as a module. Without it every `https://` page in a web
|
|
pane fails with "TLS support is not available", while `http://` still works.
|
|
- **One consistent mesa** (`__EGL_VENDOR_LIBRARY_DIRS`, `LIBGL_DRIVERS_PATH`,
|
|
`GBM_BACKENDS_PATH`). This is the subtle one. WebKit's web process is linked
|
|
against Nix's `libgbm`, but libglvnd falls back to
|
|
`/usr/share/glvnd/egl_vendor.d` and loads the *host's* mesa as the EGL
|
|
vendor — and a GBM device created by one mesa is rejected by the other.
|
|
Nix's `libgbm` also looks for its backend under `/run/opengl-driver`, which
|
|
only exists on NixOS. Either one aborts the web process at startup with
|
|
`Could not create default EGL display: EGL_BAD_PARAMETER`, so web panes open
|
|
fine, begin loading, and then go permanently blank — the terminal side is
|
|
completely unaffected, which makes it look like an app bug rather than a
|
|
graphics one. Pointing all three at the Nix mesa fixes it; the real GPU is
|
|
still used, via `/dev/dri`.
|
|
|
|
## Which "libghostty" this uses, and why
|
|
|
|
Ghostty ships two different C APIs, and only one of them is usable here:
|
|
|
|
| | `include/ghostty.h` | `include/ghostty/vt.h` |
|
|
|---|---|---|
|
|
| name | "libghostty-internal" | **libghostty-vt** |
|
|
| scope | whole terminal incl. renderer | VT core only |
|
|
| platforms | `GHOSTTY_PLATFORM_MACOS`, `GHOSTTY_PLATFORM_IOS` | any |
|
|
| intended for | Ghostty's own macOS app | external embedders |
|
|
|
|
`ghostty.h` is the one that would hand you a ready-made terminal surface, but
|
|
it has no Linux platform tag at all — its `ghostty_platform_e` enum only knows
|
|
macOS and iOS, and its own header says it is "tailored to the needs of the
|
|
macOS app". On Linux, Ghostty does not embed itself through that API; it builds
|
|
its GTK apprt directly into the binary.
|
|
|
|
So this project uses **libghostty-vt**, the API Ghostty documents for external
|
|
embedders. That means we get, for free and battle-tested:
|
|
|
|
- escape sequence parsing and full terminal state
|
|
- screen, scrollback, line wrapping, and reflow on resize
|
|
- key and mouse event encoding (including the Kitty keyboard protocol)
|
|
- paste safety checking and bracketed paste encoding
|
|
|
|
and we supply everything above it: process management, rendering, and the UI.
|
|
|
|
We consume it as a **Zig module** rather than through the C ABI. Ghostty's
|
|
`build.zig` detects that it is being used as a dependency and, in that mode,
|
|
builds only libghostty-vt — no GTK, no executable — so `zig build` pulls in
|
|
just the VT core.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
main.zig AdwApplication, CSS loading
|
|
Window.zig sidebar + GtkStack of views, tab management, shortcuts
|
|
View.zig one tab's content: its panes, their layout, and drag handling
|
|
Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
|
|
Pane.zig content plus its header, drag source, and drop target
|
|
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
|
|
Browser.zig WebKitWebView plus a back/forward/reload/address bar
|
|
webkit.zig hand-written bindings for the WebKitGTK calls we make
|
|
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
|
|
OpenLayoutDialog.zig prompts for a layout's parameters
|
|
SaveLayoutDialog.zig turns the current tab into a saved layout
|
|
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
|
Pty.zig openpt/fork/exec, controlling terminal setup
|
|
key.zig GDK keyval -> libghostty-vt key mapping
|
|
theme.zig colors libghostty-vt has no opinion about
|
|
```
|
|
|
|
A tab is a **view**, and a view holds one or more **panes** arranged in a
|
|
**binary split tree**: every interior node is a split with an orientation,
|
|
every leaf is a pane. That is what allows the layouts a single shared
|
|
orientation can't express — three panes in a row, drag the rightmost to the
|
|
bottom, and you get two on top with one spanning the full width beneath them.
|
|
|
|
A pane holds either a terminal or a web view, behind a `Pane.Content` union.
|
|
Both kinds expose the same three operations (widget, focus, destroy) and report
|
|
back through the same three callbacks (title, exit, focus), so the split tree,
|
|
drag and drop, and focus tracking are written once and never branch on which
|
|
kind of pane they are moving. A web pane carries its own navigation bar rather
|
|
than putting an address entry in the pane header, because the header is the
|
|
drag handle and a text entry there would swallow the drags that rearrange the
|
|
view.
|
|
|
|
Each split node owns a `GtkPaned`, so dividers are draggable and every split
|
|
remembers its position as a **ratio** rather than a pixel count. The ratio is
|
|
the source of truth: it is re-applied whenever the available space changes, so
|
|
proportions survive window resizes, and only user drags update it.
|
|
|
|
Where a drop lands depends on how close it is to the view's own border. Near an
|
|
edge of the window the pane is placed against the whole layout and spans it;
|
|
anywhere else it splits just the pane under the pointer. That single rule gives
|
|
both "put this across the bottom" and "split this one in half".
|
|
|
|
Panes and split nodes each hold a strong reference to their own widget, so
|
|
detaching and reattaching during a rearrangement never finalizes anything.
|
|
Running terminals keep their scrollback and processes straight through a move.
|
|
|
|
**Drags rearrange live.** Each time the drop target changes, the move is
|
|
applied for real, so the layout under the cursor is always the layout you will
|
|
get — there is no separate drop indicator because the view itself is the
|
|
preview. Cancelling a drag puts the pane back: only the dragged pane ever
|
|
moves, so the rest of the tree is unchanged and re-inserting it beside its
|
|
original sibling restores the original shape.
|
|
|
|
## Layouts
|
|
|
|
A **layout** is a saved tab: an arrangement of panes, each with a directory and
|
|
a script, that opens in one go. Layouts take **parameters**, so one layout
|
|
serves any number of projects.
|
|
|
|
The layout button in the sidebar lists them. Picking one asks for its
|
|
parameters — prefilled with their defaults — and opens a new tab. A layout with
|
|
no parameters skips the prompt. The plain new-tab button is untouched: it still
|
|
opens one shell, immediately.
|
|
|
|
**You author layouts by arranging a tab.** Split it, drag panes around, drag the
|
|
dividers, then *Save tab as layout…*. That captures the tree, the split
|
|
orientations and the ratios exactly as they are on screen, and asks only for
|
|
what it can't infer: a name, the parameters, and each pane's script. There is
|
|
deliberately no separate layout builder — the split tree already is one.
|
|
|
|
The save dialog prefills what it can read off the live tab: each terminal's
|
|
current directory, straight out of `/proc/<pid>/cwd`, and each web pane's
|
|
current page. So the usual flow is to get a tab set up the way you like,
|
|
save it, and replace the literal paths with `{{parameters}}`.
|
|
|
|
**Editing** a saved layout opens the same dialog on the stored one, so its
|
|
name, parameters and per-pane scripts can be changed without opening it.
|
|
Renaming moves the layout rather than copying it, and renaming onto a name
|
|
another layout already has is refused instead of quietly replacing it. Nothing
|
|
is written until you confirm, so cancelling leaves the layout untouched. To
|
|
change the *shape* of a layout, open it, rearrange the tab, and save over it
|
|
under the same name — the same tools you used to build it in the first place.
|
|
|
|
Every `cwd`, `command` and `url` goes through `{{name}}` substitution, and a
|
|
leading `~` is expanded afterwards. An unknown `{{name}}` is left as written
|
|
rather than blanked, so a typo shows up in the pane instead of silently
|
|
producing an empty path.
|
|
|
|
**Scripts are typed into the shell, not run instead of it.** A pane starts your
|
|
login shell as usual, and the script is fed to it once it is ready. The shell
|
|
is still there when the script finishes, with your environment loaded and the
|
|
command in history. The wait matters: writing at spawn time loses the input,
|
|
because shells that set up line editing discard whatever was buffered while
|
|
they were initializing. The shell's first output is the signal that it is
|
|
reading, so that is when the script goes in.
|
|
|
|
Layouts live in `~/.config/playpen/layouts.json` (or `$XDG_CONFIG_HOME`), and the
|
|
file is meant to be edited by hand as well — *Reload from disk* picks up
|
|
changes. It is JSON because the app writes it too, and a format that
|
|
round-trips without a hand-written emitter is worth more here than a prettier
|
|
one.
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"layouts": [
|
|
{
|
|
"name": "Project",
|
|
"parameters": [
|
|
{ "name": "path", "description": "Project directory", "default": "~" }
|
|
],
|
|
"root": {
|
|
"split": "horizontal",
|
|
"ratio": 0.55,
|
|
"first": {
|
|
"kind": "terminal",
|
|
"cwd": "{{path}}",
|
|
"command": "git status"
|
|
},
|
|
"second": {
|
|
"split": "vertical",
|
|
"ratio": 0.5,
|
|
"first": { "kind": "terminal", "cwd": "{{path}}", "command": "nvim ." },
|
|
"second": { "kind": "web", "url": "https://github.com" }
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
A node is a split if it has a `split` key and a leaf otherwise. Saves are
|
|
atomic — written to a temporary and renamed — so an interrupted write leaves
|
|
the previous layouts intact rather than a file that won't parse.
|
|
|
|
Two design choices worth calling out:
|
|
|
|
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
|
|
watch (`g_unix_fd_add`). Terminal state is therefore only ever touched from the
|
|
main thread, so the renderer reads the screen with no locking. Ghostty itself
|
|
uses a dedicated IO thread; that is the right answer for a real terminal, but
|
|
this is dramatically simpler and is not a bottleneck at interactive speeds.
|
|
|
|
**No GPU renderer.** Ghostty rasterizes glyphs into an atlas and draws on the
|
|
GPU. Here, each frame walks the visible rows, groups cells into runs of
|
|
identical style, and hands each run to Pango. That is far more work per frame
|
|
in principle, but a terminal grid is small.
|
|
|
|
## What works
|
|
|
|
- Your login shell (from the passwd database, not `$SHELL`) on a real PTY,
|
|
started as a login shell, with a controlling terminal so job control,
|
|
Ctrl-C and SIGWINCH behave
|
|
- Full SGR rendering: 16/256/true color, bold, italic, underline,
|
|
strikethrough, inverse, and the bright-on-bold convention
|
|
- Block / bar / underline / hollow cursor styles
|
|
- Scrollback via mouse wheel; typing snaps back to the prompt
|
|
- Resize reflows the grid and notifies the child
|
|
- Window title (OSC 0/2) becomes the pane header and tab label; the tab shows
|
|
its pane count once a view holds more than one
|
|
- Tabs: create, close, switch; closing the last one closes the window
|
|
- Multiple panes per tab in an arbitrary split tree, rearranged by keyboard
|
|
or by dragging a pane's header, with draggable dividers between them;
|
|
closing the last pane in a view closes its tab
|
|
- **Web panes**, on WebKitGTK, sitting in the split tree beside terminals and
|
|
dragging around exactly like they do: back/forward/reload, an address bar
|
|
that takes a URL or falls back to a search, a load-progress indicator in the
|
|
entry, and the page title feeding the pane header and tab label
|
|
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
|
|
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
|
arranging a tab and saving it. See [Layouts](#layouts)
|
|
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
|
|
it is working, waiting on you, or finished and still unanswered. See
|
|
[Agent status](#agent-status)
|
|
- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row.
|
|
A typed name pins the label; clearing it hands the label back to the panes
|
|
- **Zooming a pane** to fill its tab and back, from the header button or
|
|
`Ctrl+Shift+Z`. Nothing closes and nothing moves — hidden panes keep running
|
|
and the split comes back exactly as it was. See [Zoom](#zoom)
|
|
|
|
### Shortcuts
|
|
|
|
| | |
|
|
|---|---|
|
|
| `Ctrl+Shift+T` | new tab |
|
|
| `Ctrl+Shift+E` | new terminal in the current tab |
|
|
| `Ctrl+Shift+B` | new web view in the current tab |
|
|
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) |
|
|
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
|
|
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
|
|
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
|
|
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
|
|
| `Ctrl+PageUp/PageDown` | previous / next tab |
|
|
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
|
|
|
|
Dragging a pane by its header does the same thing as the arrow shortcuts: the
|
|
edge you drop against decides both the order and the view's orientation. The
|
|
header is the drag handle rather than the whole pane so that dragging never
|
|
competes with the content's own mouse handling.
|
|
|
|
Both add-pane shortcuts are also buttons in every pane header, which is how you
|
|
open a web view without remembering the chord.
|
|
|
|
## Zoom
|
|
|
|
A split that was the right shape for watching two things at once is usually the
|
|
wrong shape for actually working in one of them. `Ctrl+Shift+Z`, or the
|
|
fullscreen button in a pane's header, gives that pane the whole tab; the same
|
|
again puts the split back.
|
|
|
|
This is not the window manager's fullscreen. The window keeps its decorations,
|
|
the sidebar stays put, and only the tab's own content area is involved.
|
|
|
|
Nothing closes and nothing is rearranged. The split tree is left exactly as it
|
|
was and only the rendering changes, which is what lets the restore be exact
|
|
rather than an approximation — ratios, orientations and ordering all come back
|
|
untouched, because they were never taken apart. Hidden panes are unparented but
|
|
still alive: their shells keep running, output produced while they were off
|
|
screen is waiting when they return, and their grids keep the size they had
|
|
rather than reflowing to nothing.
|
|
|
|
The toggle only appears once a tab holds more than one pane, since zooming the
|
|
only pane in a view would be an invisible state change. Splitting a pane or
|
|
moving one leaves zoom, because both of those actions are about the arrangement
|
|
that zoom is hiding. Closing the zoomed pane leaves zoom; a *different* pane
|
|
closing — a background shell exiting, say — does not, since what you are looking
|
|
at is still there.
|
|
|
|
## Agent status
|
|
|
|
Playpen is mostly used to keep several Claude Code sessions side by side, and
|
|
the question a sidebar full of them has to answer is "which of these needs me?".
|
|
So a tab can carry a dot:
|
|
|
|
| | |
|
|
|---|---|
|
|
| purple, pulsing | working |
|
|
| amber | waiting for you — a permission prompt, or a question |
|
|
| green | **finished, and you haven't answered it** |
|
|
| red | stopped on an error |
|
|
| none | idle |
|
|
|
|
Each colour is carried at three sizes, so it survives being glanced at rather
|
|
than read: an 11px dot on the row, a bar down the row's leading edge, and a wash
|
|
behind the whole row. Working gets the bar without the wash — it is the resting
|
|
state of a busy afternoon, and tinting half the sidebar all day would only teach
|
|
you to stop looking. Inside a tab, a pane that is asking colours its own frame
|
|
and header the same way, which is what picks it out of a four-way split.
|
|
|
|
The green one is the point of the feature. A session that has gone back to idle
|
|
looks exactly like one that never ran, so a pane that finishes latches green and
|
|
stays that way until you deal with it. Nothing else tells you a tab is worth
|
|
going back to.
|
|
|
|
**Opening the tab clears the row.** A pane's own dot clears when you go to that
|
|
pane or type in it. The two answer slightly different questions: the row's is
|
|
"should I go there?", which visiting settles whether or not you then deal with
|
|
everything inside, and a pane's is "have you dealt with me?", which only you can
|
|
answer. In a split that difference is the whole point — you open a flagged tab,
|
|
the row goes quiet, and the panes you haven't been to yet are still marked.
|
|
|
|
Work that finishes while you are sitting in the tab flags it too, deliberately.
|
|
Having a tab on screen when a session stopped says only that the pixels were in
|
|
front of you; watching it finish and then moving on to something else is the
|
|
case this is most needed for. It goes quiet as soon as you answer that pane, or
|
|
the next time you come back to the tab.
|
|
|
|
Green also outranks purple. A tab holding three sessions goes green as soon as
|
|
any one of them finishes rather than waiting for the last one to stop, because
|
|
"one of these is ready for you" is the news. The two states that actually want
|
|
something from you — amber and red — outrank it in turn.
|
|
|
|
Panes report this individually and the tab shows the most urgent of them, so a
|
|
four-pane tab still reduces to one dot. The pane headers carry their own dots,
|
|
so once the row has brought you to the tab, they say which pane inside it was
|
|
the one asking; the row stays lit until every finished pane in it has been
|
|
answered.
|
|
|
|
### How it gets there
|
|
|
|
Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same
|
|
one Windows Terminal and Ghostty use for taskbar progress — and their title with
|
|
OSC 0/2. Nothing about this is Claude-specific: a build that reports progress
|
|
lights the same dot.
|
|
|
|
Claude Code has no idea about any of this, so a hook tells it:
|
|
|
|
```sh
|
|
mise run install-claude-hooks # merges into ~/.claude/settings.json
|
|
mise run uninstall-claude-hooks
|
|
```
|
|
|
|
That installs `hooks/playpen-status.sh` into `~/.claude/hooks/` and wires it to
|
|
five events: `UserPromptSubmit` → working (and sets the tab title from your
|
|
prompt), `Notification` → waiting, `Stop` / `SessionStart` / `SessionEnd` →
|
|
idle. The red state is deliberately not wired to anything: the obvious
|
|
candidate, `PostToolUseFailure`, fires for tool errors Claude then goes on to
|
|
recover from, so a tab would turn red constantly during ordinary work. The
|
|
script still accepts `error` for anything of your own worth flagging that way.
|
|
|
|
Existing hooks are left alone — every entry is tagged with a marker
|
|
comment, so re-running replaces Playpen's own entries rather than stacking
|
|
duplicates, and the previous file is kept at `settings.json.playpen-backup`.
|
|
Hooks are read at startup, so open a new session to pick them up.
|
|
|
|
The hook writes to a pty, not to a socket or a daemon: the bytes land in that
|
|
pane and no other, with nothing to configure and no way for two concurrent
|
|
sessions to be mistaken for each other. It writes there rather than to stdout
|
|
because Claude Code parses hook stdout as the hook's JSON result; an escape
|
|
sequence written there would corrupt the hook protocol instead of reaching the
|
|
terminal.
|
|
|
|
Finding that pty is the one genuinely fiddly part, and `/dev/tty` — the obvious
|
|
answer, and what this used to do — is the wrong one. **Claude Code starts each
|
|
hook in its own session**, so a hook has no controlling terminal at all and
|
|
opening `/dev/tty` fails with `ENXIO`. It fails invisibly, too: `[ -w /dev/tty ]`
|
|
returns true regardless, because it stats a path whose mode is `0666` rather
|
|
than opening it, so guarding on that reports success and then writes into
|
|
nothing. Every state change was being dropped on the floor with no error
|
|
anywhere.
|
|
|
|
The pty is only one hop away, though — it is on Claude Code's own standard file
|
|
descriptors. So the script tries its controlling terminal by *opening* it, and
|
|
failing that walks up `/proc` for the nearest ancestor holding a pty, with a
|
|
`ps -o tty=` fallback for systems without `/proc`. The nearest ancestor is the
|
|
right answer even with something in between: a Claude running inside tmux inside
|
|
a pane finds tmux's pty, which is where its output actually goes.
|
|
|
|
A tab's title follows your prompt for as long as Claude holds the foreground.
|
|
Once it exits, the shell's own prompt sets the title back, which is the right
|
|
answer — there is no task any more. A name you type yourself outranks both.
|
|
|
|
### Inside a VM
|
|
|
|
Claude in a microVM (`smolvm`, `krunvm`, anything libkrun-based) still works,
|
|
because the guest console passes these bytes through to the host pty unchanged:
|
|
|
|
```
|
|
$ # written to the guest's console, observed on the host pty:
|
|
b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07'
|
|
```
|
|
|
|
This is the reason for choosing escape sequences over a socket. A unix socket on
|
|
the host is not reachable from inside a guest, and matching a message to a pane
|
|
by cwd breaks as soon as guest paths stop corresponding to host ones. A console
|
|
you already have is the one channel guaranteed to cross that boundary.
|
|
|
|
The guest has its own `$HOME` and so its own `~/.claude/settings.json`. Install
|
|
the hook inside the image rather than on the host — the script is dependency-free
|
|
POSIX `sh` and behaves identically on both sides:
|
|
|
|
```sh
|
|
smolvm machine exec --name NAME -- mkdir -p /root/.claude/hooks
|
|
# copy hooks/playpen-status.sh to /root/.claude/hooks/ via a mount or a heredoc,
|
|
# then merge the same five events into the guest's settings.json.
|
|
```
|
|
|
|
`jq` is used for the title when present and a `sed` fallback covers a minimal
|
|
image that has no `jq`; if the title can't be read the state still reports and
|
|
the tab simply keeps the name it had.
|
|
|
|
## Not implemented
|
|
|
|
This is a proof of concept, and the following are deliberately absent:
|
|
|
|
- **Mouse selection and copy.** libghostty-vt provides the selection and
|
|
mouse-encoding primitives, but nothing here is wired to them yet, so there is
|
|
no way to copy text. Paste works.
|
|
- **Ligatures and complex shaping.** Each run is drawn independently at a
|
|
fixed grid offset, so text that needs shaping across cell boundaries won't
|
|
look right.
|
|
- **Moving panes between tabs.** Panes can only be rearranged within their
|
|
own view.
|
|
- **Browser furniture.** Web panes get navigation and an address bar and
|
|
nothing else: no bookmarks, history, downloads, devtools, or find-in-page,
|
|
and each pane uses WebKit's default context, so nothing is persisted between
|
|
runs. Links that ask for a new window are ignored rather than opening a pane.
|
|
- **Restoring a session.** Layouts save arrangements you open deliberately;
|
|
nothing restores the tabs you happened to have open when the app closed.
|
|
- **Kitty graphics, hyperlinks, tab reordering.**
|
|
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
|
|
`ghostty`, since we don't install a terminfo entry.
|
|
|
|
## Development
|
|
|
|
`./b.sh` wraps `nix develop --command zig build` and strips the enormous
|
|
command line Zig prints on failure.
|
|
|
|
`mise run screenshot out.png "text to type"` (or `./shot.sh` directly) runs the
|
|
app inside a throwaway **headless Sway** and screenshots it. This keeps UI checks entirely out of your real
|
|
Wayland session — nothing appears on screen, and it works while the session is
|
|
locked.
|
|
|
|
One caveat when driving it: `wtype` loses the first keystroke of every
|
|
invocation while the compositor adopts its freshly uploaded keymap, so scripted
|
|
input should begin with a throwaway key. That is a quirk of the injection tool,
|
|
not of the terminal. Note that this applies per invocation, so a trailing
|
|
`wtype -k Return` in its own call is swallowed entirely — pass it as part of
|
|
the same `wtype` command as the text it submits.
|
|
|
|
Only plain text injection reaches the app. Modifier chords (`wtype -M ctrl`)
|
|
and synthetic clicks (`swaymsg seat - cursor`) are both accepted by the
|
|
compositor and never delivered to the client, so shortcuts and buttons can't be
|
|
exercised this way; to screenshot a state that a chord would reach, open it
|
|
from code instead.
|
|
|
|
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).
|