aboutsummaryrefslogtreecommitdiffstats
path: root/DESIGN.md (follow)
Commit message (Collapse)AuthorAge
* gitignore: DESIGN.md and .fastfeedback are local, not the projectuser2026-08-28
| | | | | | | | 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.
* compact: shared borders instead of gaps (D23)user2026-08-25
| | | | | | | | | | | | | | | | | | | | | | One knob, `compact true`: panes pack flush against 1-cell divider lines that meet in real junctions, one frame rings the tab, and a pane's title rides the line above it -- a boundary costs one cell where the default look spends four. The lines are strokes: each write says which edges of its cell it needs, unions with what is there, and never clobbers text, so ├ ┬ ┼ fall out of paint order and the focus ring recolours without inventing arms. The divider *is* the gap (eff_gap, one answer for the layout, split_fits and the resize clamp), so gap hits, hover hints and corner drags work unchanged. Interior edges give up click-to-split -- one cell cannot hold two verbs -- and a pane's status row overlays its last content row. Floats, zoom and the D6 flatten keep classic frames through place_hidden: zero new tree cases. Underneath it, mode or no mode: a border drag now moves 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 is still deliberately nothing (drag.rim). Which is also the fix for gap 0, where flush panes leave no gap cell to grab and the resize gesture had silently vanished with it.
* slosh: the scrolled edge fade earns its nameuser2026-08-20
| | | | | | | | Four rows deep at 60 a row instead of three at 45: the edge row is nearly gone (#0e0e0e from white) and the melt reads as "there is more this way" rather than as a rendering artefact. The quarter-viewport cap, the (above > 0) gate and everything else about the derivation stand; a config that writes its own scrolled chain is untouched, as ever.
* slosh: M10c, the memory -- floats survive being written downuser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A dumped session quietly re-tiled its floats, which was the one data-loss gap left in D22. dump-layout now 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 (linted: floating is a bool, x/y/w/h mean nothing without it), and a file whose every pane floats self-heals through ensure_tiled: the top float lands, the same rule closing the last tiled pane follows, no lint required. save-workspace inherits all of it from the shared dumper. `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 -- floating the pane first when it is tiled, re-placing when it already floats, with omitted fields meaning "keep". The intent is 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 and would paint an image straight over a float covering it. Each pane's placements are now clipped by the float rects above it -- every float for a tiled pane, the higher-raised for a float -- cropping 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 the sub-cell offset is consumed) and suppressing what it cannot: a strip across the middle or a corner overlap leaves a shape one placement cannot say, and the image returns whole when the float moves. No z= needed -- clipping is the z-order, since nothing is ever allowed to overlap. D22's "still owed" paragraph is paid and rewritten; M10 is fully checked.
* slosh: fold floating panes and the drop grammar into DESIGN.mduser2026-08-20
| | | | | | | | | | | | D22 records the float contract: the minimized pattern (a flag and a wanted rect on a leaf that keeps its seat), derived-vs-remembered rects, the hit list as the z-order, the pop to the centre, the shadow-not-a-state, the backdrop invariant, edge-pinned resizes, and the key decisions -- with M10c (layout files, dump-layout, placement over the API, graphics occlusion) recorded as still owed. M11 extends the drag machine section with the drop grammar: swap centre plus third-width insert bands, the fill as the promise, release derived from the painted hits, and the sibling-reorder rule. The working plan file served its purpose and goes; git remembers it.
* slosh: layout files are *.layout, not *.layout.kdluser2026-08-19
| | | | | | | | | | | | | | | | | | | | | The old name argued that the extension should say how to read the file. The argument lost to a collision: zellij speaks the same KDL 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 a layout is x.layout, a project's is slosh.layout, the name says what the file is and --check says how it was read. (It is still KDL underneath; point your editor's highlighting at *.layout.) Nobody's checked-in workspace vanishes on upgrade: the project scan still finds slosh.layout.kdl, never writes it, and where both spellings exist the new one wins, because two files disagreeing needs one answer. The unparseable-file heuristic in --check learned the new suffix -- it matched '.layout.' with a trailing dot, which the new name no longer has. The webdemo overlay moves with it (demo.layout), rebuilt and boot-checked.
* slosh: defaults most people can live in, said by shadersuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
* slosh: rename the project from sl0pptyuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | sl0p + pty was error-prone to type and the zero made it worse; slosh (slop + sh) is one syllable, impossible to typo, and slosh.foo is ours. Mechanical rename everywhere it appears: binary, env vars (SLOSH, SLOSH_SESSION, SLOSH_BIN, SLOSH_CONFIG), socket dir (/tmp/slosh-<uid>, $XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards, src/sl0ppty.h -> src/slosh.h, contrib/sl0ppty-dev -> contrib/slosh-dev, the driving-slosh skill, docs and tests. The sl0ppi fork references and the sl0p theme stay: one is history, the other is the brand. The rename surfaced a latent bug in test_osc5577: the hello-reply answer always appeared on screen twice (ECHOCTL echo plus cat -v), but a seven-character implementation name happened to wrap the second copy across the 60-column boundary, so counting substrings in wrapped text found one and passed for the wrong reason. A five-character name moved the wrap and exposed it. The loop guard itself is correct -- with echo off before the request there is exactly one reply -- so the test now counts on de-wrapped text and tolerates the pane's own echo.
* sl0ppty: aim the top handle at the middle, and stop ruling through the rowuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two faults that fed each other, and only the second one made the first visible. The handle went to the **widest** free run of the top row. A centred title leaves two runs of nearly the same width, so a one-cell difference nobody can see threw the handle out toward a corner: on a 116-column pane whose row middle is 60, it sat at 85. Placement should be stated in terms of where the thing wants to be, not of the leftovers it is allowed to occupy -- so it now aims at the middle of the row and slides into the nearest run that is free. Nothing in the way: centred, like the other three sides. A title holding the middle: hard against the title, where the eye already is. That same pane now puts it at 64. The armed edge also ruled the heavy line straight across the whole border, over the pane's name, its buttons, its scroll indicator and a dead pane's epitaph. The row went unreadable exactly while the pointer was on it -- and the title the handle had just been placed beside was wiped, which is what turned a merely-off position into an apparently random one. Each cell of the edge is now painted only if the hit list says the edge still owns it: the same question the handle asks, and the 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; what it buys is that ` exited: status 3 ` survives a hover over the border it lives on, next to `[re-run]` and `[close]`, which already survived because they were buttons. Verified: `make retest`, **1877 checks measured**, all green. Four new: the handle is centred on the row when the row is empty, sits against the title when the title holds the middle, and the armed top edge leaves both the name and the three buttons legible. Live on a 116x36 pane, hovering the far left of the top row -- deliberately the rim, so this is the first stage doing all of it: ╭━━━━━━━━━━━━━━━━━━━ sl0ppty ███▲███━━━━━━━━━━━━━━━━━▬ □ ✕ ╮ and on a dead pane, hovering the bottom handle: ╰ exited: status 3 ━━━━━━━━━━███▼███━━━━━━━━[re-run]━[close]━╯
* sl0ppty: the split arrow belongs on the buttonuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The arrow rode the middle of the dashed boundary line, which put the two halves of one message in two places: the handle said *press here* and a glyph out in the middle of the pane said *this is what happens*. It also meant the direction was unreadable until you were already on the target, since the dashed line is stage two of the hover. So the arrow moves into the handle and the boundary line goes back to being a plain boundary -- which is the honest shape for it anyway: it is the *edge* of the pane you are about to make, and an edge has no middle worth marking. The handle is now one object that explains itself, and because the handle is drawn from the first stage, brushing any part of an edge tells you where its button is and which way that button splits, without previewing a boundary nobody asked about. Knocked out with ATTR_INVERSE rather than drawn in a second colour: the bar is solid `guide`, so a `guide` glyph on top of it would have been invisible, and inverting gets contrast that follows whatever colour the theme sets for no new knob. Along the handle the arrow sits in the middle, where the pointer is aiming. Across it -- a distinction only the upright pair has, being the two that grow a second cell -- it sits on the edge's own cell rather than the one over the content, because it marks a boundary and the boundary lands on the frame. Verified: `make retest`, **1873 checks measured** (`test_split_ux.py` 100 of them), all green. The test that pinned the arrow to the dashed line is now the test that pins it to the handle, on all four sides, plus one that it is legible from the first stage with no boundary drawn. Checked live on a 116x36 pane: `◄█` at column 2 and `█►` at column 117 on the middle row of their bars, `━━━███▼███━━━` on the bottom border's single row, and -- the case that shows why this is worth it -- hovering the *far left* of the top row draws `███▲███` at column 85, where the handle actually is, having dodged a centred title. A note on the two commits before this one: their messages quote 1801 and 1807 checks, which were carried over from earlier in the session rather than measured. The figure was 1865 at that point. The code and the tests were right; the numbers in those two messages were not.
* sl0ppty: a horizontal handle gets heavier, not talleruser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | The armed handle grew by one cell into the pane on all four sides, which is one change on two axes and therefore two different results. A cell is about twice as tall as it is wide -- the fact `gap_aspect` exists for, and the one the split picker measures its longer side by -- so the extra column on a left or right edge read as a thicker line, and the extra row on a top or bottom edge read as a wall. It also spent a row of somebody's output to say something the glyph was already saying: `█` is the heaviest thing one cell can draw, and against `━` it needs no help. So only the upright sides widen now. A horizontal handle stays on its own row. That also removes its claim on a content cell, which is why the inner-strip hit is registered on those two sides only -- the paint and the hit still describe the same rect, which is the whole reason the guide registers what it thickens. Verified: `make retest`, 1807 checks, all green -- six new, pinning the asymmetry in both directions: the top and bottom handles occupy exactly the row their border is on, the left and right ones exactly two columns, and the inner column of each upright handle hit-tests as that border rather than as the pane. Measured on a live session rather than argued: hovering the bottom border of a 116x36 pane puts seven block cells on row 37 and no others, inside the `━` rule; hovering the left border puts them on rows 16-22 across columns 2 and 3.
* sl0ppty: the middle of a border is the button, not all of ituser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking a pane's border to split toward it is the right gesture and was the wrong target. A border is the longest thing on the screen and it sits between two things you did mean to click -- a title, a gap, the pane beyond it -- so reaching a cell too far changed the layout, and the top row was worse than the sides: clicking a pane's header to focus it split it upward. So the target shrank. Each side carries a *handle* of three to seven cells in the middle, and only the handle splits; the rest of the edge is the *rim*, which arms the guide and does nothing when pressed. Three is the floor the pane buttons already settled on for the reason written there -- a one-cell target is a thing you miss with a mouse -- and seven is the ceiling, because a third of a tall pane's border is most of that border again and brings the accidents back with it. Hover now answers two questions in two stages, because they are two questions. Anywhere on an edge, the edge goes heavy and the handle thickens by a cell into the pane: that says where the button is, which is the thing you can no longer infer. On the handle, the dashed boundary and the arrow as well: that says what the click will do. The hint follows the same split, so sweeping an edge neither draws a boundary nor offers a split you cannot get from where the pointer is. Three things this had to get right. **The thickened handle is registered, not just painted.** The guide adds the hit for the inner strip in the frame that draws it, and reads the outer rect back from the hit list rather than recomputing it, so there is one opinion about where the handle is (D6). A handle you can see and cannot click would be worse than either mistake alone. **The top row cannot put its handle in the middle.** The buttons own its right and a centred title -- the default -- owns exactly the middle. The title winning is the existing rule, and it is what keeps a double-click rename from splitting twice on its way, so the handle is placed last, in the widest run of cells the row has left and nearest the middle among equals. A row with nothing left offers no upward split; its other three edges are unaffected. Each handle cell is then painted only if the hit list still agrees it belongs to the handle, so a block never lands on a pane's name. **The row keeps the drag and loses the split.** `title:<id>` takes no side now and `title:<id>:t` -- the handle -- carries one, which is why the press path reads the side out of the action instead of assuming it. Press-and-hold skips the dwell on a handle, where a press means something, rather than anywhere on an edge. Verified: `make retest`, 1801 checks, all green. Nine of them are new or rewritten: the two-stage guide, the caption belonging to the handle, the rim splitting nothing on any of the four sides, the top row's handle still splitting upward on a titled pane, and the rim of the top row not. Tests read the handle's cell out of the hit list (`Snapshot.handle`/`.rim`) rather than deriving the geometry, so a test cannot agree with a broken formula. Also checked by hand on a live session, which is where the placement rule earned itself: hovering the rim draws the heavy edge and the block and no dashed line, hovering the handle adds the line and the arrow, three clicks on pane headers split nothing, and on a pane titled `sl0ppty` the top handle lands to the right of the title and left of the buttons -- `drag to move · click to split up` at x=42, `drag to move` either side of it.
* sl0ppty: a dead pane says what it ranuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ``` │help: find a package to install `watch` with `cargo search cargo-watch` │ │ │ │[ran: cargo watch -x test] │ │[process exited: status 101] │ ╰ exited: status 101 ─────────────────────────────────[re-run]─[close]─╯ ``` `[re-run]` is one button on a pane whose program is gone, and nothing on the pane said what it would run. The command was never anywhere: not in the frame, not in the backlog, not in the exit note. Worst in a tab a project's layout built, where the command was never typed into that pane and so cannot be scrolled back to either -- which is now the common case, since that is what `open-workspace` does. So the death note gains a line above it. A pane running the session's shell has no command to name and gets the exit line alone, as before: `[ran: /bin/sh]` would be noise dressed as information. One note rather than two calls. `pane_note` puts a blank line either side of what it writes, so two of them would space the two halves apart as if they were about different things, when they are two facts about one run. Re-running keeps the previous run above it (D14), so the pair turns a pane you re-run into a log of what ran rather than a pile of identical epitaphs. Not put in the frame footer, which is the other place the question gets asked. It already carries the exit words and competes with the buttons for width, so a long command would arrive truncated in the one place that is always visible -- and the note is on screen anyway in the case that matters, because a pane whose program just exited is scrolled to the bottom. **Three existing tests were counting a string that now appears twice.** `DIES_CMD` is `echo mark-one; exit 3`, and they waited for `screen().count("mark-one") == 2` to mean "it ran twice" -- but the note contains the command, so every run now matches twice and the wait never finished. They ask `output_runs()` instead, which counts lines holding the marker that are not the `[ran:` line. Per line rather than by trimming the text, because every row still has the pane's frame border on the end of it. That is a fair correction and not a weakening: the assertion is still "the output appeared twice", which is what they meant. Verified: 56 checks in `tests/test_dead.py`, 1801 overall, all green. Three new: the command is named and sits *above* the exit line (asserted by row order, not by reading the two strings out of one screen); a pane with no command names none; and two runs produce two of each note. Then a real session, `command="cargo watch -x test"` with cargo-watch not installed, which is how this was noticed.
* sl0ppty: workspaces, and layouts that write themselvesuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A **project** is a directory on disk. A **workspace** is the tab it occupies in a session. 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. `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `sl0ppty.layout.kdl` -- or a `.git`, which is the project that has not said what it needs yet. Both markers, because a `~/dev` of forty checkouts and three layout files is a picker with three rows, and `C-a W` is how a project stops being the second kind. 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`. `C-a w` is the third subject of the one picker (D19): the finder searches what this session has, this lists what is on disk, including the projects with no workspace open yet, which is most of them and the whole point. **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. **Nothing watches the filesystem.** The list is derived when it is asked for -- one `readdir` per root and two `stat`s per entry -- 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. Opening is idempotent (`sl0ppi up`'s property): the same request twice is one workspace, focused, and the answer says which branch it took. Four things had to be true before `save-workspace` could exist, and none of them were. **A relative `cwd=` in a layout file now means that file's own directory** -- the rule `include` already followed for configs, applied to the other half of the same syntax. A project's layout could previously only name absolute directories, which are right on exactly one machine and wrong in a worktree of the same repo. **A dump can be asked for one tab, against a base.** `dump-layout` takes `tab`, `relative_to` and `suspend`. Writing it also turned up a bug: `dump_node` asked `cur(a)->focus` whichever tab it was writing, so a dump of three tabs restored the focus of one. **A dump records what a pane is *running*.** `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 -- which made arranging a project and writing it down two jobs instead of one. 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 the way `live_cwd` already reads `/proc/PID/cwd`. 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 an ephemeral pane. 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=` goes through `/bin/sh -c`. **A pane from a split starts where the pane it came out of stands**, not where the server was launched. Wrong on its own, and it had been quietly writing an absolute path to somebody else's directory into every saved project. 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. Over the API, replacing a layout that is already there is refused without `force`; from the keyboard `C-a W` replaces and says `replaced`, because a key cannot pass a flag and the file is committed text whose diff is the review. `--check` now holds a layout to the layout schema instead of answering with the name of the flag that reads one. `unknown pane property: cmd`, `split is cols or rows`, `purpose is ignored on a pane with panes in it` -- the mistakes a layout can make that still parses. The lists live beside `build_pane` and a test greps for every prop the loader reads, the arrangement that keeps `KNOWN_TOP` honest. **`C-a P` sets a pane's purpose**, a third subject on the editor the pane title and the tab label already share. A purpose is what tooling finds a pane by, and it could only be declared by a layout or set over the socket -- so every pane anybody arranged by hand had none, and a layout dumped from one was a shape with no tags in it. Typed by an operator it counts as declared (D8: "a layout or an operator"). Clearing one now *unlocks* it: a lock held over an empty string was the one state nothing could escape. `set-purpose` also reads `id: 0` as the focused pane now, which every sibling verb already did. Two gaps found on the way, both reachable only over the socket before: **`C-a X` closes a tab.** It used to be a side effect -- press `x` once per pane and the tab evaporates when the last one goes -- so four panes meant four presses and counting. It says how many panes went, since most of them were not the one you were looking at, and on the last tab it refuses and points at `q`: a key that closes a tab four times and ends your session the fifth is one you cannot press without counting first. No `✕` on the tabs, for the arithmetic that took the per-pane `+` away. **`scrollback` is a number we choose.** D4's "free from lib-vt" 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. `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling it runs into, because either limit can be reached first and neither can see what the other depends on. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (2% of the lines), ~32 MB at 400 (half of them). No `unlimited`, deliberately: the ceiling is per pane, and a negative is refused rather than wrapped into eighteen quintillion lines. Read when a pane is created, so it applies to the next pane you open -- same rule as `shell`, because shrinking a pane's history retroactively throws away output somebody is reading. `--project-root` was considered and dropped: the config is the one place projects are declared, and tests pin a config like every other config-driven feature. Verified: 1752 checks, all green. 90 in `tests/test_workspaces.py` (the two markers, depth without descending into a project, two worktrees as two workspaces, idempotent open, the honoured-purpose count, the save/close/open round trip byte-for-byte, the set-up-by-hand-and-write-it-down flow, the picker, and every verb's bare form); 53 in `tests/test_portable_layouts.py` (relative and absolute `cwd=`, text having no base, per-tab focus, the four `suspend` words, captured commands, an idle shell and a background job captured as nothing, spaces surviving the round trip, label precedence, and the checker's lists not going stale); 21 in `tests/test_purpose_editor.py`; 14 in `tests/test_scrollback.py`; 5 more in `tests/test_tabs.py`. `tests/test_osc5577.py` had a vacuous check: `\033` through the driver's unescaper is NUL + "33", so "a pane cannot relabel a declared purpose" passed without the sequence ever arriving. It uses `emit_cmd` now and has a positive control that the same sequence lands once the purpose is cleared. Three cheatsheet tests were pinned to terminal sizes that exactly fitted the old sheet, which four new bindings pushed past. They now ask for room; truncation has its own test.
* sl0ppty: layouts are `*.layout.kdl`, and each loader says which it gotuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `config/layout.example.kdl` becomes `config/example.layout.kdl`, and the convention for the files people write is `project.layout.kdl`. Not a new extension, and the reason matters: the syntax genuinely *is* the same hand-rolled KDL subset for both documents -- one parser reads them -- so calling layouts something other than KDL would trade one wrong signal for another and lose every editor that highlights it. The confusion is about *schema*, so the schema goes in the stem: the extension says how to read the file, `.layout` says what is in it. `docker-compose.yml` and `tsconfig.json` settled this convention long ago. **But a filename cannot enforce anything, and that turned out to be the real bug.** `sl0ppty --check` on a layout file printed `ok` and reported "bindings 53" -- it had read a document containing nothing it understood and said so was fine. A config that `include`d a layout linted clean for the same reason. So did `rouded true`. The loader reads a document by *asking it for the keys it knows*, which is fast and clean and never notices what it was given. So it notices now. `KNOWN_TOP` is every node the loader reads, and anything else at the top level is a complaint with a file and line: s.kdl:2: unknown setting: rouded example.layout.kdl:14: this is a layout, not a config: `sl0ppty --layout` reads those sl0ppty: config.kdl: this is a config, not a layout: `gap` is a setting The `layout` node gets its own message because it is the mistake people actually make, and the reverse direction is the same list read backwards -- the layout loader asks `config_is_setting()` rather than keeping a guess of its own, so the two answers cannot disagree about which file is which. A layout whose own tabs are misspelled still gets "layout declares no tabs", which is a different mistake. D2 records it, since it is the decision that two schemas share one syntax. Verified: `tests/test_config.py` gained thirteen checks -- the unknown setting with its line, both wrong-schema messages, the misspelled-tabs case keeping its own, the shipped config linting clean and the shipped layout loading as one, and a drift guard that greps `kdl_child(root, ...)` out of the loader and fails if a setting is missing from `KNOWN_TOP` (verified by removing `gap_aspect` and watching it fail). Full suite green; my own config and the reference config both still lint clean, which is the thing this change could most easily have broken.
* sl0ppty: drag a pane onto a tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Drop it on a tab in the strip and it moves into that tab; drop it on the `+` and it gets one of its own. The fourth verb of the same drag machine, and it cost almost nothing because it is the third one's target read the other way round: the strip was already a row of drop targets, and a pane in your hand was already a thing being carried somewhere. The strip draws the drop states itself. `ptr_on` reports nothing during a drag -- on purpose, so hover feedback does not fight a gesture -- so a tab cannot find out it is under the pointer the usual way. It uses the two states the panes already use for this exact question: every tab the pane does not live in is a candidate in the drop colour, and the one under the pointer is filled. These are all targets; that is the one you are on. The tab the pane already lives in does not offer itself, because dropping there is the one refusal a drag can reach -- and it says "it is already in that tab" rather than looking like a drag that failed to take. Leaving the strip takes the offer back, the same way dragging a tab off the strip is not a cancel but simply not a place to put one. Releasing over a pane still swaps the two, which has a test of its own: the strip is a new kind of destination, not a replacement for the old one. A toast says where the pane went, and here it matters more than for the keys: the pane lands in a tab you are not looking at, so without a word the screen just shows one fewer pane. Verified: `tests/test_move_pane.py` at 52 checks. The new ones drive real mouse reports -- press on a title, motion onto a tab hit, release -- for the move, the `+`, the three highlight states (candidate, filled, and the pane's own tab declining), taking the offer back by dragging off the strip, the refusal, and that pane-onto-pane still swaps.
* sl0ppty: a pane can set its own shadersuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)"\033\\' The field after `shader` is which rect -- `content` or `chrome` -- and the rest of the payload is a chain in exactly the syntax a `shaders { }` block uses, taken verbatim so that `;` can go on separating entries. Empty text clears that chain. The session answers on the program's stdin, `shader-reply;ok` or `shader-reply;error;bad amount for tint: ...`, because a prototyping loop that gets no answer cannot tell a typo from a build without the feature. `contrib/shader-repl` is the prompt this exists for: type a chain, watch the pane, and `:paste` prints what you have as a config block. Both ends are the same parser, so what you arrive at is what you can keep -- verified by pasting `:paste`'s output into a file, linting it, and checking the frame comes out the same colour. **D13 said this was impossible on purpose, and the reasoning stands**: a program that can restyle the session it happens to be running in is a hazard, and `cat hostile.txt` should not dim your panes. But *impossible* was the wrong shape for it -- editing a file, saving, looking and guessing again is slow enough that shaders go unwritten. So it takes `in_band_shaders true`, one line of consent, and it is scoped to the pane that asked: its own two chains, never its neighbour's, never what the config said about anyone else. There is a test for the neighbour. Each chain owns the programs its expressions compiled to, and is swapped in whole, so a chain that turns out not to read leaves the pane looking like it did rather than half-restyled. Measured: 1,800 replacements of an animated chain, resident size flat after the first pass. Also here, because it is the same paint order: the chrome chain now sits between the config's and policy's, exactly where the content chain does. Dead code out -- `app_shade_add`/`app_shade_clear`/`app_shade_count` had no callers and their comment claimed the opposite of what this commit does.
* sl0ppty: shaders on the chrome, and two keys for the arrangementuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Five things, in one commit because they land in the same few files -- the action table, the shader pass, config.kdl -- and splitting them by hunk would buy a tidier log at the price of commits that do not build. Each is verified end to end; the suite is 46 files green. Chrome shaders. `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, same chain order, one new word, because a frame is another rect and so it is another pass. It runs over the frame's whole rect with the contents punched out as a hole, rather than 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 keeps the two honest -- a skipped cell is never visited, so a full-strength chrome tint cannot reach your text. It 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. `channel="fg"|"bg"|"both"` came out of looking at the first border flash: `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 the shader: the other colour is snapshotted before materialising and put back after, so an unset colour goes back to unset, it works for a built-in and a plugin alike, and no shader grows an fg-only twin of itself. A `bell` pane state and a `since` variable, because "flash the border when a pane rings" needed both and neither existed. `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 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. Animation needed a clock and did not have one: a shader reading `t` only advanced when something else caused a frame, so a pulse froze the moment you stopped typing. `app_next_deadline_ms` now reports `anim_ms` while a chain that reads the clock ran over the last frame -- derived from what was applied, so a pulse hung off `dead` costs a clock while a pane is dead and nothing for the rest of the session. A `deadline` verb makes that observable; the server's poll loop already lived on the number. contrib/chrome has seven presets and the perimeter trick they are built on: on a frame the coordinate you want is distance along the ring, which the language can work out, and `min(d, P - d)` is what makes an effect cross the corner it started from. Hand-written, unlike contrib/shaders, because the toy previews contents and a frame has no preview to give -- so tests/test_chrome_presets.py checks each one the only way left: load it into a real session, assert the frame moves and the contents do not. `equalize` (C-a =) puts every share back to even: each split's children weighted by how many visible panes are behind them, so one pane beside a stack of three is a quarter of the width and "even" means the same thing at every depth. Exactly equal cell counts are not something a tree can always express, so the action promises the even division it can. `rotate-layout` (C-a Space) turns the whole tab a quarter turn clockwise. Every split changes axis and a row split's children reverse, because the pane on top of a stack is the pane on the right once you turn it; get that asymmetry wrong and you have a mirror image, which looks almost right until four turns fail to come back. They do come back, exactly, which is why the inverse is three more turns rather than a second mirrored routine with its own chance of being wrong -- and why a key as easy as space is the right one rather than a hazard. 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. 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. Keys: the palette moves from `shift+p` to `p`, and cycling tabs from `n`/`p` to tab/shift+tab. `p` is pressed far more often than "the tab before this one" and was the only shifted letter in the defaults; tab is what every other tabbed thing cycles with, and `\e[Z` decodes as shift+tab on a plain terminal, so this costs nothing on a client without the kitty protocol. `n` is free again. The actions are untouched: two lines of config put the old binds back. The shader ABI goes to 3: `shade_ctx_t` gained `state_ms` and struct shader gained `channels`, both the pass's business rather than a shader's. Plugins need a rebuild, which is what the version is for.
* sl0ppty: a command palette, on a key you chooseuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every action by name, without having to know its key. The cheatsheet answers "what is the key for this?" and then needs you to press it; this answers "just do the thing", which is the question you have when you cannot remember there was a key at all. `C-a shift+p` by default, and `bind "<key>" "palette"` for anything else. It lists actions that are bound to nothing, which the cheatsheet by design cannot -- that sheet is built from the binds, so an action with no key does not appear on it, and that is exactly the action you have no other way to reach. Two of ours are in that state today (scroll by a line, either direction). Where a binding does exist the palette shows it beside the row, so using the palette teaches the key that would have skipped it. Where several exist it shows them all: a config's binds add to the defaults rather than replacing them, so naming only the first would hide the one the user actually chose. Rows match on the phrase, the group, and the name a config file uses, so "split into columns", "panes" and "split-cols" all find the same thing depending on whether you have been editing the config lately. The finder and the palette are now one picker with a subject rather than two machines that would drift -- the same modal, query, filtered list, selection, keys and mouse rules, differing only in what fills the rows and what Enter does with one. Choosing goes through the row's own action string, so the keyboard and the mouse cannot take different paths to the same row. The finder's 55 checks pass unchanged across the refactor. Left out on purpose: `literal-prefix`, which means "type this key" rather than "do this thing" and picked from a list would send the prefix to a pane hidden behind that list; and `select-tab-N`, which is positional, so a row reading "go to that tab" cannot say which. Running is by action rather than 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. That meant splitting prefix_command in two, which it wanted anyway: the lookup and the doing were only ever together by accident.
* sl0ppty: answer the terminal when it is asked how big it isuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | XTWINOPS (CSI 14/16/18 t) went unanswered. A program that draws images needs the cell size to keep an aspect ratio; the pty's pixel fields are the first way to get it (D17) and this query is the second, which is what a program reaches for when those come back zeroed -- as they do on the far side of an ssh hop. Unanswered, the query times out and the program falls back to a guess: ida-tui guesses 10x20, and against a real 9x22 cell that is every image stretched by a fifth, which looks like a bug in the program rather than in us. Found alongside the png bug and from the same program, which asks `CSI 16 t` in the same round trip as its graphics query -- so once the png decoded, the picture would have appeared at the wrong shape. lib-vt encodes and writes the reply once the callback says what the numbers are; they come from the pane's own geometry, so a program is told about the pane it is in rather than the window. Mode 2048 (the in-band report on resize) already worked, because that one is answered from the size passed to ghostty_terminal_resize. Also notes in graphics.c what re-emission actually costs: we send decoded pixels because that is the only form lib-vt keeps, so an 819KB png reaches the client as 3.3MB of base64, once per image and again after a reattach clears `sent`. Fine for a splash, and written down so the next person to wonder does not have to measure it again.
* sl0ppty: decode the PNGs programs actually senduser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Kitty graphics were green in the suite and a real program still drew nothing. libghostty-vt ships no image codec: built as a library it sets its `sys.decode_png` hook to null, and a null hook rejects every `f=100` transmission outright -- no image stored, so no placement exists, so the re-emission layer this project is mostly *about* is never reached. The program gets no error and no picture. Raw RGB worked throughout, which is why it looked done: every test here sent raw pixels, because that is what is easy to write in a test. It is the same lesson the last kitty graphics bug taught -- write the test the way a program actually behaves -- and a program with a picture on disk has a PNG. Found because ida-tui's startup logo never appeared, and its detection probe answers `OK` first, so it fails silently rather than falling back to its block art. PNG is the whole of the gap and cannot grow: the protocol has exactly three formats (`f=24`, `f=32`, `f=100`), the first two are raw pixels lib-vt already handles, and `decode_png` is the only codec hook it has. Decoding only, never encoding. The decoder is the stb_image lib-vt already vendors and we already commit -- upstream compiles that same header with the same `STBI_ONLY_PNG` for this same purpose, it is just not in the `-Demit-lib-vt` build. So nothing new is pulled in; a file we already ship is finally compiled. src/png.c is the hook, a header pre-check and a copy: dimensions are read before decoding because the buffer we return is allocated through lib-vt's limited allocator but stb's own working allocation is plain malloc and answers to nothing. Tests cover a PNG placed at the size it asked for (RGB and RGBA sources, both normalised to RGBA), the client being handed `f=32` rather than the png it was given, and a malformed png placing nothing without taking the pane with it. They fail on the previous binary.
* sl0ppty: only panes that were given a command keep their corpseuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Keeping every dead pane was too much. Split off a shell, do something in it, type `exit` -- and you are left with a corpse to dismiss, which is fussiness rather than information. You know what happened. You did it. So the policy has a line now, and the line is the *command*, not who made the pane. A pane told to run `npm run dev` is part of the session's shape: when it dies you want the error it left and the button that runs it again. A pane that is a shell is finished when you say so. Not provenance, because a shell is a shell whether a layout file made it or C-a \ did -- and that also means a session restored from dump-layout behaves like the one it was dumped from, since the dump writes `command=` only for panes that had one. The distinguishing mark turned out to need no new state: pane_label() already holds the command a layout gave it and is empty for a plain shell, so this is a policy over something already recorded. `keep_dead` is now `commands` (default), `all` or `none`, and still reads `true`/`false` as `all`/`none` -- that was the shipped spelling and a config saying it should keep working rather than quietly changing behaviour. Most of the diff is tests. The dead-pane suite had been written against the session's own pane, which is a shell under the new rule, so it was testing the half of the policy that now says "reap it". They run commanded panes from layouts instead, and the pair of cases -- a shell you split off goes, a commanded pane stays -- sits together in one place so the rule is legible in the test file too. One of those layouts wanted an escape sequence, which has to be written `\\033` so KDL's unescaping hands `\033` to the shell. Written singly the shell prints `033[38;2;...` and the test reads as "the colour stopped working".
* sl0ppty: dim the panes you are not in, and a knob to say how muchuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `dim_unfocused 60`, on by default, 0 to turn it off. The dimming itself was already possible -- `states { unfocused { dim } }` -- and deliberately not the default. The argument then was that ambient contrast is a taste, and shipping a taste as a default is how a tool earns a reputation for fighting you. That argument was mostly about the escape hatch: "turn it off" meant knowing the states block exists and what a shader chain is. A single number does not have that problem, so the objection goes away and the default can be the one most people want. The knob and the table are one mechanism -- the knob fills the unfocused chain in when the config has not -- so writing the chain by hand still wins, including writing an empty one. `states { unfocused { } }` means nothing at all, and is not quietly refilled, which needed the parser to distinguish "said nothing" from "said none". 60 by choice: white text lands on #c3c3c3, which reads as "not this one" without reading as "not available", because half the time you are reading the other pane. Picked by looking at 40, 55, 70, 90 and 120 rather than by taste alone. The tests that had to change are the interesting part. Six in test_shaders.py asserted "back to green after the drag", which is no longer what normal looks like -- and rewriting them to compare against a snapshot taken before the drag failed too, because a drag *moves focus*: press a title and that pane is yours, so the two panes legitimately swap appearances across one. They assert the rule now (the pane you are in keeps its colour, the others are pushed back), which is both what we mean and immune to the next change of defaults. test_states.py's "an unfocused pane is left alone" is inverted, with the reason written where the assertion used to be, because a test that flips should say why it flipped.
* sl0ppty: colour the states that mean a pane is not liveuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The shader machinery has been able to do this since it existed, and shipped an opinion about exactly one state. Now it ships three, and the line between those and the rest is the point: a pane that is *not live* gets a default -- dead, suspended, scrolled. In all three the cells are showing something other than a running program's present: output from something that has exited, a pane that never started, or the past. None of that is discoverable by looking unless something says so, which is the whole argument for the feature. a pane that is merely *not the one you are in* does not -- unfocused, and one being dragged. That is ambient contrast, which is a taste, and a taste shipped as a default is how a tool gets a reputation for fighting you. It is one line of config away for anyone who disagrees, and there is now a test asserting we have not quietly changed our minds. So: suspended is greyed and dimmed (inert, still legible -- its contents are the command it is waiting to run), and scrollback takes a 9% wash of the scrollback indicator's own colour, so a theme moves the indicator and the wash together. Checked against phosphor, amber and paper: each gets its own hue rather than a hardcoded one. The other half is the gap this leaves. A collapsed header never reaches the shader pass -- a header is chrome and shaders colour contents (D13) -- so collapsing a tab used to hide precisely the facts the colour exists to show. The header now carries them in words, in the same order the status line ranks them: `exited: status 3`, `not started`, `▲ 12`. tests/test_states.py asserts the line in both directions, including that an unfocused pane is left exactly as it was, and that a flattened tab still says which of its panes never started.
* sl0ppty: re-emit what the program meant, not what it coversuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Images rendered after the last commit, and were still wrong twice. Both mistakes are invisible in a still picture, which is what every test was looking at, and obvious the moment anything moves. **The sub-cell offset was dropped.** A placement says where the image starts *inside* its first cell (`X=`/`Y=`); that is what makes motion smooth rather than a slideshow. We tracked the placement and re-emitted it without those, so a bouncing logo moved in steps of a whole cell -- eight pixels across, sixteen down. **`c=`/`r=` were passed on unconditionally.** They mean *scale the image into this many cells*, and the count a natural-size image happens to cover goes up by one whenever it straddles one more boundary -- so the picture changed size as it slid along. They are now sent only when the program asked for them, which lib-vt reports as 0 when it did not. Cropping splits the same way, and getting it wrong took a test failure to notice: at natural size a lost cell costs the screen pixels the pane has left (measured from the offset, not the cell edge, or an offset image hangs up to a cell over the border); when the program asked to scale, a lost cell is worth source/cells pixels and the crop is proportional. Cropping a 4-pixel image scaled across 40 cells by screen pixels removes all of it. gfx_place() took twenty positional arguments, half of them uint32_t pixel quantities where a transposed pair compiles perfectly and lands the picture slightly wrong -- the exact bug class this file exists for. It takes a struct now. `{"cmd":"graphics","format":"bytes"}` answers with the escape sequences the client is actually sent. The model being right is not the same as the bytes being right, and this whole episode was a lesson in asserting on the first while the second was wrong. The new tests assert on the bytes.
* sl0ppty: carry the terminal's cell size, so images can have a sizeuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | "Kitty graphics pass through" was true of the tests and not of anything else. Every test transmitted and placed in one command with explicit c=/r=; real programs upload once with `a=t` and place per frame with `a=p`, saying nothing about cells, and that path was broken from end to end. A placement that does not say how many cells it covers is sized from the image's pixels and the cell size. We never had a cell size: - pty_spawn and pty_resize left ws_xpixel/ws_ypixel at zero, so a program asking its own tty how big a cell is got nothing and fell back to guessing; - ghostty_terminal_resize was called with 0, 0 for the cell size, so lib-vt computed zero cells for such a placement -- and a placement covering zero cells is skipped, which on screen is an image that simply is not there. So the size now runs the whole way through. The client reads it from TIOCGWINSZ (re-read on every resize, because a window can move to a monitor with different metrics), MSG_HELLO and MSG_RESIZE carry it -- a 4-byte payload still means "I did not say", so an older client still attaches -- 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. That is the whole lesson of this bug: a plausible cell size means an image appears at roughly the right size, and a zero means it does not appear at all. `{"cmd":"resize"}` takes optional cell_w/cell_h, so a script can be a client that knows its own metrics, and so the tests can be one. The tests now use the protocol the way programs use it, including a check that a program can read real pixel dimensions out of its own pty -- the thing that was silently zero. The remaining half of the fix is one layer down, in vendor/patches/0001.
* sl0ppty: a shader's strength can be an expressionuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adding an effect meant writing C -- in the binary, or since D15 in a shared library. Both are a lot of ceremony for "dim every other row". So: `amount` may be an expression, compiled to bytecode when the config is read and evaluated per cell. shaders { dim amount="(y % 2) * 40" // scanlines dim amount="(x > cols - 10) * 120" // a right margin tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7" } The scope is the argument, and that is the whole design. Every built-in here is already colour_op(amount) with the amount a pure function of position -- vignette, zebra, ruler, margin and spotlight differ only in that function -- so the part worth handing to a config is the *number*, not the colour maths. Three things follow at once: the mixing stays in C where it is fast and correct; an expression cannot produce an invalid cell, because it produces one byte; and that byte is cacheable. The cache is what makes an interpreter affordable, and the numbers are why this exists at all. Measured on a 200x50 pane, one pass: native vignette 6.1 ns/cell constant expression, folded 5.6 ns/cell positional expression, mapped 5.2 ns/cell animated (reads the clock) 35.0 ns/cell A shader you wrote in your config costs what a compiled one does. A program that does not read the clock is evaluated once per cell into a byte map and reused until something it reads changes; one that does read the clock is interpreted per cell, per frame, which is the honest price of animation and is opt-in. (The earlier measurement of 56ns for interpretation was per evaluation -- correct, and the reason the map exists rather than an argument against the language.) What a program reads is derived by the compiler rather than declared, and that dependency set *is* the cache key. A stale map is therefore unrepresentable rather than merely unlikely -- the same rule the layout follows, arrived at from the other direction. A unit test caught the first violation of it before anything else did: `cols` and `rows` carried no dependency flag, on the theory that the key always contains the rect, and so a program reading only the size looked constant and was folded at compile time, where every size is zero. The language is deliberately total. No loops, no recursion, no jumps: even `?:` compiles to evaluating both sides and selecting, because nothing in it has an effect. Evaluation is O(program length), so a config cannot spin, and there is nothing to sandbox. Division by zero is 0 rather than a trap: an expression that is wrong should look wrong, not take the session with it. One that does not compile drops that one shader with a warning -- not the config, and not the half-strength version of what you asked for, which is what the number path did with garbage and is now also fixed. shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing but a rebuild -- the check earning its keep the first time it was asked to.
* sl0ppty: a tab is something you can name and moveuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tabs had names -- a layout could set one, the control API could set one -- and no way to type one. A tab you made yourself was stuck being a number. And the strip's order was the order tabs happened to be created in, forever. Both are the same gesture as the pane equivalent, on the other thing that has a name: double-click a tab rename it in place, in its own cell in the strip drag a tab move it along the strip They land together because they share the machinery they are built on, and splitting them would have meant writing the shared half twice. The rename editor is the pane's, generalised from "renaming a pane" to "renaming a pane or a tab" -- one editor with a subject rather than two that would drift. Enter keeps, Escape abandons, clicking away commits, empty gives the tab back its number. It is seeded with the name only: a pane seeds from its title because a title is always something, but a tab shows its *number* until it is named, and offering "3" to backspace over would be offering to edit the wrong thing. The drag is the existing drag machine's third verb, and it reorders **as you go**. The strip is the only thing that could show an insertion point, and a strip already showing the result needs no such invention -- no ghost tab, no caret, nothing remembered between frames. Dragging off the strip is not a cancel: it is simply not a place to put a tab. Two things that were not obvious until they were written down: - pane ids and tab ids are separate sequences, so pane 2 and tab 2 both exist. The double-click detector remembered only the id, so a click on one followed by a click on the other read as a double-click on whichever came second. It remembers the kind too now, and the test asserts the ids really do collide before asserting they are told apart -- otherwise it would quietly stop testing anything the day the sequences diverge. - `cur` is a *position*, not an identity, so a move has to recompute it. Carried across, you would be looking at whichever tab slid into the index you used to be at. `{"cmd":"move-tab","id":N,"index":M}` does the same thing from a script, and is what tests the arithmetic from a tab that is not the one being moved -- the case a drag can never produce, because a drag selects what it grabs.
* sl0ppty: the new-tab button is a bare +, and yours to chooseuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | It read "+tab" because of this, in the code, right above it: Not a bare "+": the frame already has one, for splitting a pane. Two verbs that look identical is a UI bug the fork shipped and noticed. That was true when it was written and has not been since f2f3d67, which removed the frame's "+" when the border became the button. The collision it was avoiding no longer exists, and the word was left explaining itself to nobody -- while every other affordance in this UI is a bare mark: _ # x on a frame, and a hint under the pointer for anyone who wants the word. So: "+", padded to three cells, because their own comment applies -- a one-cell target is a thing you miss with a mouse. Also configurable, as `newtab_mark`, which it should have been already: it was the only mark here that was not (zoom_mark, zoom_on_mark, close_mark, min_mark, bell_mark all are). Try "[+]" or "new" if you disagree; a session picks it up on save. Four tests were asserting the literal "+tab". Three of them only needed the new string, but two were using it to *locate* the button -- bar.index("+tab") -- which is exactly the mistake of asserting something incidental: what the button says is now config, and a test that spells it out fails the day somebody changes their mind. They look it up through the hit list now, which is what they meant. test_no_more_plus keeps the invariant that makes the bare mark safe, and now says so: there is exactly one "+" in the UI, so it means one thing. If one ever returns to a frame, that button needs its word back.
* sl0ppty: shaders can be loaded from shared librariesuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adding an effect meant editing shader.c and rebuilding, which is fine for us and useless to anyone else. Now any `*.so` in the shader directory that exports a plugin table is loaded at startup, and what it declares is named in the config exactly like a built-in -- same syntax, same three parameters, same pass. Default location is a `shaders` directory beside the config file, so dropping the .so next to config.kdl is the whole installation. The interesting part is what a plugin can see, which is one header. shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else: no libghostty-vt, no pty, no layout, no session. A shader is therefore unable to reach anything but the cell it was handed, not by rule but because nothing else is declared where it can find it. cell_t and color_t moved there from sl0ppty.h for the same reason -- that is the one place they are a contract with code compiled outside this tree rather than an internal type. Loading is deliberately narrow: - refused unless the library reports our ABI version *and* matching struct sizes. The version says which contract; the sizes catch someone editing their copy of the header, which a version number alone calls fine and a running session calls garbled colours; - refused if it redefines an existing name, because a config naming `dim` means the `dim` it was written against; - all-or-nothing per file, and per file: a bad library is a line on stderr and everything else still loads (D9 fail-open); - nothing is ever unloaded. A shade_fn may be sitting in a live config or on a pane, so dlclose() is a jump into an unmapped page. A reload picks up newly *added* plugins; replacing one needs a new session. This is the single exception to D9's "a plugin is a subprocess", and it is argued in D15 rather than drifted into: a shader is called per cell, per pass, per frame, which no subprocess can be in the path of, and the measured alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the compiled function. On trust, `shell` and a layout's `command=` already let a file in the config directory run anything as you, so a .so beside them is not a new boundary. The docs say so plainly rather than implying a sandbox. contrib/shader-plugin is a working skeleton: Makefile, two commented effects (one positional, one animated), and the three rules a shader has to keep. It is built and loaded by the test suite, so a broken example is a failing test rather than someone else's afternoon.
* sl0ppty: a pane outlives the program that was in ituser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane whose program exited was reaped before the next paint. The pane went, and with it everything the program had printed -- including, always, the one line that would have explained why it stopped. Close the last one and the session went too, so a mistyped command in a fresh session closed the terminal you were typing in. Now the pane stays. It keeps its contents, writes one line into its own backlog saying how it ended -- `[process exited: status 3]`, `signal 9` -- says the same thing in its frame and in the status line, and offers the two verbs that still mean anything: ╰ exited: status 3 ──────────────────────────────[re-run]─[close]─╯ `[re-run]` runs the same argv in the same cwd in the *same pane*: same id, same node in the tree, same terminal. Nothing that referred to that pane has to be told anything, and the run that ended stays above the new one in the scrollback, which is what makes comparing two runs possible at all. C-a r and {"cmd":"rerun"} do the same thing; a live pane is refused rather than restarted, because "run it again" is not a request to kill something. Those buttons are the OSC 5577 row, not a second mechanism. A dead pane's own buttons are inert -- a click would write a report into a closed pty -- so the row is given over rather than added to. `close` is listed last, so it is drawn rightmost and is the one that survives a narrow frame: being unable to dismiss a dead pane is a trap, being unable to re-run one is an inconvenience. `dead` is a shader state now, which D13 said it could never be: it named a frame that could not be drawn, and now there is one. Dead panes drain to grey by default, so a wall of them reads at a glance. A collapsed header says "exited" in words as well, because the shader pass does not reach one and a flattened tab draws nothing else of a pane. Two things about this are load-bearing rather than incidental: - the pty fd is closed the moment EOF is seen. A pane that outlives its program stays in the poll set, and an fd at EOF is readable forever: the session would spin a core behind a pane that looks idle. - the exit status is collected with a bounded wait, never a blocking one. EOF means the program already closed its fds, so it is on its way out and a few hundred microseconds wins that race. A blocking waitpid would hang the whole session on one program that closes its terminal and keeps running; losing the race costs the words "status 0" and nothing else. `keep_dead false` restores the old behaviour for a one-shot layout that wants it. `panes` reports alive/exit_code/exit_signal, so a script can find the pane whose command failed and rerun it.
* sl0ppty: a tab is laid out or it is a list, never half of eachuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Collapsing was a local decision: the subtree that could not fit flattened and everything else carried on. In practice that produces a screen with a full-height pane on the left and a stack of headers on the right, which explains neither what just happened nor what to do about it. Seeing it is enough to know it is wrong. The question is now asked of the whole tab and answered once. If any node cannot give its children their floor, every pane in the tab becomes a row and the focused one opens below them — including the panes that would have fitted perfectly well on their own. That costs something real and it is worth being honest about it: a wide screen with one cramped column now flattens entirely, where before it would have kept the rest of the layout. The trade is that there is one collapsed state instead of a spectrum of partial ones, and a rule you can hold in your head is worth more here than squeezing the last working layout out of a bad size. Mechanically this made layout simpler rather than more complex. layout_node no longer decides anything about presentation; a node that cannot fit sets a flag and returns. Laying out happens twice — once to ask, once to do — and the probe is the real layout with resizing switched off rather than a second copy of the fitting arithmetic, because two copies of "does this fit" is exactly how the split guide and the layout would begin to disagree. The probe not resizing is the load-bearing part: a pane it lays out normally may be a header by the time the real pass runs, and a pane about to be collapsed must not be told a size it will never show.
* sl0ppty: collapsing flattens the tree instead of nesting headersuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Collapse was a local decision that stayed local all the way down, so a tab whose root did not fit could show one pane as a header and then, inside the child that did fit, three more panes side by side. That is a degraded mode still spending rows on an arrangement it has no room to express, next to a pane it has reduced to a single line. Now a subtree that does not fit flattens: every pane under it becomes one row, in one list, with the focused one opened below them. Nesting is precisely what there is no space for, and a reader in that state is asking which pane, not which arrangement. The better reason is that the old shape made panes unreachable. mark_collapsed gave every leaf under a collapsed subtree the *same* rect, and draw_collapsed registered one hit for first_leaf_of(). Three stacked panes therefore became one header whose only action was focus on the first of them: the other two could not be clicked at all, and directional focus could not separate them either, because their rects were identical. One row per pane fixes that by construction rather than by adding a case. Headers sit together with the body below rather than the open pane keeping its position in the list. Switching then moves only the content while the list you are picking from holds still, and the tree order that an accordion would have preserved is the thing being flattened away. The stack's guard counts panes rather than children now, since that is what it spends a row on each of, and layout_solo flattens the same way so there is one mental model rather than two. subtree_has() went with them. One regression, caught by the test that exists for it: collapse_leaf set `content` to the header rect, and a hidden pane's content is the size the program inside still believes it has. It is not resized on the way in and must not appear to have been. Nothing draws a collapsed pane's content, so writing a header rect there would only have made the lie more convincing.
* sl0ppty: pane states are a table, not three hardcoded knobsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | dim_unfocused, drag_grayscale and drag_dim were three knobs describing two situations, and there was no way to say anything about the third, fourth or fifth. They are replaced by a table: a state names a chain, and the chain is exactly as expressive as the global `shaders` block because it is the same parser. states { dragging { } drop_target { grayscale amount=200; dim amount=140 } suspended { grayscale amount=180; dim amount=90 } scrolled { tint amount=30 color="#ffcc88" } unfocused { dim amount=90 } } A pane is usually in several states at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. Fixed rather than read from the block, because it is a ranking by urgency rather than a preference — a pane you are holding should not be recoloured by anything, and a mode the whole screen is in outranks an ambient hint about one pane. They do not stack, which is the same call the drag policy already had to make by hand: two reasons to be grey compound into a muddy grey that reads as neither. Defaults are the old drag behaviour and nothing else, so out of the box this changes nothing. drop_hover is now separable from drop_target. The pane under the pointer was already distinguished by its border; it can now differ in colour too, and defaults to the same chain so it does not have to. Two states asked for did not survive contact with the code, and it is worth writing down why: `dead` cannot exist. app_reap() runs every iteration of the server loop, before the paint, and closing the last pane closes the tab — so a pane whose program exits is gone before a frame containing it is ever composed. A `dead` shader would name something that could never draw. Making it real means keeping dead panes until they are dismissed, which is a feature about lifetimes and not about colour. `suspended` could not be seen either, for a much smaller reason: draw_cb drew its "press a key to run" label and returned before reaching the shader pass. It falls through now. A pane that has not started is a state worth colouring, and its label is the only contents it has.
* sl0ppty: drag greys the other panes, and dim_unfocuseduser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The first two consumers of the shader pass, and between them they settle how a pane gets one. Dragging a pane by its title now greys every *other* pane, so the one that is moving lifts off the page — which is the thing that made a colour pass worth building. It engages only once the pointer has actually moved, because a press on a title that turns out to be a click would otherwise flash the whole session grey on its way to doing nothing. dim_unfocused fades everything but the focused pane and is off by default: an always-on effect is the kind you stop seeing and start fighting, so it should be asked for. Both are derived every frame rather than attached and cleared. Focus and drags move through far too many paths — hover, click, the finder, a close, a layout apply, any keystroke ending a drag — to keep an attachment in sync with, and a pane left grey by the one path that forgot is a bug that only turns up in front of someone. The rect and the collapsed flag are recomputed every pass for exactly this reason; this is the same rule applied to colour. It also makes the awkward cases free: dropping into the margin, a keystroke ending the drag, or the dragged pane being closed under it all un-grey without anywhere having to say so, and there are tests for each because "it turns off again" is the whole risk. The two policies replace rather than stack. Dragging an unfocused pane while dim_unfocused is on would otherwise give it two reasons to be grey, and they compound into one muddy grey that reads as neither; the drag wins, so the dragged pane is at full colour whatever the focus says. shade_apply no longer caps at SHADE_MAX. That cap was right when the only shaders were the ones a pane had attached, and wrong the moment the session derives one on top of them: a full stack would have silently dropped it. The count is the caller's to bound. The chrome check that mattered was already there: test_drop_target_is_visible samples a frame corner mid-drag, and it still passes, because the pass runs between the contents and the chrome drawn over them.
* sl0ppty: terminal shaders — a colour pass over pane contentsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A shader is a pure function from (cell, position, parameters) to that cell's colours, applied to a pane's contents. Tinting, dimming and greying are the first three; the point is the mechanism, because it is going to carry a lot of UX later — drag a pane and grey the others, dim what is not focused, mark a pane that wants attention — and all of those want to be a colour pass rather than another special case in the drawing code. Where it runs is the whole design. draw_cb already paints the frame, then the contents, then the split guide, so the pass goes between the last two and "contents, not chrome" stops being a rule anyone has to remember: the frame was painted before and lies outside the content rect, and an affordance painted after stays legible on top of a pane that has been dimmed underneath it. node_t already carries the content rect, so the pass has its bounds. Cells only, and colours and attributes only. Rewriting a cell's text would desync selection and copy, which read the terminal rather than the screen, and could break the wide-cell invariant; the tail half of a wide cell is skipped for the same reason, since shading it computes a colour nothing displays. Each cell is shaded independently — no neighbours — so anything positional works and convolutions do not. That is deliberate: blur would need a second buffer, and everything actually wanted here is a function of position and colour. The context carries the content size and rect-relative coordinates so gradients and vignettes are expressible, plus a timestamp for whatever ends up animated. The one real problem is that "terminal default" has no RGB. We never learn what the client's default foreground is, and most terminal text is default-coloured, so a shader that skipped those cells would leave nearly everything at full brightness and read as broken. The pass materialises them to a configured default_fg/default_bg (white on black) before running, which means a shaded pane stops tracking the terminal's own theme for those cells — so the config exists to make that right, and asking the terminal directly with OSC 10/11 is the eventual fix. A pane with no shaders skips the pass entirely and still defers to the terminal exactly as before. Attachment is internal policy: app_shade_add/clear, callable from inside the app and from nowhere else. Not over the control API and not in-band, so a program in a pane cannot restyle itself. Nothing attaches one yet. Kitty graphics are re-emitted as images rather than cells, so a shader cannot touch them: a greyed pane keeps a full-colour image in it. Known and accepted. Tested as a C unit test, since the pass is pure: the built-ins and their strength scale, luma weighting, chaining order mattering, defaults being materialised, wide-cell tails and text surviving, degenerate input, what the context tells a shader, and — the one that protects the chrome — that the pass touches nothing outside its rect.
* sl0ppty: rename from sl0pttyuser2026-08-14
| | | | | | | | | | | | | | | | A pty is a pseudo-terminal, and that is what this multiplexes: the extra t was never standing for anything. Renaming now, while the only users are the people who typed the old name on purpose, is cheaper than renaming later. Mechanical, but not only cosmetic. The name is also the runtime contract, so this moves $SL0PTTY and $SL0PTTY_CONFIG, ~/.config/sl0ptty/config.kdl, the socket directory ($XDG_RUNTIME_DIR/sl0ptty, /tmp/sl0ptty-<uid>), and the identity string a program gets back from an OSC 5577 hello. A session left running under the old binary keeps its old socket directory and is invisible to the new one; there is no migration and it does not seem worth writing one. The sl0ppi fork keeps its name: it is a different thing, and D1 still points at it.
* sl0ptty: kitty graphics passthroughsl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A program in a pane transmits an image and it appears. libghostty-vt parses the protocol and tracks images and placements; what a multiplexer has to add is re-emitting them to the client, which is exactly what tmux and zellij do not do — and the reason tools fall back to sixel under them. Per frame: walk the visible tab's panes, transmit each image once (a=t), place every visible placement (a=p,C=1) after the cell diff so a repainted cell cannot land on top of one, and delete placements that vanished. Image ids are remapped into a high range, so two panes both using image 7 stay two images and neither collides with what the client's terminal already had. A reattaching client is told everything again, because it has seen nothing. 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 — the client's font metrics never come into it. Clipping at a pane edge, or at the top of the viewport when a tall image is partly scrolled off, moves the source rectangle; asking for fewer columns or rows alone scales the image into them instead of cropping it. The first version did exactly that, and dropped partially-visible placements entirely rather than cropping them. Tests cover the multiplexer-specific case first: two panes, both using image id 7, must get different client ids. Then placement position in screen cells, following the layout, cropping at edges and when taller than the pane, nothing from collapsed panes, and removal when a pane closes or the image scrolls away. A live check confirms the wire: transmitted once, re-placed per frame, not re-transmitted. 416 checks green (389 headless + 27 live).
* sl0ptty: the split guide arms on dwell, not on contactsl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | A pointer crossing a border on its way somewhere else made the screen flash. The guide now waits for the pointer to rest (hover_delay_ms, 250ms by default, 0 to disable). The timer is on pointer stillness rather than on the hovered target, which is what makes a sweep across three borders arm none of them: every motion event that changes the position restarts the clock, so only stopping counts. Holding the button on a border skips the wait, because that is intent rather than passage. This needs the loop to wake when nothing has happened — a pointer that is deliberately not moving generates no events to repaint on — which is the same self-scheduled repaint toasts already needed, so app_next_deadline_ms grew a second reason rather than the loop growing a second mechanism. Four older guide tests waited 60ms and now wait for the dwell; they were asserting on contact because contact was all there was. 398 checks green.
* sl0ptty: fix the split guide describing the layout from before the splitsl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | Which border the pointer is over was remembered from the last motion event, so after a click split the guide stayed attached to the old pane and the old edge until the mouse moved and corrected it. It is derived during the paint now: draw_split_guide asks the hit list where the pointer is, using the entries that same pass just registered for that pane. Drawing and hit-testing already could not disagree; now drawing and the layout cannot either. The pointer position is the only thing kept. Same lesson as the hit list, one level up: do not remember what you can derive from the frame you are drawing. Regression test splits by clicking a border and then deliberately does not move the mouse, asserting exactly one pane shows a guide, that it is the pane the pointer is actually inside, and that it is on the edge the pointer is on. 391 checks green.
* sl0ptty: the border is the button — click an edge to split toward itsl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-pane '+' is gone. It was one glyph for a verb with four directions, so it always split into columns; it taxed every frame three columns of title forever; and there were N of them on screen for one verb. Clicking a pane's border now splits toward that border: the side you click is the side the new pane appears on. Hovering arms the edge (it goes heavy) and draws a dashed line exactly where the new boundary would land, so the gesture explains itself and an idle frame stays quiet. ╭──────────────────── shell ─────────────────────╮ ┃ ╎ │ ┃ ╎ │ hovering the left ╰────────────────────────────────────────────────╯ border │╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ hovering the bottom ╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯ border The top border keeps its other job: a click splits upward, a drag moves the pane. A press that never moves is a click, which is a distinction the drag machine already had to make. The guide has to be painted after the pane's content, not with the frame: the dashed line crosses the terminal's own cells, and the first version was drawn underneath them and was invisible. Two old tests changed rather than the code, both because the gesture set is better now: 'beside the split button' is a drag handle, and 'dropping a pane on itself does nothing' is a click, which splits. The replacement asserts what actually needs asserting — a drag that wanders and returns changes nothing. 387 checks green (360 headless + 27 live).
* sl0ptty: M8 — pane sizes as weights, resize and drag-to-reordersl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Sizes are weights now, so an even split is equal weights and resizing is not a special case of anything: the layout pass is still the same pure function of the tree and the rect, and a resized layout survives a collapse/expand cycle without anyone writing code for that. Keyboard: C-a shift-H/J/K/L (and shift+arrows) move the boundary in the direction pressed, whichever side of it the focused pane is on, which is what a person means by wider. A pane can be squeezed but never squeezed out. Mouse: one drag machine, two verbs, both starting from the hit list so neither can disagree with what is on screen. - a frame's top row is a drag handle: drop a pane on another to swap them, with the drop target highlighted while you drag; - the gap between two panes is the boundary: drag it to resize. The gap was dead space until now, and its hit rect is derived from the rects the children were just given rather than recomputed from the config — the same rule that keeps the split button honest. Any keystroke ends a drag. A release can go missing (the pointer leaves the terminal, a client detaches mid-drag) and a mouse wedged in a state the user cannot see is the worst outcome available. One test needed updating rather than the code: the frame's top row is now the drag handle, so the cell beside the split button reports title: rather than focus:. That is the intent, and the hit list said so immediately. 304 checks green (277 headless + 27 live).
* sl0ptty: M7 — layout files and suspended panessl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A session can be a file: layout { tab name="api" purpose="project:api.a1b2c3" cwd="~/dev/api" { pane purpose="agent:main" command="pi" pane split="rows" { pane purpose="service:web" command="npm run dev" suspended=true pane purpose="shell:scratch" } } } built by --layout at startup or applied to a running session with {"cmd":"apply-layout", path|kdl, replace?}. Idempotency stays with the caller, which is where sl0ppi's CLI design already puts it: diff list-tabs against the projects on disk, then apply what is missing. close-tab completes that loop. Purposes a layout declares are locked, so identity comes from the file rather than from whatever the program inside prints — tested by having a pane try to take one in-band and be refused. Suspended panes: a pane that exists, is laid out, and has run nothing, showing what it would run and starting on its first keystroke (which is consumed, not forwarded). This is sl0ppi's D6 — a root with twelve projects must not become twelve running servers — and it is the reason can be cheap. Also fixes a test-isolation bug latent since M2: the live tests attached to the session called "main", which is whatever happens to exist on the box rather than one they created. They now use a unique session per run, which is how a run that had somebody else's htop in it became reproducible. 284 checks green (257 headless + 27 live).
* sl0ptty: M6 — the config file, and a READMEsl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | D2, delivered: a hand-rolled KDL subset (kdl.c, 300 lines, no dependency) with arguments, key=value properties, children, // and /* */ comments, and ; terminators for single-line nodes. config/config.kdl documents every setting by being the defaults. Geometry, theme, the prefix and every binding now come from that file instead of being compiled in. Bindings are a table of (key, mods) -> action, so an action can be rebound, unbound with "none", or given a different prefix entirely; caps and num lock are masked out so they cannot stop a binding working. The property that matters is fail-open, applied to the mux itself rather than only to the CLI wrapper: a missing config is silent, a broken one writes the line number to stderr and keeps the compiled-in defaults, and a bad binding does not discard the rest of the file. A live session can reload, and a reload that fails to parse keeps the configuration that works instead of leaving you with half a theme. min_pane is configuration too, so the point at which a layout collapses is a preference rather than my taste — tested by making the same split collapse or not on the same terminal size. 14 parser cases as a C test, 24 config checks through the harness, and a README that shows what the thing actually looks like. 250 checks green.
* sl0ptty: M5 — responsive collapse, status bar, pane findersl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | D6 as promised: layout(tree, rect) recomputed every frame, and a split that cannot give every child a floor (24 cols / 6 rows) collapses *itself* into a stack — one expanded child, every sibling a one-row header. No swap layouts, no remembered position, no synthesized anything. Three properties fall out of the design rather than being coded: - the expanded child is the one holding focus, so focusing a collapsed pane expands it: from the finder, by clicking its header, or by walking into it; - a hidden pane is never resized, so its program does not reflow off-screen; - a narrow/wide cycle returns the exact rects it started with, and a pane added afterwards is not stacked. That last one is the fork's 'panes stacked after a responsive collapse/expand cycle' bug, asserted directly. It cannot regress here without the layout function growing memory it does not have. A collapsed header shows the pane's title and its OSC 5577 status, so a stacked agent is still legible — the monitored strip, without a 'rail' mode. Below the size where headers fit, a node shows only the focused subtree: at 12x4 you get one pane instead of rects that do not fit on the screen. Every size from 200x60 down to 12x4 is asserted for overlap and containment. Status bar: tabs, a +tab button, live pane count, and the held prefix shown as a mode. The button is '+tab', not '+', because the frame already has a '+' for splitting — two verbs sharing a glyph is a UI bug the fork shipped, noticed, and wrote down; the collision showed up here as two hit-list tests failing. Pane finder (C-a f): substring filter over title, purpose and tab name across every tab, keyboard and mouse, painted last so its rows win the hit list. It is how you reach a pane the screen has collapsed away, which is tested. Deferred to M6: drag-to-reorder, which wants the same drag-state machine as pane resizing and should be built with it. 213 checks green (186 headless + 27 live).
* sl0ptty: M4 — OSC 5577 pane status bars, buttons and click reportssl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The payoff milestone: sl0ppi's pi extensions work here unmodified. printf '\033]5577;1;status;building 3/7\033\\' printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\' ╰ building 3/7 ──────────────────────[Approve]─[Cancel]─╯ libghostty-vt's UNKNOWN_SEQUENCE reports APC only, so the sequences are picked up by a scanner (osc5577.c) reading the same bytes the terminal does — lib-vt discards an OSC it does not know, so nothing is drawn and no pane output has to be buffered. The scanner is a byte-at-a-time state machine, so a sequence split across reads still lands. Buttons are budgeted from the right before the status text gets any columns, and each registers its hit as it is painted — the split button's rule, for the split button's reason. A press sends the click report to that pane's stdin. In-band purposes arrive here too, and by construction can never be declared: a pane printing one is asking, not telling. A pane cannot relabel a purpose a layout declared, which is now tested end to end rather than only at the API. One real bug, found by the hostile-input tests: an over-long button id was truncated into a *valid* one, so a pane could mint a 40-character id that collides with the 32-character id its program already trusts. Ids are now unescaped into a large buffer and validated at full length. Acceptance test replays answer-picker's exact byte patterns, including `buttons` with no payload (drop buttons, keep status) and a click report that matches the extension's own CLICK_RE. Four test bugs on the way, each worth remembering: printf eats %3B as a format specifier; the driver's unescaper reads \0 as NUL so \033 became NUL+'33'; a trailing ST inside a double-quoted shell string escapes the quote (BEL avoids it); and readline eats a typed ESC as a meta prefix, so tests that drive a pane must not use an interactive shell. 187 checks green (160 headless + 27 live).
* sl0ptty: M3 — tabs, purposes, and the JSON control APIsl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tabs are layout trees, one per tab, and panes in every tab keep running: fds, pumping and reaping span all of them, only the current one is composed. A one-row strip shows them (M5 makes it the real status bar) and each label registers a hit, so clicking a tab switches to it through the same mechanism as every other click. C-a c new tab C-a n / C-a p cycle C-a 1..9 select Purposes on panes and tabs with D8's trust model implemented as a lock: declared (from a layout or an operator) outranks in-band, cannot be overridden, and everything is sanitised to [A-Za-z0-9_.:/-] on ingest — 'project:foo bar/../$(rm -rf)' becomes 'project:foobar/../rm-rf'. The in-band path arrives with the OSC scanner in M4 and always passes declared:false; the refusal is already tested. The control API is now JSON in, JSON out: {"cmd":"new-tab","name":"agent","purpose":"project:x.deadbeef"} {"ok":true,"id":2} with panes/tabs/snapshot/send/raw/resize/split/focus/close/select-tab/ set-name/set-purpose/alive/quit. Panes are addressed by id, not by 'the focused one', so a background tab can be scripted. The bare-verb form stays as a harness alias and runs the same code, so the tests cannot drift from the API. Adds a small JSON reader (jsonval.c) — we could only emit until now. 129 checks green.
* sl0ptty: M2 — server/client split, detach/reattach, control socketsl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The session now outlives its client, which is the point: agents keep running while you are gone. The server owns the panes, the layout and the renderer; the client is a terminal in raw mode and a socket, forwarding bytes and painting what it is sent. It does not even decode input, so the decoder can change without redeploying clients. sl0ptty attach to 'main', creating it if nobody is home sl0ptty -s NAME a named session sl0ptty ls live sessions sl0ptty cmd LINE one control command C-a d detach Connections are not clients. A connection becomes the display client only by sending HELLO; control commands and liveness probes are just connections and never displace anybody. This was found the hard way, one hour after the server first ran: 'sl0ptty ls' kicked the attached client off, because ls connects to see whether a socket answers. That is precisely the fault that gave the zellij fork a 20-25% CLI failure rate, rebuilt and then fixed structurally. The regression runs 20 x (ls + control) against a live client and demands 0 failures and a client that can still type. cmd.c now holds the command vocabulary once, shared by the headless driver and the control socket, so a script written against one works against the other — 'sl0ptty cmd "snapshot text"' against a detached session prints its screen. tests/test_session.py: 18 checks over create, split, probe-resistance, detach, survival, drive-while-detached, reattach, displacement and cleanup. 123 checks green overall.
* sl0ptty: M1 — layout tree, splits, focus, frames, and the hit list in usesl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | app_t owns the tree, focus, the prefix and what a key means; the interactive client and the headless driver are now both thin front ends over it, so there is exactly one implementation of every command. Layout is the pure function D6 promised: layout(tree, rect) recomputed every frame, remainder spread across children so nothing drifts, and splitting in a direction that already exists grows that split rather than nesting — three splits give three equal columns, not 1/2 + 1/4 + 1/4. Chrome: rounded frames, aspect-corrected gap, centred titles. The split button is budgeted BEFORE the title and hit-tested from the rect it was drawn in, which is the fix the fork needed four attempts to land. Every painted element registers (rect, action) as it is painted; a click is a lookup. Mouse into a pane is translated to pane-local coordinates. Three bugs the harness caught, none of which unit tests would have: - one click on the split button split twice: press *and* release both fired. Chrome now acts on press; forwarding to a pane still gets every event. - callbacks were registered as &fn, but the option is pointer-typed, so the stored 'callback' was a stack address. Any OSC 2 or DSR jumped into a dead frame. Now a hard segfault turned into two regression tests. - a keystroke that changed nothing still emitted a cursor hide/show pair. The cursor is hidden lazily now, so an idle frame costs zero bytes. 96 checks green in 6s (40 unit, 23 screen, 33 layout), plus 9 live.
* sl0ptty: M0.5 — the headless driver and the harness everything rests onsl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | --script runs the whole compositor with no tty: commands in (send, raw, settle, resize, snapshot, alive), composited screen out as JSON — rows of text, style runs, cursor, and the hit list. Styles are emitted as runs rather than per-cell objects, because a test wants 'columns 0..5 of row 0 are bold red', not 1920 objects. The hit list is empty until M1 paints something clickable, but the type, the JSON, and hit_test() exist now: one geometry, established before there is anything to get wrong. Also fixes two host-policy defaults found by the cursor never appearing. lib-vt is a VT library, not a terminal, so it starts with DECTCEM (mode 25) off and grapheme clustering (2027) off. Both are now set via MODE_DEFAULT, so they survive RIS. tests/harness.py + tests/test_screen.py: 19 checks over composition, styling, wide chars/clusters, scrollback, resize and the full input round trip — including the kitty/legacy re-encoding matrix, which no longer needs a pty. make test is now 2.4s for 59 checks; make test-live keeps the four that genuinely need a tty.