Build in the review tool.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user