Build in the review tool.

This commit is contained in:
Greyson Parrelli
2026-08-24 09:24:01 -04:00
parent 4eef6a28d3
commit 407dd81512
55 changed files with 12932 additions and 40 deletions
+191 -11
View File
@@ -2,9 +2,13 @@
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, and a **startup list** that opens the ones you always want.
**vertical tabs**, each holding a split tree of terminal, web and **code review**
panes, with **saved layouts** that open a whole arrangement — directories, scripts
and all — in one go, and a **startup list** that opens the ones you always want.
A tab can hold a GitHub-style review of the repository it is working in, and the
agent in the terminal beside it can read the comments you leave there and reply
inline. See [Code review](#code-review).
The sidebar holds the window controls, a new-tab button, one row per tab, and a
settings gear at its foot — the layout Zen Browser uses for vertical tabs —
@@ -13,11 +17,15 @@ with the content inset to its right.
## Quick start
```sh
nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed
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
@@ -115,7 +123,15 @@ 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
@@ -199,8 +215,8 @@ 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,
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
@@ -303,6 +319,22 @@ A node is a split if it has a `split` key and a leaf otherwise. Saves are
atomic — written to a temporary and renamed — so an interrupted write leaves
the previous layouts intact rather than a file that won't parse.
A leaf's `kind` is `terminal`, `web`, or `review`. A `review` leaf takes a `cwd`
and nothing else — a review has no shell to start and no page to load, so the
only thing to say about one is which repository to point it at:
```json
{ "kind": "review", "cwd": "{{path}}" }
```
The directory is expanded exactly like a terminal's, so `{{parameters}}`, a
leading `~` and `$(a command)` all work, and opening the same layout against two
worktrees gives two different reviews. Leave it out — `{ "kind": "review" }`, all
a layout saved before this field existed says — and the pane opens with nothing
bound and says so: a layout has to name the directory itself, because the
terminals it would otherwise be read off have not started yet.
See [Code review](#code-review).
## Startup tabs
Opening the same three layouts against the same three worktrees every morning is
@@ -392,6 +424,12 @@ in principle, but a terminal grid is small.
that takes a URL or falls back to a search, a load-progress indicator in the
entry, and the page title feeding the pane header and tab label, plus
find-in-page on `Ctrl+F`. See [Find in page](#find-in-page)
- **Review panes**: a GitHub-style review of the repository the tab is working
in — split/unified diff with syntax highlighting, a folder-tree file rail,
viewed marks and a progress meter, line/range/file/review-level comments, and
a commit list for reading a branch one commit at a time. One per tab, served
by a local HTTP API so the agent in the next pane can read your comments and
reply inline. See [Code review](#code-review)
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
and scripts — opened in one go, parameterised by `{{name}}`, authored by
arranging a tab and saving it. See [Layouts](#layouts)
@@ -430,6 +468,7 @@ in principle, but a terminal grid is small.
| `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 |
@@ -619,6 +658,140 @@ The choice lives on the tab and not on disk, the same as a typed name: it lasts
as long as the tab does. Saving the tab as a layout does not carry it, since a
layout describes an arrangement of panes rather than what a row looks like.
## Code review
A tab can hold a **review pane**: a GitHub-style review of the repository that
tab is working in, on `Ctrl+Shift+D` or the ✎ button in any pane's header.
It exists because reading a diff and driving an agent are the same session. The
agent runs in a terminal in the tab; the review is a pane beside it; you leave
line comments, say "address the review", and the replies appear in the pane while
you watch. Nothing is copy-pasted out of a terminal, and nothing has to be
switched to.
The UI is a web page (React, in `web/`), built by `zig build` and carried inside
the binary. It is served by an HTTP server this process runs — which is how the
agent gets at it too.
### One review per tab, bound to a directory
A review pane takes its repository from **the directory the tab is working in**:
the focused terminal's current directory, or the first terminal's if the focused
pane is not one. That is resolved once, when the pane opens, and then it stays
put. Re-resolving on every fetch was the alternative, and it means a `cd` in a
terminal can swap the diff out from under you mid-read; a review you have to
reopen is the better failure.
A **layout** names the directory itself, with a `cwd` on its `review` leaf (see
[Layouts](#layouts)). It has to: a layout's review pane is built alongside its
terminals rather than after them, so there is no working directory to read off
yet. It is resolved before any pane in the tab exists, so the review is already
attached to its repository by the time the pane's page loads.
A tab holds at most one. Two review panes would each be publishing a different
diff selection to the server, so an agent asked to review "the diff I'm looking
at" would follow whichever wrote last — the second pane would quietly break the
first. Asking for a review you already have takes you to it instead.
Comments live in `<git-dir>/playpen-review/reviews.json`. Inside the git
directory, so they never show up in the diff being reviewed, and so a worktree's
comments belong to that worktree rather than to the repository it was cut from.
Closing the pane leaves them there; opening another one in the same tab picks the
review back up.
### The server, and how an agent finds it
One server for the whole window, on `127.0.0.1:8420` (the next free port up if
that one is taken; `PLAYPEN_REVIEW_PORT` overrides where it starts looking). Each
tab is its own endpoint:
```
/t/<tabId>/ the review UI for that tab
/t/<tabId>/api/... that tab's review
/api/tabs every tab and what it is reviewing
```
The tab id in the path *is* the addressing. There is no repository parameter on
any call, so a request cannot land on the wrong review.
Every terminal pane is handed its own tab's endpoint as **`PLAYPEN_REVIEW_URL`**,
from the moment the pane opens — before any review pane exists, so an agent never
has to be restarted because you opened one after it. That variable is the whole
of the discovery step:
```bash
curl -s "$PLAYPEN_REVIEW_URL/api/review/pending" # what is waiting for you
curl -s "$PLAYPEN_REVIEW_URL/api/diff?base=main&uncommitted=true"
curl -s -X POST "$PLAYPEN_REVIEW_URL/api/comments/<id>/replies" \
-H 'Content-Type: application/json' \
-d '{"body":"Done — it returns the error now.","author":"claude"}'
curl -s -X POST "$PLAYPEN_REVIEW_URL/api/comments/<id>/resolve"
```
The endpoints, all under `/t/<tabId>/api`:
| | |
| --- | --- |
| `GET repo` | the repository, its refs, the comment counts, and the diff selection on screen |
| `POST repo/context` | what the page publishes when you change the base ref |
| `GET diff` | `base`, `uncommitted`, `commit`, `force`, `ignoreWhitespace` |
| `GET 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 two skills
`mise run install-skills` installs them into `~/.claude/skills`, keeping whatever
was there under `~/.claude/playpen-skill-backups` (outside the skills directory,
since anything inside it is loaded as a skill). `mise run uninstall-skills` puts
the originals back.
- **`address-review`** — pull the comments you submitted, edit the code, reply on
each thread, resolve it.
- **`leave-review`** — the loop the other way: the agent reviews the diff you are
looking at and leaves its own comments, as **Claude**, anchored to real lines.
It is the delivery mechanism, not the judgment: point it at a review skill you
have already tuned for a repo and it posts that skill's findings as inline
comments.
Together they close the circle. `leave-review`'s comments are open threads, so
they land in the same queue yours do, and "address the review" fixes them the same
way.
### The server runs on threads. Nothing else does.
`review.zig` is the one place in playpen with threads in it: the listener and each
connection get their own. That is not gold-plating — a request spends most of its
life inside `git diff`, and doing that on the GTK main loop would freeze the
window for the length of every fetch. Nothing under `src/review/` touches a
widget, and nothing in the widget tree touches a socket; the two meet at the tab
registry, which is what the mutexes there are for.
### Working on the UI
```sh
mise run web-deps # npm install, once
mise run web-dev # Vite on :5173, proxying the API to a running playpen
```
Then open `http://localhost:5173/t/<tabId>/` — the path is what tells the page
which review it is. `zig build` runs the production build itself and embeds the
result, so the bundle in the binary can never be older than the source it came
from.
## Agent status
Playpen is mostly used to keep several Claude Code sessions side by side, and
@@ -927,11 +1100,18 @@ emit from and the client never sees it. Anything reachable only by clicking has
to be reached another way — a keyboard shortcut, or `zig build test` if the
thing being checked is logic rather than pixels.
`zig build test` runs the unit tests, which cover layout parsing, the
parameter/`$(...)` substitution pipeline, and the emoji table and its search.
They build as two binaries, rooted at `Layouts.zig` and `emoji.zig`, so neither
links GTK — a test binary has one root, and those are the two files worth
testing in isolation that have nothing to do with each other.
`zig build test` runs the unit tests. They cover layout parsing, the
parameter/`$(...)` substitution pipeline, 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