aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-28 15:38:41 +0200
committeruser <user@clank>2026-08-28 15:38:41 +0200
commita7a20bc026488928b6b65f44924b31615b898263 (patch)
tree37407e2ca9a7369bb02cd56e5bf5b5b779395fee
parentweb: the Windows port, on the front page and out of install.sh (diff)
downloadslosh-a7a20bc026488928b6b65f44924b31615b898263.tar.gz
slosh-a7a20bc026488928b6b65f44924b31615b898263.tar.xz
slosh-a7a20bc026488928b6b65f44924b31615b898263.zip
gitignore: DESIGN.md and .fastfeedback are local, not the project
DESIGN.md was a scratch design doc and .fastfeedback a per-repo tool cache; neither belongs in the tree. Both stay on disk, ignored. Everything that pointed at DESIGN.md now points at docs/design.md, and the decision counts quoted from it (which had already drifted: the README said twenty-one, the docs said twenty-two) are gone with it.
-rw-r--r--.fastfeedback/SPEED.md49
-rw-r--r--.gitignore5
-rw-r--r--DESIGN.md543
-rw-r--r--Makefile2
-rw-r--r--README.md2
-rw-r--r--docs/design.md4
-rw-r--r--web/index.html2
7 files changed, 9 insertions, 598 deletions
diff --git a/.fastfeedback/SPEED.md b/.fastfeedback/SPEED.md
deleted file mode 100644
index d62d13a..0000000
--- a/.fastfeedback/SPEED.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# SPEED — slosh
-
-All numbers measured on this box, 2026-08-14. Re-measure only if something
-structural changes.
-
-| what | command | time |
-|---|---|---|
-| build (incremental) | `make all -j8` | ~0.1s |
-| build (clean) | `make clean && make all -j8` | ~0.15s (zig caches objects) |
-| C unit tests | `./build/{input,kdl,shader}_test` | ~0.00s |
-| one python test | `cd tests && python3 test_shaders.py` | 0.6–7s |
-| **fast suite** | `make test` | **~5.5s** (parallel, `-j$(nproc)`) |
-| fast suite, nothing changed | `make test` | ~0.02s (per-file stamps) |
-| fast suite, one test touched | `make test` | ~1.1s |
-| force re-run everything | `make retest` | ~5.5s |
-| live suite (real ptys) | `make test-live` | ~5.7s |
-| coverage (second build, gcc) | `make coverage` | ~50s |
-
-## Notes
-
-- `make test` runs each `tests/test_*.py` as its own make target, so they run
- in parallel and an unchanged test whose binary has not changed is skipped.
- Stamps live in `build/.pass-*`, logs in `build/.log-*`.
-- Touching a **header** does rebuild the objects that include it (`-MMD -MP`).
- There is no reason to `make clean`; a clean build is 0.15s anyway.
-- `test_session.py` is a *live* test (real pty) and is excluded from `make
- test` on purpose — it runs in `make test-live`.
-
-## Writing fast tests here
-
-`settle N` is the only thing that reads a pane's pty, but it is a **duration**,
-i.e. a guess. `send` and `snapshot` are already synchronous with the app, so:
-
-- asserting on **our own chrome** (frames, guides, shaders, hints) after a
- mouse or key event needs **no settle at all**;
-- waiting for a **program's output** should use `s.until_text("...")` or
- `s.until(pred)`, which pump in 10ms steps and return the instant the
- condition holds;
-- anything gated on a **real clock** (hover dwell, toast expiry) is a genuine
- wait — so configure the clock down (`hover_delay_ms 20`, `toast_ms 150`)
- rather than sleeping for the default.
-
-Getting this wrong is what made the suite 80s. It is 5.5s now.
-
-## Not a trap
-
-`speedscan` flags `make test` as spinning up docker/compose. It does not —
-that is a false positive from the word `compose` (as in *compositing a
-screen*) appearing all over this codebase.
diff --git a/.gitignore b/.gitignore
index fb64896..6a3998e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,8 @@ web/slosh-preview.mp4
# the project. macos-release.mk.example is the template, and is tracked.
/dist/
macos-release.mk
+
+# Local dev tooling state, not part of the project: the per-repo speed cache,
+# and a scratch design doc.
+.fastfeedback/
+/DESIGN.md
diff --git a/DESIGN.md b/DESIGN.md
deleted file mode 100644
index eb3191b..0000000
--- a/DESIGN.md
+++ /dev/null
@@ -1,543 +0,0 @@
-# slosh — design
-
-An opinionated terminal multiplexer in C, built on
-[libghostty-vt](https://github.com/ghostty-org/ghostty).
-
-Status: **agreed contract, M0 in progress.** This file is what we build against.
-It supersedes the riff in `SEED.md`.
-
-## Why
-
-`sl0ppi` is a fork of zellij carrying 62 patches. Reading them, almost none of
-the work is *multiplexing* — it is fighting a codebase whose assumptions differ
-from ours, at ~5s per iteration on a good day and 5m16s for a release build.
-The valuable part is the **opinion**, and the opinion is small enough to
-implement directly against a VT library that already does the hard half.
-
-libghostty-vt is that library. What it gives us, from its C headers:
-
-- the full VT/ANSI state machine, scrollback, page compression
-- two-layer dirty tracking (global + per-row) — precisely our render loop
-- resolved per-cell fg/bg, styles, UTF-8 grapheme clusters
-- selection incl. semantic word/line/output selection
-- kitty graphics with placement tracking, OSC 8 hyperlinks per cell
-- key and mouse **encoders** (event → the bytes an app expects, honouring that
- pane's own mode state)
-- an effects callback table: `WRITE_PTY`, `TITLE_CHANGED`, `PWD_CHANGED`,
- `CLIPBOARD_WRITE`, `BELL`, `DESKTOP_NOTIFICATION`, `PROGRESS_REPORT`, `MODE`,
- and **`UNKNOWN_SEQUENCE`**
-
-**Correction, found by reading the header rather than the summary:**
-`UNKNOWN_SEQUENCE` today reports **APC sequences only**
-(`GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC` is the sole tag), not unknown OSC. So
-it is *not* a free OSC 5577 hook. Three consequences, all cheap:
-
-1. **OSC 5577 (D1) arrives via a side-channel scanner** on the pty byte stream:
- ~100 LOC that spots `ESC ] 5577 ; … ST` and lifts it out. The bytes still go
- to lib-vt, which discards an unknown OSC harmlessly, so nothing is drawn.
- The hook point is already marked in `pane_pump()`.
-2. **Protocol v2 should be APC**, not OSC — APC *is* delivered as a first-class
- effect, and no terminal will ever render it. The v1 OSC form stays forever
- for the existing pi extensions.
-3. Worth an upstream issue: a generic "unhandled OSC" callback. Mitchell's
- library, our use case, small patch.
-
-### lib-vt assumes nothing a host terminal would
-
-It is a VT *library*, not a terminal, so host policy is ours to set. Found by
-the cursor never appearing:
-
-- **DECTCEM (mode 25) starts off.** The cursor is invisible until the host says
- otherwise. We set it via `MODE_DEFAULT`, which updates the current value *and*
- the one restored by RIS, so a program that resets the terminal does not lose
- its cursor.
-- **Grapheme clustering (mode 2027) starts off**, so flags and ZWJ emoji occupy
- several cells. We default it on, matching the outer terminal we assume (D11).
-
-Both are set in `pane_new()`. Expect more of these; the rule is that anything a
-terminal emulator would decide is not decided for us.
-
-### The option-setting convention that will bite you
-
-`ghostty_terminal_set()` takes `const void *value`, and what that points at
-depends on the option's type:
-
-| header says | pass |
-|---|---|
-| `Input type: GhosttyTerminalWritePtyFn` | the function pointer **itself** |
-| `Input type: void*` (userdata) | the pointer **itself** |
-| `Input/output type: GhosttyTerminalModeConfig *` | a pointer to the struct |
-
-A pointer-shaped value is passed as itself. Passing `&fn` compiles, links, and
-stores the address of a stack local as the callback — which survives exactly
-until `pane_new()` returns, and then jumps into a dead frame. It went unnoticed
-until the first program asked the terminal a question (OSC 2, DSR); both paths
-now have regression tests.
-
-### What we own
-
-1. **Input decoding.** lib-vt encodes events *out* to a pane; nothing decodes
- what the outer terminal sends *in*. We write that parser: legacy keys, kitty
- CSI-u, SGR mouse, bracketed paste, focus events. Because we decode to a
- semantic event and re-encode per pane against *that pane's* modes, kitty
- keyboard passthrough works properly — which tmux and zellij both fumble.
- **Done** (`src/input.c`, ~500 LOC): 40 table-driven cases plus a live check
- that drives one client into two panes with different negotiated modes and
- asserts each receives the encoding *it* asked for.
-2. **Compositor + diff → ANSI.** Turning N panes of cells into a minimal byte
- stream for the real terminal.
-3. **pty spawn, event loop, layout tree, sockets, config.**
-4. **Kitty graphics re-emission** (id remapping across panes and scroll).
- **Done.** lib-vt tracks the images and placements; we walk them each frame,
- transmit an image once (`a=t`), place it every frame (`a=p,C=1`) after the
- cell diff, and delete placements that vanish. Ids are remapped into a high
- range so two panes using image `7` stay two images, and so nothing collides
- with images the client's terminal already had.
-
- The cropping rule is the part worth remembering: **a placement is N pixels
- drawn across M cells, so the placement itself tells you the pixels per
- cell** — no need to know the client's font metrics. Clipping at a pane edge
- or at the top of the viewport moves the *source rectangle*; reducing `c`/`r`
- alone scales the image into fewer cells instead of cropping it.
-
-Budget: **under 10k LOC**, zero deps beyond libc and lib-vt, static musl
-binary, sub-second full rebuild.
-
-## Two structural commitments
-
-These exist because of two lessons the sl0ppi fork paid for in days.
-
-### One geometry
-
-The `+` split button shipped twice while being invisible on screen, rendering
-correctly in 265 passing unit snapshots, for four independent reasons — three of
-them "the code that paints and the code that hit-tests derived the rect
-separately, and disagreed."
-
-> **Every painted interactive element emits its `(rect, action)` into a hit-list
-> as it is painted, by the same code, in the same pass. A click is a lookup in
-> that list.**
-
-Drawing and hit-testing cannot disagree, because there is only one of them.
-
-### Headless from day one
-
-From the sl0ppi roadmap, four separate times: *unit tests were necessary and
-insufficient.* Every real bug was found by reading the screen.
-
-> **`slosh --script` runs the entire compositor with no tty: commands in
-> (`send`, `raw`, `settle`, `resize`, `snapshot`), and the composited screen out
-> as JSON — rows of text, style runs, cursor, hit-list.**
-
-Tests are "drive these events, assert this screen". `make test` is **2.4s for
-59 checks**; `make test-live` keeps the handful that genuinely need a tty (raw
-mode, the diff emitter, SIGWINCH, the prefix key). The command vocabulary is
-deliberately the one M2's control socket will speak, so the harness moves over
-unchanged.
-
-Styles are emitted as **runs**, not per-cell objects: a test wants to say
-"columns 0..5 of row 0 are bold red", and 1920 cell objects per snapshot is
-unreadable.
-
-## Decisions
-
-| # | decision |
-|---|---|
-| **D1** | **OSC 5577 stays byte-compatible** with the sl0ppi fork, so the five existing pi extensions work unmodified. Delivered by a side-channel scanner on the pty stream (see the correction above), with a versioned `hello` handshake added on top — the fork's protocol is unversioned, which is a known risk — and an APC-based v2 once anything new is written against it. |
-| **D2** | **Config is a hand-rolled KDL subset** (`kdl.c`, 300 lines, no dependency): nodes with arguments, `key=value` properties, children, `//` and `/* */` comments, `;` terminators for single-line nodes. `config/config.kdl` documents every setting by *being* the defaults. A broken file costs a warning on stderr and nothing else; `{"cmd":"reload"}` re-reads it live and refuses a file it cannot parse rather than half-applying it. **Two schemas share the syntax** -- a config and a layout. Layout files were `*.layout.kdl` for a while, on the argument that the extension should say how to read the file; the argument lost to a collision: zellij speaks the same syntax under that same extension with a different schema, and to anyone arriving from there `.kdl` said *whose* file it is -- naming the wrong owner is worse than not naming the parser. So layout files are `*.layout` (a project's is `slosh.layout`): the name says what the file is, and `--check` says how it was read. The old spelling is still found by the project scan, never written. A name cannot enforce anything, so each loader also *says* which document it was handed (`this is a layout, not a config`, and the reverse), both answers measured against the same list of settings the loader reads. That list is what makes an unknown top-level node reportable at all: a loader that asks a document only for the keys it knows never notices what it was given, which is why a mistyped setting used to do nothing quietly and a whole layout linted clean. |
-| **D3** | **Clean JSON control API** (one JSON object per line on a unix socket): `{"cmd":"new-tab","purpose":"project:x.deadbeef"}` -> `{"ok":true,"id":2}`. We do *not* mimic `zellij action`'s surface. A bare-verb form (`panes`, `snapshot text`) is kept as a human/harness alias and runs the same code, so a script cannot drift from what the API does. The `sl0ppi` CLI is ported on top later; `up`'s idempotency and D9's fail-open property carry over. |
-| **D4** | **Scrollback yes** (free from lib-vt), **copy-mode UI later.** "Free" was true of the *machinery* and quietly false of the *size*: handed no limit, lib-vt picks its own — 10,000 **bytes**, which measured at 622 lines of an 80-column pane, less than one `make` run. A promise to keep history that keeps a screenful of it is the kind of thing a library default gets to decide only once. So `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling that count runs into: lib-vt applies whichever limit is reached first, and it has to, because a line count cannot see how wide the terminal is or how many styles a program used and a byte count cannot see how many lines that bought. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (it costs 2% of the lines), ~32 MB at 400 (it costs half). Invisible at ordinary widths and a brake at extraordinary ones, which is the job. There is deliberately **no unlimited**: the ceiling is per pane, sixty panes is sixty times it, and "unlimited" across that many is a memory leak with a friendly name — a negative is refused rather than wrapped into one. Both are read when a pane is created, so they apply to the next pane you open rather than to panes already holding output somebody is reading, which is the same rule and the same reason as `shell`. |
-| **D5** | Binary, session dir, socket and config are all named **`slosh`**. |
-| **D6** | **Responsive layout is a pure function** — see below. If *any* node cannot give its children their floor, the **whole tab** flattens — asked of the tab, answered once, because half a screen of panes beside half a screen of headers explains neither what happened nor what to do about it. The check is the real layout run with resizing switched off, not a second implementation of the same arithmetic. Flattening means: every pane under it becomes one row in a flat list with the focused one opened below them, rather than a nest of headers. Nesting is exactly what there is no room to express, and in that state a reader wants *which pane*, not *which arrangement*. It also makes every pane reachable — collapsing a subtree to a single header gave every leaf inside it the same rect and one hit for the first of them, so the rest could not be clicked at all. |
-| **D7** | **Server renders, client is dumb.** The client forwards raw bytes and paints what it is sent — it does not even decode, so the decoder can change without redeploying clients, and a bug there costs a frame rather than a session. The same socket is the scripting API. Detach/reattach is table stakes because agents keep running. |
-| **D8** | **Panes and tabs carry `purpose=`**, sl0ppi's semantics kept verbatim, including the trust model: *declared purposes outrank in-band ones and cannot be overridden*, so `cat hostile.txt` cannot relabel a project tab. Declared means "from a layout or an operator", i.e. the control path; the in-band path (OSC, M4) always passes `declared:false` and is refused against a locked slot. Sanitised on ingest to `[A-Za-z0-9_.:/-]`. |
-| **D9** | **No wasm plugins.** Status bar and pane finder are built in. A "plugin" is a subprocess speaking the control protocol — with exactly one exception, shaders, for the reason in D15. |
-| **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. |
-| **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. |
-| **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. We needed one: `vendor/patches/0001` stops a screen clear from freeing transmitted kitty images, which broke every program that uploads once and places per frame (see D17). Patches are applied to the committed tree and listed in the vendor json, so a build has them and a re-vendor knows what to re-apply. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. |
-| **D13** | **Shaders are a colour pass over cells, and *which* cells is the pass's, not the shader's.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so a content pass covers the contents and nothing else by paint order, not by a rule anyone has to remember. Frames are shaded by a pass of their own over a rect of their own (D19); no shader knows the difference, which is the point. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. **Which states ship a default is a line, not a mood**: a pane that is *not live* gets one — dead, suspended, scrolled, where the cells are showing something other than a running program's present and nothing else would tell you. `unfocused` is a taste rather than a fact and gets one anyway, through a knob of its own (`dim_unfocused`) rather than through the table: the objection to shipping a taste was never the taste, it was that turning it off should not require knowing the states block exists, and a single number fixes that. Writing the chain by hand still wins over the knob, including writing an empty one. `dragging` keeps no default, because the pane in your hand keeping its colour is what makes it lift off the page. The corollary is that a collapsed header, which the pass never reaches because a header is chrome, has to carry those same three facts in words. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. |
-| **D14** | **A pane that was given a command outlives it.** When such a program exits, the pane stays: it keeps everything it printed, writes two lines into its own backlog saying what it ran and how it ended (`[ran: npm run dev]`, `[process exited: status 3]`) -- the command because `[re-run]` is one button, and a pane that does not say what it would run makes pressing it a guess, most of all in a tab a project's layout built where the command was never typed into that pane to be scrolled back to; a pane running the session's shell has none to name and gets the exit line alone, says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. Which panes this applies to is the *command*, not the provenance: a pane told to run something is part of the session's shape and its death is news, while a shell you split off is finished when you type `exit` and a corpse to dismiss there is fussiness. A shell is a shell whether a layout made it or a keystroke did, which also means a session restored from `dump-layout` behaves like the one it was dumped from. `keep_dead` moves the line: `all` keeps shells too, `none` keeps nothing. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. |
-| **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into (and D16 has since removed most of the *need* for it). A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. |
-| **D16** | **A shader's `amount` may be an expression**, compiled to bytecode at config load. The scope is the argument: every built-in here is already `colour_op(amount)` with the amount a pure function of position, so the part worth handing to a config is the *number*, not the colour maths. That keeps mixing in C, gives an expression no way to produce an invalid cell (it produces one byte), and makes the result cacheable — a program that does not read the clock is evaluated once per cell into a map and reused until something it reads changes, so a config-written positional shader measures 5.2ns/cell against a compiled vignette's 6.1ns. Reading the clock costs 35ns/cell, per frame, which is the honest price of animation and is opt-in. The language is **total**: no loops, no recursion, no jumps — even `?:` evaluates both sides and selects, because nothing in it has an effect. So evaluation is O(program length), a config cannot spin, and there is nothing to sandbox; division by zero is 0 rather than a trap, because an expression that is wrong should look wrong rather than take the session with it. What a program reads is **derived by the compiler**, not declared, and that dependency set *is* the cache key — a stale map is unrepresentable rather than merely unlikely, which is the same rule the layout follows. An expression that does not compile drops that one shader with a warning: not the config, and not a half-strength version of what was asked for. **`above`/`below` joined the environment for the scrolled edge fades**: lines of scrollback hidden past the viewport's top and bottom edges, read from the same scrollbar the ▲ indicator uses, with a dependency bit of their own (`EXPR_DEP_SCROLL`) so a cached map is keyed on the scroll position and reused while it holds still — they change per scroll event, not per frame, so they cost no clock. The compiled-in `scrolled` default uses them to melt the viewport's top and bottom rows where content continues, `min(4, rows / 4)` deep (60 a row -- it shipped shallower and softer, and read as a rendering artefact rather than as "there is more this way") so a short viewport is not all fade; `(above > 0)` is a binary gate, so the edge you cannot scroll past renders solid, which is the message. They ride in `shade_ctx_t`, so `SLOSH_SHADER_ABI` went 3→4. |
-| **D17** | **Images need pixels, and we were not carrying any.** Kitty graphics worked in the test suite and not in real life, because three separate things all quietly assumed a cell has no size. `pty_spawn`/`pty_resize` left `ws_xpixel`/`ws_ypixel` zero, so a program asking the tty how big a cell is got nothing and guessed. `ghostty_terminal_resize` was called with `0, 0` for the cell size, so lib-vt could not work out how many cells an image covers when the placement did not say — and a placement that covers zero cells is dropped, which is a picture that silently does not appear. And the vendored lib freed image *data* on `ED 2`, so a full-screen program lost everything it had uploaded. The fix runs the whole way through: the client reads its terminal's pixel size (`TIOCGWINSZ`, re-read on every resize because a window can move to another monitor), `MSG_HELLO`/`MSG_RESIZE` carry it, the app hands it to every pane in every tab, and each pane gives it to both its pty and its terminal. Where nobody knows, the default is **8x16 rather than 0**: a plausible cell means an image appears at roughly the right size, and zero means it does not appear at all. Tested end to end, including that a program can read real pixel dimensions out of its own pty. **Re-emission has to preserve what the program meant, not what it currently covers.** Two things fall out of that. A placement carries a sub-cell offset (`X=`/`Y=`, where the image starts *inside* its first cell); dropping it is invisible in a still picture and makes anything moving jump a whole cell at a time. And `c=`/`r=` mean *scale into this many cells*, so they are passed on only when the program asked for them — sending the count a natural-size image happens to cover makes it change size as it slides across a cell boundary, because that count goes up by one. Cropping follows the same split: a natural image is cropped by the screen pixels the pane has left (measured from the offset, not the cell edge), a scaled one proportionally, because a lost cell is worth `source/cells` pixels there. **And a program may prefer to *ask*.** `TIOCGWINSZ`'s pixel fields are the first way to find the cell size and XTWINOPS (`CSI 14/16/18 t`) is the second, which is what a program reaches for when the pty fields come back zeroed — as they do on the far side of an ssh hop. We answered none of it, so the query timed out and the program fell back to a guess; 10x20 against a real 9x22 cell is every image stretched by a fifth, and it looks like a bug in the program. `GHOSTTY_TERMINAL_OPT_SIZE` answers all three from the pane's own geometry (lib-vt encodes the reply). Mode 2048, the in-band report on resize, already worked: it is answered from the size handed to `ghostty_terminal_resize`. |
-| **D18** | **We decode PNG ourselves, with the stb_image we already vendor.** lib-vt ships no image codec: built as a library its `sys.decode_png` hook is null, and a null hook rejects every `f=100` transmission outright — no image stored, no placement, nothing to re-emit. A program that uploads a PNG (which is what a program with a picture on disk does) drew nothing at all, silently, while raw RGB worked. Found because ida-tui's startup logo never appeared. PNG is the only format this can ever be about: the protocol has exactly three (`f=24`, `f=32`, `f=100`) and the first two are raw pixels lib-vt already handles, and `decode_png` is the only codec hook it has — so there is no scope to creep. Decoding only, never encoding; the client is handed RGBA. The decoder is `vendor/libghostty-vt/src/stb/stb_image.h`, which is **already in the tree and already pinned** by the vendor commit — upstream compiles that same header with the same `STBI_ONLY_PNG` for the same reason (`src/stb/stb.c` says so), it is simply not part of the `-Demit-lib-vt` build. So this is not a new dependency, it is a file we already ship finally being compiled, and `src/png.c` is a hook and a copy around it. Hand-rolling a subset decoder was considered and rejected: the bulk of a PNG decoder is a DEFLATE inflate, and "just the colour type ida-tui uses" breaks on the first palette PNG, which is what every png optimiser emits. That is a lot of new bounds-checked parsing, fed by whatever a pane decides to send, to replace a file already in the repo. Two consequences to know: we re-emit **decoded** pixels, so a 819KB logo goes to the client as 3.3MB of base64 (once per image, again after a reattach clears `sent`) — passing the original PNG through would be cheaper but lib-vt hands back only the decoded form; and lib-vt caps image storage at **10MB** in library builds, so images are bigger than they look. |
-| **D19** | **The finder and the command palette are one picker with a subject.** They are the same object — a modal, a query, a filtered list, one selection, the same keys and the same mouse rules — differing only in what fills the rows and what Enter does with one. Building the second as its own machine would have meant two of everything, and the pair would have drifted the way the pane title editor and a tab label editor would have if they had not been made one thing with a subject. So there is one `picker` state (`PICK_FINDER`/`PICK_PALETTE`), one draw, one key handler, and a `pick_row_t` of three columns that both fill: a dim left (a tab, a group), the thing itself, and a dim right (a purpose, the chord). Choosing goes through the row's own action string, so the keyboard and the mouse cannot take different paths to the same row. **The palette lists actions that are not bound to anything**, which the cheatsheet by design cannot — it is built from the binds, so an action with no key does not appear, and that is exactly the action you have no other way to reach. Where a binding does exist the palette shows it, so using the palette teaches the key that would have skipped it; where several exist it shows them all, because a config's binds add to the defaults rather than replacing them and naming only the first would hide the one the user chose. Two actions are left out: `literal-prefix`, which means "type this key" rather than "do this thing" and would send the prefix to a pane hidden behind the list, and `select-tab-N`, which is positional — a row saying "go to that tab" cannot say which. Running is by `action_t`, not by synthesising the keystroke that would have caused it: a palette that had to invent input to do its job would be lying to the rest of the program about what happened. |
-| **D20** | **Chrome is a rect too, so it is a pass too.** `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's **frame** — border, padding, title, buttons — instead of its contents. Nothing else changes: same registry, same `amount`, same expressions (D16), same chain order, one new word. It is one pass over the frame's *whole* rect with the contents punched out as a hole, not four passes over four sides: an effect has to be able to travel round a border, and four passes would each count `x`/`y` from zero and restart at every corner. The hole is what keeps the two honest — a skipped cell is never visited, so a full-strength chrome tint cannot reach your text and a full-strength content tint cannot reach the border. The chains are stored separately rather than as a flag on each shader, because a pass *is* a chain and a rect, and because `shader_abi.h` has no business knowing where a session chose to run an effect. This is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. **Animation needs a clock**, so `app_next_deadline_ms` reports one while a chain that reads `t` ran over the last frame — derived from what was applied, like everything else about a frame, so a pulse hung off `dead` costs `anim_ms` while a pane is dead and nothing for the rest of the session. Without it a border froze the moment you stopped typing, which is the bug that makes an animated shader look broken rather than absent. A **`bell` pane state** and a **`since`** variable came with it, because "flash the border when a pane rings" needed both and neither existed: a bell had no state to hang a chain off, and `t` says what time it is rather than how long ago something happened, so a one-shot effect was not expressible at all. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state — a transition is the one thing a frame cannot tell you, so it is remembered, but it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is (the frame clock stops with it). **Shipped on by default**: the compiled-in `bell` state is two tints in theme's `bell` colour whose amounts read `since` — on the frame, three full-strength blinks over the first 900ms and then a slow breathe; on the body, a quiet shimmer — one soft diagonal sheen gliding across, peak strength 95 against the blinks' 255, its colour softened halfway towards `default_fg` (light, not paint), its width breathing on a sine as it travels, gone within those same 900ms — so what you are reading is only touched, gently, while the announcement lasts. Derived after the theme block the way the scrolled wash is, so a theme moves the mark, the flash and the shimmer together. The original objection was that an animated default would spend a frame clock on every session, and the clock's own derivation is what answers it: `since` carries `EXPR_DEP_TIME`, so the `anim_ms` clock runs only while a rung, unanswered pane is actually on screen — an idle session pays nothing. It follows `bell_indicator false` off (that knob's promise is silent *and* invisible), and `states { bell { … } }` — including an empty one — replaces it like any state, tracked across includes like `scrolled`. **`channel="fg"|"bg"|"both"`** came from the first border flash actually being looked at: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our *guess* at that default and mixed towards the colour — a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not by the shader: the cell's other colour is snapshotted before materialising and put back after, so it works for a built-in and a plugin alike, an unset colour goes back to *unset* rather than to our guess, and no shader grows an fg-only variant of itself. It is one byte tested once per pass, not per cell, and 0 means both so every shader built before it behaves as it did. |
-| **D21** | **A project is a directory; a workspace is the tab it occupies.** Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. The directory is found rather than named: a `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `slosh.layout` — or a `.git`, which is the project that has not said what it needs yet, and without which `~/dev` with forty checkouts and three layout files is a picker with three rows. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. **Identity is the tab's `purpose`**, `project:<name>.<hash8>` of the resolved path — the shape D3 already published — so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone rather than letting that be a surprise later. **Nothing watches the filesystem.** The list is derived when it is asked for — one `readdir` per root and two `stat`s per entry, forty checkouts for eighty-one syscalls next to the keystroke that asked — and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. The one thing a scan cannot see is a project's layout file changing under a workspace that is already open, and that is deliberately not answered by re-applying it over running processes: `workspaces` reports the file's `mtime`, and drift is derivable outside without this session storing a byte. **Opening is idempotent** (`sl0ppi up`'s property, D3): the same request twice is one workspace, focused, and the answer says which branch it took so a caller never has to ask first. **Saving is the inverse, and it is the whole of onboarding.** `save-workspace` is `dump-layout` for one tab, relative to the project, written beside it — the same dumper, so there is one answer to "what does this tab look like written down". Four things made that possible and none of them were true before: a relative `cwd=` in a layout file now resolves against that file's directory (the rule `include` already followed, applied to the other half of the same syntax), a dump can be asked for one tab against a base, a pane from a split now starts where the pane it came out of stands rather than where the server was launched — which had been quietly writing an absolute path to somebody else's directory into every saved project — and **a dump now records what a pane is actually running**. That last one is what turns arranging a project and writing it down into one act instead of two: `label` only ever knew what a *layout* said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell. The kernel knows what the label does not — a pty has a foreground process group, and that group is by definition the job that owns the terminal — so the answer is read rather than guessed, the way `live_cwd` already reads `/proc/PID/cwd` rather than trusting where a pane was started. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal, and resurrecting `&` jobs in the foreground describes a session nobody had), and an ephemeral pane (restoring one reopens a file somebody finished with). A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` is run through `/bin/sh -c` and an argument with a space in it would otherwise come back as two. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact, and starting a dev server on every open is exactly what `suspended` exists to prevent — which is worth much more now that there are commands to suspend. What is **not** built: no guessing commands from `package.json` or `Cargo.toml` (a table of guesses that is wrong on day one and stale by year two — recording what you actually ran makes it unnecessary), no watcher, and no auto-save on close, because a layout file is a commit-sized decision. |
-| **D22** | **A pane can float**: lifted out of the layout, drawn over the tiled panes and under the modals, freely moved and shaped. The model is a flag, a wanted rect and a raise stamp on the leaf — exactly the `minimized` pattern, not a second collection: the seat in the tree is kept, `layout()` skips it, the siblings absorb its share, and un-floating returns it home, so everything that walks leaves (the finder, `close_leaf`, the dump, the D6 flatten) works with zero new cases. **The drawn rect is derived, the wanted rect is remembered**: what is painted is the intent clamped into the tab every frame and the clamp never writes back, so a terminal squeeze round-trips exactly — the same lesson the weights taught — while an explicit move or resize *does* write the clamped answer back, because the hand is the author of the intent (and is refused while the tab is flattened or zoomed, where the shown rect is not a place to return to). A resize pins the **grabbed edge** at walls and floors and never moves the opposite one — the general clamp preserves size by sliding the rect, which turns "drag the right edge past the wall" into the window walking left — and which edges a grab holds is derived from where the press landed (`float_edges_at`), shared by the press, the hover hint and the guide, so a bottom corner is two edges with no corner target to register and the promise cannot disagree with the drag. **Floats paint after everything tiled and before the modals**, so the backwards-searched hit list makes the z-order and the click order the same list — the one-geometry commitment paying out as "no routing code to write" — and before the status line, which reads that list for its hover hint and must not hint a float's border as the pane underneath. Focusing a float raises it, stamped in `layout()` once rather than at each place focus can move. **Floating pops the pane to the centre**: the jump is what says the float happened — lifted in place it looks like a pane whose neighbours flinched — so position is the announcement and only the size is remembered. The shadow is what tells a float from a tile (the frame is the same frame): `float_shadow`, a number and not a state chain, for the `dim_unfocused` reason — turning it off must not require knowing the states block exists. A `floating` state joined the D13 table **above `unfocused`**, shipped empty: a float is never dimmed as merely-unfocused, because full strength is what keeps the thing on top reading as lifted (the `dragging` argument). Guards, all of one invariant — **an overlay needs a backdrop**: the last tiled pane cannot float or be minimised away, and closing it lands the top float (`ensure_tiled`, checked once per frame in `layout()` like the minimised-focus rule). A float's splits are refused through `split_fits`, which reaches the guide, the border click and the keyboard in one place; it is no swap target, because its tree seat is not where it is. The keys: `f` floats (what "float" sounds like; the finder moved to `s`, where it reads as *search*), `F` opens a **new floating shell** — the throwaway terminal, a real leaf beside the focused pane so un-floating lands it and D14's shell rule closes it — `H J K L` move a focused float, the `=`/`+` key grows it about its own centre and `-` shrinks it (its split was a refusal anyway); equalize moved to `0` because a legacy terminal reports `+` as a bare `=`, and two verbs on one key survive only until the shift is dropped. **The memory (M10c)**: a float survives being written down. `dump-layout` records `floating=true x= y= w= h=` — the *wanted* rect, because intent is what survives a session and the next screen's layout clamps it like any frame — layout files declare the same, and a file whose every pane floats self-heals through `ensure_tiled`, the invariant holding itself with no lint required. `float` over the API takes `x y w h` and then *places* rather than toggles — a placement is a statement about where, not about whether — with omitted fields meaning "keep", and the intent stored exactly as asked (the write-back rule is for hands, whose drags need the clamp to agree with the eye; a script means what it typed). And **kitty graphics respect the stack**: the cell compositor gets occlusion free from paint order, but placements are sent after the diff, so each pane's placements are clipped by the float rects above it — crop what one clean edge can express (the pane-edge source-rectangle arithmetic aimed at another clipper, including from the top and left, where the source origin moves), and suppress what it cannot: a float across the middle or on a corner leaves a shape one placement cannot say, and it returns when the float moves. No `z=` needed — clipping *is* the z-order, since nothing is ever allowed to overlap. |
-| **D23** | **Compact mode: shared borders instead of gaps** (`compact true`). One knob, because the request is one idea: the boundary between two panes costs one cell instead of four (a border, a gap's two columns, a border) — on an 80×24 screen with four panes, the difference between 54% of the cells being content and 74%. Panes pack flush against 1-cell divider lines, one frame rings the whole tab, and a pane's title, buttons and scroll indicator ride the line *above* its rect — a line every pane has by construction, the ring for the top row and a divider for everyone else — so the tab strip and the status line give up their surrounding air too. The lines are drawn as **strokes**: a write says which of the cell's four edges it needs (up/down/left/right), unions with whatever line is already there, and never touches a cell holding anything that is not a line. So a divider running into the frame makes a ├ and two dividers crossing make a ┼ with nobody computing junctions; the focused pane's ring is *recoloured* by re-stroking — adding no arms of its own, because an arm the structure does not have is exactly what the minimised bar's rows must not grow — and a title drawn onto a line later simply wins its cells, the paint-order rule the hit list already lives by. The geometry underneath is the old geometry: the divider *is* the gap, one cell wide whichever axis (`eff_gap`, the one answer the layout, `split_fits` and the resize clamp all read), so the gap hits, the hover hints, the corner-finding and the corner drag work unchanged — the divider drags exactly as the gap did because it *is* the gap, with a line drawn through it. What compact costs, on purpose: **interior edges give up click-to-split** — a one-cell line cannot honestly hold two verbs, so the whole of it is the resize handle; the outer frame keeps its handles, the keyboard splits anything, and a pane whose title line is a shared divider is still dragged, by its name. And a pane's **status row overlays its last content row** — a dead pane's epitaph and its `[re-run] [close]`, a program's OSC 5577 row — there being no bottom border left to carry it, which is why draw_cb paints it after the pane composes rather than draw_frame before. The hover hint had to learn to ask (`edge_owns`): each dotted cell is painted only if the hit list still says the boundary owns it — the top-row furniture rule, applied to a line that now carries a neighbour's name — which is also why the hints moved to a pass after the panes, where the list is finished. Floats keep the classic frame — an overlay needs its own edge — and so do a zoomed pane and the D6 flatten, through the `place_hidden` paths the layout already had: compact adds zero new tree cases, the D22 property inherited rather than re-earned. Chrome passes (D20) run over the pane's ring of shared line, one cell out; a line between two panes belongs to both, so a chain that recolours an unfocused frame will touch the focused pane's stretch of a shared line — two owners is what sharing means, written down rather than papered over. |
-
-### D6 — responsive layout as a pure function
-
-A grid of panes squeezed onto a small screen is unusable, so we need this. But
-zellij does it with swap layouts + a remembered position + synthesized stack
-layouts, and it produced the roadmap's "panes stacked after a collapse/expand
-cycle" bug (a feasibility check counted `visible_panes_count()`, which a stack
-lies about).
-
-Instead:
-
-```
-layout(tree, rect) -> (rects, collapsed_set) recomputed every frame
-```
-
-Each split node carries a min-size policy. When its rect cannot satisfy its
-children, **that node** flips mode locally:
-
-- `split` — children side by side (normal)
-- `stack` — children collapse to title rows, one expanded
-- `rail` — one child takes the bulk, the rest become a monitored strip
-
-No `.swap.kdl`, no remembered state, no synthesized layouts. The bug class is
-unrepresentable because there is no state to go stale, and `rail` is the thing
-the fork kept almost-reinventing: with agents you want *one big pane you are
-reading* plus *a strip of small ones you are watching*.
-
-**As built (M5).** `split` and `stack` are in; `rail` is not needed yet,
-because a stack of headers *is* the monitored strip:
-
-```
- 1 2 + 5 panes
- pi · ready───────────────────────────────────────────
- pi · ready───────────────────────────────────────────
- pi · ready───────────────────────────────────────────
- ╭────────────────────── pi ────────────────────────+─╮
- │ │
- ╰ ready ──────────────────────────────────[continue]─╯
-```
-
-A header row carries the pane's title *and its OSC 5577 status*, so a
-collapsed agent is still legible. Three properties that fall out of the design
-rather than being coded:
-
-- the expanded child is simply the one holding focus, so **focusing a
- collapsed pane expands it** — including from the finder, and by clicking its
- header;
-- **a hidden pane is never resized**, so its program does not reflow while you
- are not looking at it;
-- a narrow/wide cycle returns the *exact* rects it started with, and a pane
- added afterwards is not stacked. That is the fork's bug, asserted directly.
-
-When not even one header row per sibling fits, the node degrades to showing
-only the focused subtree. A pane one cell tall helps nobody, and rects that do
-not fit on the screen help less.
-
-### Connections are not clients
-
-The server holds a set of connections. A connection becomes *the* display client
-only by sending `MSG_HELLO`; anything else — a control command, `ls` probing
-whether a socket answers — is just a connection, and never displaces anybody.
-Only the display client's `MSG_INPUT` is honoured.
-
-This is not fussiness, it is the fork's worst bug rebuilt correctly. From the
-sl0ppi roadmap: `zellij action` failed 20-25%% of the time because every action
-probed liveness by connecting, *that probe counted as a client*, and its
-teardown removed the real one. We reproduced it exactly — `slosh ls` kicked
-the attached client off — within an hour of the server existing. The regression
-test runs 20 x (`ls` + control command) against a live client and requires 0
-failures and a client that can still type.
-
-## Shape
-
-```
-slosh-server (one per session; holds ptys + lib-vt terminals + layout)
- ├── epoll: pty fds, client socket, control socket, signalfd, timerfd
- ├── pane[] = { pty fd, GhosttyTerminal, purpose, chrome, hit-list }
- ├── layout tree (tabs -> split tree -> panes; stack/rail are node modes)
- ├── compositor: dirty panes -> cell grid -> diff vs last frame -> ANSI
- └── control API: newline-delimited JSON on a unix socket
-slosh-client (raw mode; decode input -> events; write bytes to tty)
-```
-
-Frame pacing: pty reads are coalesced and painted on a timerfd at a cap
-(~120Hz), never per-read. That single property is why tmux feels sluggish under
-`cat bigfile` and we will not.
-
-## Milestones
-
-- **M0** — pty + one fullscreen pane + input passthrough + resize ✅
-- **M0.25** — input decoder + per-pane re-encode ✅ (pulled forward: retrofitting
- it under a layout tree would have been worse, and it is the load-bearing
- half of the mouse work in M4)
-- **M0.5** — headless driver + screen-assert harness ✅
-- **M1** — layout tree, splits, focus, frames (gap/padding/title alignment) ✅
-- **M2** — server/client split, detach/reattach, control socket ✅
-- **M3** — tabs, `purpose=`, JSON control API ✅
-- **M4** — chrome: OSC 5577, buttons, hit-list mouse ✅ (drag-to-reorder deferred to M5)
-- **M5** — built-in status bar, pane finder overlay, responsive (D6) ✅
-- **M6** — config file: geometry, theme, keybindings, live reload ✅
-- **M7** — layout files, suspended panes, `apply-layout` ✅ (what `sl0ppi up`
- is ported onto)
-- **M8** — pane sizes as weights: keyboard resize, drag the gap to move a
- boundary, drag a title to swap two panes, `equalize` to put every share back
- to even, `rotate-layout` to turn the whole tab a quarter turn ✅
-- **M9** — workspaces (D21): a project root scanned for projects, a third
- picker subject to go to one, layout files that are portable because a relative
- `cwd=` means the file's own directory, a dump that records what each pane is
- *running* rather than only what a layout told it to run, `save-workspace` to
- write one out, and a purpose you can set from the keyboard — the last two being
- what made a hand-arranged tab worth saving at all ✅
-- **M10** — floating panes (D22): the model and the guards, then the hands —
- drag to move, edge-pinned resize, keyboard moves, the shadow, the hover
- guide — plus `new-float`, the key decisions, and the memory: floats in
- layout files and `dump-layout`, placement over the API, and placements
- clipped by the floats above them ✅
-- **M11** — the drop grammar: while a pane is carried, the pane under the
- pointer subdivides into a swap centre and four insert bands — dragging
- became re-layout (see the drag machine below) ✅
-- **M12** — compact mode (D23): shared 1-cell borders drawn as merging
- strokes, titles riding the line above their pane — and, mode or no mode,
- a border drag now moves the boundary on that side, which is what `gap 0`
- had been missing ✅
-
-### The border is the button
-
-The per-pane `+` is gone. It was one glyph for a verb with four directions, so
-it always split into columns; it cost every frame three columns of title,
-forever, for something pressed rarely; and there were N of them on screen for
-one verb.
-
-**Clicking a pane's border splits toward it.** The side you click is the side
-the new pane appears on, which is the direction information a single glyph
-could never carry.
-
-**But not all of it: the middle of it.** The first version made the whole edge
-the button, and the whole edge is the longest target on the screen, sitting
-between two things you did mean to click. Reaching across a pane, going for a
-title, grabbing a gap a cell too far — each of those was a changed layout. The
-gesture was right and the target was wrong, so what shrank is the target: each
-side carries a **handle**, three to seven cells of it, and only the handle
-splits. The rest of the edge — the *rim* — arms the guide and does nothing when
-clicked.
-
-**All of it drags, though.** A border press that then *moves* becomes a drag
-of the boundary on that side — converted on the first motion, so the press
-that never moves keeps its old meaning: the handle's click still splits, the
-rim's click is still deliberately nothing (`drag.rim` is what keeps it inert
-on release). Which boundary is a walk up the tree (`boundary_for_side`),
-because the pane's own parent may divide the other way and the boundary to
-the right of a pane can belong to a split any number of levels up; a side on
-the outer edge has nothing to drag against and drags nothing. This is also
-the answer for `gap 0`: flush panes leave no gap cell for the resize hit to
-live on — `gap_rect` truthfully reports no gap — so before this, choosing no
-gap silently cost the mouse its resize. The border is all there is there, and
-now it is enough.
-
-Hovering therefore answers two questions in two stages, because they are two
-questions. **Anywhere on the edge**: the edge goes heavy and the handle goes
-solid **with the direction arrow knocked out of it**, which says *where the
-button is and what it does*. **On the handle**: the dashed line where the new
-boundary would land, which says *exactly where the result goes*. The hint
-follows the same split: it is the caption on the second stage, so sweeping an
-edge neither draws a boundary nor claims a split you cannot get from where you
-are.
-
-**The button carries its own verb.** The arrow used to ride the middle of the
-dashed line, which put the two halves of one message in two places — the handle
-saying *press here* and a glyph out in the pane saying *this is what happens* —
-and made the direction unreadable until you were already on the target. On the
-handle it is one object that explains itself, the boundary line goes back to
-being a plain boundary (an edge has no middle worth marking), and brushing an
-edge now tells you which way that button splits. It is knocked out with
-`ATTR_INVERSE` rather than drawn in a second colour, because the bar is solid
-`guide` and a `guide` glyph on top of it would be invisible: inverting gets
-contrast that follows whatever colour you set, for no new theme knob.
-
-**Only the upright sides get wider.** A cell is about twice as tall as it is
-wide — the same fact `gap_aspect` exists for, and that the split picker measures
-its longer side by — so growing by one is not one thing on the two axes. A left
-or right handle takes a second column and reads as a thicker line. A top or
-bottom handle taking a second *row* read as a wall instead, and spent a row of
-somebody's output saying it, so those stay on their own row: the block glyph is
-the heaviest a single cell has, and against `━` it needs no help.
-
-The handle is a rect in the hit list like everything else, so where the heavier
-form claims a cell the pane was using, that cell is *registered* in the frame
-that paints it — a handle you can see and cannot click would be worse than
-either mistake alone.
-
-The top border keeps its other job: **the row drags the pane, its handle also
-splits upward**. A press that never moves is a click — the same distinction the
-drag machine already had to make — and the row itself now takes no side, so
-clicking a pane's header to focus it cannot split it. That row is also the one
-place the handle cannot simply sit in the middle: the buttons own its right, and
-a centred title (the default) owns exactly the middle. The title winning is the
-rule — that is what keeps a double-click rename from splitting twice on its way
-— so the handle is placed last, **as near the middle of the row as the row
-allows**: aimed at the middle and slid into the nearest run of cells that is
-still free. With nothing in the way that is simply centred, like the other three
-sides; with a title in the way the handle ends up against it, where the eye
-already is. A row with nothing left offers no upward split and its other three
-edges are unaffected.
-
-Choosing the *widest* free run was the first rule and it was wrong in a way
-worth recording: two runs either side of a centred title differ by a cell for
-reasons nobody can see, so the handle jumped to whichever won and looked as
-though it had been dropped at random. Placement should be stated in terms of
-where the thing wants to be, not of the leftovers it is allowed to occupy.
-
-**The armed edge stays off the row's own furniture.** A pane's name, its
-buttons, its scroll indicator and a dead pane's epitaph all live on a border,
-and ruling the heavy line straight through them made the row unreadable exactly
-while the pointer was on it — and hid the title the handle had just been placed
-beside, which is what made a merely-off position look arbitrary. Each cell of the
-edge is painted only if the hit list says the edge still owns it, which is the
-same question the handle asks and the same one the click will ask. The epitaph
-had to start claiming its cells to take part; it answers `panestatus:` and does
-nothing when clicked, because an epitaph is not a button.
-
-**The guide arms on dwell, not on contact** (`hover_delay_ms`, 250ms). A
-pointer crossing a border on its way somewhere else should not make the screen
-flash, and the timer is on *pointer stillness* rather than on the target, so
-sweeping across three borders arms none of them. Holding the button on a
-handle is intent, and skips the wait.
-
-That needs the event loop to wake when nothing has happened — the same
-mechanism toasts already needed, since a pointer that is deliberately not
-moving generates no events to repaint on.
-
-**The guide is derived, not remembered.** Which border the pointer is over is
-worked out *during the paint*, by asking the hit list that same paint just
-filled. The first version remembered the answer from the last motion event,
-which meant that immediately after a split the guide still described the
-layout from before it, until the mouse moved and corrected itself. Same lesson
-as the hit list, one level up: do not remember what you can derive from the
-frame you are drawing.
-
-### One drag machine (M8, grown through M10–11)
-
-Pane sizes are **weights**, so an even split is simply equal weights and
-resizing is not a special case of anything: the layout pass stays the same
-pure function of the tree and the rect, and a resized layout survives a
-collapse/expand cycle for free.
-
-**Rotating is a permutation of the tree** (`rotate-layout`, `C-a Space`): every split
-changes axis, and a row split's children reverse — turn a column of panes
-clockwise and the one that was on top is the one on the right, while a row's
-leftmost becomes its top. That asymmetry is the whole feature; getting it wrong
-gives a mirror image, which looks almost right until you notice four turns do
-not come back. They do come back, exactly, which is what makes it safe to press
-and why the inverse is three more turns rather than a second mirrored routine
-with its own chance of being wrong. A turn is **refused when it would not fit**:
-a share of the height is not always a share the width can afford, and a node
-that cannot give its children their floor takes the whole tab down to a list
-(D6) — so the turn is tried, the layout is asked what it made of it, and one
-that flattened the tab is put back with a word. Same rule `split_fits` applies
-to splitting, for the same reason: an action a person just asked for should not
-be the thing that breaks the arrangement.
-
-**Equalising is one pass over the weights** (`equalize`, `C-a =`), because the
-weights are the whole state: no layout of its own, nothing remembered, and the
-next frame is the answer. Each split's children are weighted by *how many
-visible panes are behind each of them*, not given one weight each — one pane
-beside a stack of two is a third of the width, not half of it, or "even" would
-mean something different at every depth. Exactly equal cell counts are not
-something a tree can always express (a nested split pays for its own gaps out
-of its share, and a share that is not a whole number of cells lands where the
-layout's remainder loop puts it), so the action promises the even *division*
-the tree can express. Minimised panes are not counted: they are out of the
-layout and cost a row in the strip rather than a share, so counting one would
-hand its share to a pane nobody can see.
-
-Every mouse verb starts from the hit list, so none of them can disagree with
-what is on screen:
-
-| target | painted by | drag does |
-|---|---|---|
-| a frame's top row | `draw_frame` | carry the pane: where it lands decides (the drop grammar, below) |
-| the gap between two children | `draw_node`, from the rects they were just given | move that boundary (compact's divider is this same gap with a line drawn through it, D23) |
-| a side's border — handle or rim | `draw_frame` | move the boundary on that side; the press that never moves stays a click — the handle's splits, the rim's does nothing |
-| a tab in the strip | `draw_tab_strip` | move it to where you drop it |
-| ...and a *pane* dropped on a tab | `draw_tab_strip` again | move the pane into that tab; onto the `+`, into one of its own |
-| a floating pane's title / border | `draw_frame` | move / resize the float, its own verbs — everything a title or border drag means on release is what a float must not do (D22) |
-
-The fourth is the third one's target read the other way round, which is why it cost
-almost nothing: the strip was already a row of drop targets and a pane in your hand
-was already a thing being carried somewhere. `ptr_on` deliberately reports nothing
-during a drag, so the strip draws the two drop states itself — every tab the pane
-does not already live in as a candidate, the one under the pointer filled — which
-are the states the panes were already using for the same question. A pane cannot be
-dropped on the tab it is in, and that refusal is said out loud rather than looking
-like a drag that failed to take.
-
-The tab drag reorders **as you go** rather than dropping at the end. The strip
-is the only thing that could show an insertion point, and a strip already
-showing the result needs no such invention -- so there is no ghost tab, no
-caret, and nothing remembered between frames. Dragging off the strip is not a
-cancel: it is simply not a place to put a tab, so it stays where it last was.
-`cur` is a *position*, so a move has to recompute it; carrying it across would
-leave you looking at whichever tab slid into the index you used to be at.
-
-**The drop grammar (M11).** A carried pane's drop used to mean one thing —
-swap — so the whole target was one answer. Now the pane under the pointer
-subdivides: a **centre**, which is the swap it always was, and four **edge
-bands** meaning *insert me beside this pane, on this side* — `detach_leaf` +
-`place_beside`, the surgery cross-tab moves already had, aimed within the
-tab. Bands are a third of each dimension (they began as quarters, and the
-first map of the result showed the swap owning half of each axis — a target
-that big reads as dead space mid-drag); the hovered band **fills the half of
-the pane the drop would hand over**, where the pane will *be* rather than
-where the button is; and zones materialise only under the pointer, because
-four bands on every candidate is noise. Two rules are load-bearing. **The
-release derives from the painted frame's hits**, not from a side remembered
-off the last motion event: zones are painted a frame behind the pointer, and
-a remembered answer could disagree with the fill on screen — the same
-"derive from what the user saw" rule as everything else. And **a drop beside
-a sibling along the axis is always offered**: the dragged pane leaves the
-split it rejoins, so the child count never changes and there is nothing to
-fit — `split_fits`' arithmetic, right for a border click, refused the most
-ordinary drag there is, rearranging a row. Everything else answers to the
-same floor a border click does, conservatively; a side there is no room for
-simply never registers, so the preview cannot promise what the drop cannot
-deliver. Floats subdivide for nobody.
-
-The drop target is highlighted while dragging, and **any keystroke ends a
-drag** — a release can go missing (the pointer leaves the terminal, the client
-detaches mid-drag) and a mouse wedged in an invisible state is the worst
-possible outcome.
-
-M4 landed: the pi extensions' exact byte patterns are an acceptance test
-(`tests/test_osc5577.py::test_pi_extension_compat`), including `buttons` with
-no payload — answer-picker's way of dropping buttons while keeping the status
-text — and a click report that satisfies the extension's own `CLICK_RE`.
-
-Three protocol details worth keeping in mind, all found by testing:
-
-- **An over-long button id must be rejected, not truncated.** Unescaping a
- 40-character id into a 33-byte buffer produced a *valid* 32-character id, so
- a hostile pane could mint one that collides with an id its program already
- trusts. The id is now unescaped into a large buffer and validated at full
- length.
-- **BEL terminates an OSC just as ST does**, which matters more than it looks:
- a trailing `ESC \` inside a double-quoted shell string escapes the quote, so
- anything scripting this from a shell will reach for BEL.
-- **A reply must never re-parse as a request.** Every message the session sends
- a program ends its verb in `-reply`, and nothing dispatches one. `hello` used
- to answer with the verb `hello`, so a pane that echoed what it was sent — a
- shell with echo on, `cat`, a REPL waiting for a line — answered the answer:
- measured at 4.3 MB and 165,532 hellos in a second and a half. Fixed as the
- rule rather than the instance, because the loop is a property of "a reply that
- looks like a request" and the next verb to get an answer would have found it
- again.
-
-**D13, revisited: a program may set its own pane's shader chains**, behind
-`in_band_shaders` (off by default). The original reasoning stands — a program
-that can restyle the session it is running in is a hazard, and `cat
-hostile.txt` must not be able to dim your panes — but *impossible* was the wrong
-shape for it: arriving at a colour pass by editing a file, saving, looking and
-guessing again is slow enough that shaders go unwritten. So it is possible, it
-costs a line of consent, and it is scoped to the pane that asked: its own two
-chains, never its neighbour's and never what the config said about everybody
-else. The payload is the config's own chain syntax parsed by the same function
-(`config_parse_chain`), because a chain that meant one thing typed and another
-thing pasted would be worse than not having it. `contrib/shader-repl` is the
-prompt this exists for, and `:paste` prints the block to keep.
-
-## Non-goals
-
-- Not a zellij/tmux replacement for anyone but us.
-- No multi-user session rendering (D10).
-- No content resurrection. Layout restore yes, replaying scrollback no.
-- No plugin sandbox (D9, D15): a shader plugin is native code with the
- session's privileges.
-- Nothing but `x86_64-linux` until it works.
diff --git a/Makefile b/Makefile
index e9442e9..db75d24 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-# slosh — see DESIGN.md
+# slosh — see docs/design.md
#
# zig is the C compiler: it is already required to build libghostty-vt, and it
# gives us static musl linking and cross-compilation for free (D12).
diff --git a/README.md b/README.md
index 0eec9bb..1589936 100644
--- a/README.md
+++ b/README.md
@@ -89,4 +89,4 @@ dependencies: one file of Python and one stylesheet.
the session's socket, and a pane can draw its own status bar and buttons with
an escape sequence.
-Design notes and the twenty-one decisions behind it: [DESIGN.md](DESIGN.md).
+Design notes: [how it works](docs/design.md).
diff --git a/docs/design.md b/docs/design.md
index ed37c13..bf1d412 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,8 +1,6 @@
# How it works
-Two decisions do most of the work. The rest (twenty-two numbered decisions,
-each with what it cost to get wrong) is in
-`DESIGN.md`.
+Two decisions do most of the work; the rest follow from them.
## One geometry
diff --git a/web/index.html b/web/index.html
index ccce836..4f01173 100644
--- a/web/index.html
+++ b/web/index.html
@@ -122,7 +122,7 @@
part — the terminal core from <a href="https://ghostty.org">ghostty</a> does
the VT parsing, scrollback, selection, images and key encoding. What sits on
top is about 15k lines of C, and recomputes rather than remembers.
- The twenty-one decisions behind it: <a href="docs/design.html">how it
+ The decisions behind it: <a href="docs/design.html">how it
works</a>.</p>
</section>