aboutsummaryrefslogtreecommitdiffstats
path: root/src/cmd.c (unfollow)
Commit message (Collapse)Author
2026-10-09panes: the focus a closed tab left behinduser
A tab's focus outlived the tree it pointed into. close_leaf's last-pane branch freed the leaf and removed the tab but never cleared `t->focus`, and tab_remove only shifts the slots *after* the one it removes -- so with the last tab gone, `a->tabs[0]` kept the stale tab_t while `ntabs` was 0 and `cur` was 0. `cur(a)` is `&a->tabs[a->cur]` with no bounds check, and `a->quit` is a request rather than a barrier: the loop keeps serving commands, and the next one to ask for the focused pane was handed freed memory. From outside it looked like a random crash, because what the freed chunk held depended on what had been allocated since. Two from one afternoon: a pane_t recycled into a `panes` JSON reply, whose `term` pointer was then the ASCII of `k":true,` and faulted as a general protection fault; and the same node read back zeroed, which left pane_clear_bell testing a flag at NULL+0x5d. Typing `exit` in the last pane is the whole recipe. close_leaf drops the focus with the tree tab_remove blanks the slot the shift vacated app_compose tests ->pane as well as ->focus, the way the search beside it already did app_apply_layout clears restore_focus up front -- it is a node pointer, and an apply that fails before consuming it leaves it naming a tree the next apply frees Two more the same reading turned up, both in close_leaf: an emptied split set the focus to the node just freed, and a leaf whose parent disagreed with that parent's child list underflowed `p->nkids - at - 1` into a memmove of nearly SIZE_MAX. FOCUS_CHECK -- behind -DSLOSH_FOCUS_CHECK, compiled out otherwise -- is the invariant all of this turns on: every tab's focus is a leaf of that tab's own tree. It is what found this one. Committed with --no-verify: the files are deliberately partly staged, and the staged blobs were checked against clang-format directly.
2026-09-29layouts, moves: build panes without pulling the view to themuser
Applying a layout landed on the first tab it made, and moving a pane into a tab took focus there. For a program building a pane in the background that is two failures at once: it yanks the view off the human mid-sentence, and it leaves the *next* caller to read focus as "where I am" and put its work in a stranger's tab. `apply-layout focus:false` builds the tabs and keeps looking at whatever was on screen; a layout naming an `active` tab still wins, because that is a statement about where to be. `move-pane` gains `beside` (which pane in the destination to split, rather than whatever it has focused) and `focus:false` -- the two things a caller placing a pane next to *itself* needs. That uncovered a sizing bug that was unreachable before: a pane's size comes from a layout pass, and a tab nobody has looked at never had one, so a pane built in the background stayed 1x1 and its program drew into a single cell. The jump is what used to size it. layout_tab() gives a background tab a real pass, borrowing the tab cursor for its length -- nothing composes, reads input or draws a frame in between.
2026-09-29panes: read and write a pane by id, without taking focususer
`raw` wrote into the focused pane and `snapshot` composited the active tab, so driving a pane anywhere else meant focusing it or selecting its tab first. Both of those move the view of whoever is watching, and both lose to anybody doing the same thing at the same time. `raw` now takes an `id` (0 is the focused pane, as before), and `capture` answers one pane's viewport as text -- from the pane's own terminal, so it works for a pane in a background tab, changes no focus, and leaves the human's selection alone. It builds its selection as a snapshot rather than setting one on the terminal, because a read with a visible side effect is not a read. `capture` tells "no such pane" apart from "that pane is empty": a caller polling a dead id would otherwise wait forever on a pane that is quiet because it is gone.
2026-09-22themes: ask the binary where it is, not the prefix it was built foruser
`theme_name "sl0p"` with all seven themes sitting in /usr/share/slosh/themes: "no theme named sl0p". The lookup knew two places -- your own themes directory and the PREFIX this build was compiled with -- and on that machine the first did not exist and the second was /usr/local, because that is the Makefile's default and the package was built with PREFIX=/usr. Neither was where the themes actually were, and nothing in the search could ever have found them. A compiled-in prefix is a guess about where the binary will end up, and the binary is the one thing that knows where it really is. So the search now asks it: path_self() (/proc/self/exe, _NSGetExecutablePath, GetModuleFileName -- lifted out of server.c, which was already doing this for SLOSH_BIN) gives <bin>/../share/slosh/themes for an installed slosh wherever it was installed to, and <repo>/contrib/themes for one being run out of a build tree, which makes a checkout able to name a theme without installing anything first. The compiled-in prefix stays as the last candidate, for a binary sitting somewhere that says nothing about its data, and duplicates collapse so the usual install does not report the same directory twice. Your own directory is still first and still shadows everything, which is the property that matters; the rest is about the shipped set being findable at all. The tests that assumed a closed world of two temp themes now narrow to their own names by typing, since the shipped ones are (correctly) always in the list -- and the "no themes anywhere" toast lost its test, because with the shipped set reachable that state takes a binary with nothing beside it. The case that started this is a test of its own.
2026-09-22theme: a name and a directory, not sixty colours in your configuser
Wearing a theme meant pasting its `theme { }` block into config.kdl, or including a path to a file you had copied there yourself. Both make the palette a literal in your config, and a literal is the one thing you cannot change cheaply: not from a script, not from a running session, not by a list of what is available, and never again from upstream -- the colours you pasted are the colours that theme had the day you pasted them. So a theme is a file in a directory now, and a config names it: theme_name "phosphor" theme { frame_focus "#00ff88" } // ...but that one colour is mine theme_name is applied after the includes and before everything the file says itself, which is the rule includes already follow and for the same reason: the loader reads a document by asking it for names rather than walking it in order, so "here" is not a position it could honour, and "what you asked for is the base, what you wrote is yours" is the rule worth having. Two directories, yours first: `themes/` beside the root config -- beside the file that was loaded, so a config somewhere else (SLOSH_CONFIG, or one handed to --check) takes its themes with it -- and then <prefix>/share/slosh/themes, compiled in from PREFIX. install(1) has been putting themes there all along and nothing could read them; now a shipped theme is nameable without being copied, and a file of yours with the same name shadows it, which is how a root-owned theme gets edited. A name, not a path. The directory is what makes a theme switchable, listable and shadowable, and a path silently does none of those, so `theme_name "../x.kdl"` is refused with a line pointing at `include`, which still works and is still the answer for a file somewhere a directory of names cannot reach. A theme that is not there is one complaint and no more (D9): the rest of the config applies, because losing your keybindings over a mistyped colour scheme would be the wrong trade. A theme file is read by the same loader, so it is watched like any other: saving one reloads the session wearing it, and the derivations (tab_status_fg, tab_idle_bg) run over the theme's own colours exactly as before. Switching without editing anything, over the socket a session already has: slosh -s main cmd theme # amber default* ... slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}' The session holds the name over later reloads, so saving your config for an unrelated reason does not put the old colours back; `save` writes the theme_name line into the file, replacing the one already there and leaving every other line and comment alone -- dumping the whole config back over itself would reformat a hand-written file and drop its comments, which is not a fair price for changing a colour scheme. A name is tried on a throwaway config first, so a typo costs nothing. An empty name gives the answer back to the file. --dump-config stops writing colours: it writes the theme's name and the colours a config overrode by hand, so a dumped config keeps following its theme instead of freezing it. --dump-theme is the other half and is new: the resolved palette as a theme file, which is how you start one from the session in front of you rather than from an empty file. It is also what keeps contrib/themes/default.kdl honest -- that file is now the annotated reference (the commented palette moved there out of config.kdl, where it was 119 lines of colour in the middle of the prose), and a test compares it against the compiled-in one. While in there: tests/test_themes.py's "every theme sets every colour" guard had been grepping for `&c->`, the spelling before the colour table moved to offsetof, so it had been finding zero colours and passing for months. Fixed, with the derived and opt-in colours exempted for the reasons they are derived and opt-in, and with a check that the guard found a table at all.
2026-08-20slosh: M10c, the memory -- floats survive being written downuser
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.
2026-08-20slosh: floating panes and the drop grammaruser
A pane can float: lifted out of the layout, drawn over the tiled panes and under the modals, freely moved and shaped. The model is a flag and a rect on the leaf, exactly like `minimized` -- the seat in the tree is kept, layout skips it, the siblings absorb its share, and un-floating returns it home. The drawn rect is the wanted one clamped to the tab every frame, never written back, so a terminal squeeze round-trips exactly; an explicit move or resize *does* write back, because the hand is the author of the intent. Floating pops the pane to the centre: the jump is what says it happened. Painted after everything tiled, so the hit list -- searched backwards -- resolves an overlap to the float you can see: the z-order and the click order are the same list. It casts a shadow (float_shadow, one number), and `floating` joins the states table above `unfocused`, so a float is never dimmed as merely-unfocused. The hands: the title moves it, any other border resizes it (a bottom corner on both axes; the grabbed edge pins at walls and floors, never sliding the window), H J K L move a focused float, the =/+ key grows it about its centre, `-` shrinks it (its split was a refusal anyway), and hovering shows which edges a grab would take, from the same derivation the press uses. Equalize moved to `0` to free the key; `C-a f` floats (finder went to `s`, where it reads as search); `C-a F` opens a new floating shell -- the throwaway terminal; the function row f1..f12 became nameable in configs along the way. Guards throughout: the last tiled pane cannot float or minimize away, closing it lands the top float, splits are refused with their own words, floats are not swap targets. Dropping got a grammar: while a pane is carried, the pane under the pointer subdivides into a swap centre and four edge bands meaning "insert me beside this pane, on this side" -- detach_leaf + place_beside, the surgery cross-tab moves already had. Bands are thirds (the first map of quarters showed the swap owning half of each axis), the hovered band fills the half the drop would hand over, the release resolves against the painted frame's hits so it cannot disagree with the fill, and a drop between siblings along the axis is a reorder that always fits -- refusing it was refusing the most ordinary drag there is. Also: rename-pane/rename-tab as palette actions (renaming was a double-click and nothing else, the one verb the palette could not reach), and C-u clears the rename editor like it clears the picker's query. Design log in PLAN-FLOATING-PANES.md; to fold into DESIGN.md as D22/M10-11.
2026-08-19slosh: the logo flies into place -- a particle engine, statelesslyuser
Every non-space glyph of the logo is a particle that flies to its cell over the first two fifths of splash_ms, then *is* the logo for the rest. There is deliberately no particle state anywhere: a position is a pure function of (target, elapsed, seed), recomputed every frame like the layout and the pane states and for the same reason -- physics that is stored can drift, physics that is derived cannot. The seed is the splash timestamp, so every attach flies differently and one splash is coherent frame to frame; a per-particle hash staggers departures, and the delay rescaling lands every straggler exactly at the end, because an almost-assembled logo reads as a bug. Five motions, picked by the clock and pinned by {"cmd":"splash", "motion":N}: rain (fall in, column by column), scatter (converge from everywhere), slide (rows enter from alternating sides), vortex (spiral in around the centre, orbit shrinking and unwinding), shuffle (the glyphs trade places, then sort themselves). The colour chains keep playing over the box throughout, so particles catch their hue as they cross into it. expr_sin() is the expression language's own sine exported for C-side animation: a motion sketched in the repl and one compiled into the binary cannot disagree about what sin(90) is. The tests learned two things the flights taught: the logo's opening run appears three times per line and assembly is staggered, so a middle occurrence can land first -- "assembled" is the row whose first block glyph is where the match begins -- and windows need margins that survive a parallel suite on a busy box.
2026-08-19slosh: the splash learns colouruser
One tint is one colour, and every effect wore the theme accent -- six effects that were all the same dull blue. An effect is a *chain* now: phase-shifted tints in loud literal colours, mixing per cell, which is how a fixed-colour pass machinery does rainbows. Passes may light the backdrop's background too, so the box is a scene rather than tinted text, and the logo is drawn white -- the canvas that takes every hue at full saturation, where tinting blue towards pink lands on mud. The six: spectrum (rainbow bands), plasma (two interference patterns in clashing hues), fire (heat up the glyphs, embers in the box), rings (rainbow ripples), glitch (a reveal under two colours of static), aurora (green and violet curtains over cyan). The splash is deliberately the one place that does not defer to the theme: it is branding wearing a demo, not chrome. {"cmd":"splash","fx":N} picks an effect by index -- a test needs a deterministic one, and "show me the aurora again" deserved an answer. The new test counts dominant-channel hue families on the logo rows, because two hundred shades of the same blue is exactly the failure it guards against.
2026-08-19slosh: greet an attach with the logo, wearing a random shaderuser
logo.txt is the source of truth; the Makefile bakes it into build/logo.h the way version.h is baked. When a client attaches (MSG_HELLO turning a connection into the display), the logo appears centered over everything for splash_ms (900, 0 for never), on a cleared backdrop, in the accent -- and one of six effects runs over its rect through the ordinary shader pass: a diagonal sheen, plasma, rings, sparkle, a left-to-right reveal, a breathe. Which one is picked by the splash's own timestamp, so every attach looks a little different without anybody keeping a counter. Every effect reads the clock, so the frame clock runs only while the splash is up (note_animation, same as any animated chain), and `since` in a splash context is milliseconds since it appeared, so one-shot effects sweep exactly once. Any key or click ends it and still lands where it was aimed: a greeting is not a modal. A screen the box does not fit on gets no greeting rather than a cropped one. Headless and scripted sessions never attach, so the whole fast suite never sees it; {"cmd":"splash"} triggers it on demand, which is what makes it testable from a scripted session -- and lets you replay the greeting whenever you like.
2026-08-17sl0ppty: clang-format the treeuser
The bumpy one. `make fmt` over every `.c` and `.h` outside `vendor/`: 32 files, 1,843 lines rewritten, no behaviour changed. Nothing here is worth reading -- the next commit adds it to `.git-blame-ignore-revs` so `git blame` walks past it. Two thirds of it is alignment the formatter now owns: trailing comments in a struct, continuation lines under a signature. The rest is the hand-packed tables opening up to one entry per line -- `ACTIONS`, `NAMED_KEYS`, `ACTION_HELP`. That is a real loss of density and a real gain in diffs: adding an action was a reflow of the block and is now one line. Anything worth keeping packed can be fenced with `// clang-format off`; nothing was, on the grounds that a table is the thing a formatter is for. **One change is not formatting.** `src/config.h` had three trailing comments glued onto one field: uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */ -- `toast_ms`'s and `scroll_lines`' comments, landed on their neighbour by an earlier edit, documenting the wrong things ever since. It is in this commit because it is what caught it: clang-format could not settle on that line, wrapping it one way and then re-indenting the wrap on a second pass, so `make fmt` was not idempotent. The comments are back where they belong and it is. Verified after the reformat, not before: `make clean && make all` is warning-free, `make retest` is 1801 checks green, and `make fmt` twice in a row changes nothing the second time. A formatter that alters behaviour would be worse than none, so the gate was the whole suite rather than a reading of the diff.
2026-08-17sl0ppty: `set-name target="pane"`, so a script can overrule a program's titleuser
A pane's name already outranks the title its program sets -- `pane_title` prefers the name while there is one -- and that is the whole remedy for a program announcing something it stopped meaning: an agent still spinning the summary of a task it finished, a build script that titled itself once. The remedy was reachable by double-clicking the title on somebody's screen, and from a layout file, but not from the socket, while `set-purpose` next to it has taken `target:"pane"|"tab"` all along. `target` defaults to `tab`, which is what this verb has always meant. Two verbs whose defaults differ is a smaller wrong than a verb that quietly changes its subject under scripts already written against it. The refusals say `no such pane` or `no such tab`, so a mistyped target shows up in the reply rather than as a label that did not move. The gesture now calls the same function, so the two ways in cannot disagree about where a pane's name lives -- there is a test that a name set over the socket is the name the double-click editor offers to edit. Verified: eight checks in `tests/test_pane_title.py`. A pane named while its program keeps setting OSC 2 titles keeps the name; clearing it hands the label back to what the program calls itself *now*; `id: 0` is the focused pane and leaves the other one alone; `set-name` with no target still renames the tab and leaves the pane's title alone.
2026-08-17sl0ppty: workspaces, and layouts that write themselvesuser
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.
2026-08-16sl0ppty: a pane can change tabsuser
sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2}' sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2,"dir":"rows"}' sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":0,"name":"logs"}' The mechanism only. There is no key and no gesture for it yet -- that is the next conversation, and guessing at it now would mean building the wrong one twice. **The pane keeps running.** Measured, not assumed: `pid=3062557` before the move and `pid=3062557` after, in a different tab, with its scrollback intact and still answering what it is sent. That is the whole reason to move a pane rather than open one where you wanted it and close this one. It is tree surgery and it reuses the surgery that was already here. `place_beside` is the second half of `split_node_with` -- everything after "make the leaf" -- lifted out so that a leaf which already exists somewhere else can be grafted the same way, flat sibling or new split, on either side. It takes the tab rather than `cur(a)` now, because replacing a root has to land on the right one and `cur(a)` was only right while the sole caller was splitting the pane you were looking at. `detach_leaf` is `close_leaf` without the funeral. Three decisions worth naming: **The destination is a tab id, not an index.** A tab whose last pane leaves has nothing to be, so it is removed -- and every index after it moves. An id survives that; an index quietly means a different tab. The move re-finds the destination by id *after* the removal for exactly this reason, and there is a test that empties a tab and checks the pane landed where it was sent. **A move drops what the old tab thought about the pane** -- a zoom naming it, and its minimised flag. Both are the tab's opinion and not the pane's: carried across, the first zooms a pane that has left and the second files the arrival in a strip nobody asked for. **It does not follow the pane.** Which tab you are looking at is a question about intent, and this is the mechanism; `move-pane` answers with the tab id so a caller can select it if that is what it meant. "Into a tab of its own" is its own call rather than new-tab-then-move, because a pane that is the only thing in its tab would otherwise have its tab removed from under the new one, and the order those two happen in is precisely the bug. Verified: `tests/test_move_pane.py`, 25 checks -- the pane still running and still talking afterwards, placement beside and under the destination's focus, focus landing on the arrival, the emptied tab, a tab of its own, every refusal (no such pane, no such tab, already there, already alone), the dropped zoom and minimise, and after three moves a layout with no overlaps that dumps and rebuilds as itself.
2026-08-16sl0ppty: three ways back from a painted paneuser
printf '\033]5577;1;shader;\033\\' # from the program sl0ppty cmd '{"cmd":"clear-shaders","id":0}' # from outside it bind "z" "clear-shaders" # from the keyboard `app_clear_pane_shaders` empties both of a pane's chains and says whether there was anything there, which is what lets a key answer "shaders cleared" or "no shaders on this pane" instead of maybe having done something. In-band it is not a new verb: naming no rect means all of them. `shader;` sits under `shader;chrome;` and `shader;content;` as the degenerate case of "which rect", so a program puts a pane back in one exchange and cannot leave it half done. `contrib/shader-repl`'s `:both` now uses that rather than two. **None of the three is gated on `in_band_shaders`.** A chain outlives the setting that allowed it -- paint a pane, then turn the setting off, and the paint is still there, because a chain belongs to the pane and not to the file (there is a test that reloads with it off and clears anyway). A way out that the setting can take away is not a way out. The operator path matters for the same reason: the program that made a pane unreadable is not always in a state to put it back. They clear what the *pane* set. The config's chains and the session's own dimming are not this pane's doing and are derived every frame anyway. The action is bound to nothing, like `scroll-up`: it is in the palette, which is what the palette is for, and the docs say to bind it if you leave the setting on. Which is where a second bug turned up -- the palette truncates a label at 26 characters, so "undo this pane's own shaders" read as "undo this pane's own shade" on screen. Renamed to fit, and `tests/test_palette.py` now checks every label against the column, since nothing failed for that: it just looked like a typo in front of someone.
2026-08-15sl0ppty: say what the config could not honouruser
A config that half-applied said nothing. An unknown shader, a binding that does not parse, an include that is not there: the file loaded, that line did not, and the session announced "config reloaded" as though nothing had happened. Every one of those messages was already being produced -- config_load has filled `err` on a successful load since it was written -- and then dropped on the floor by every caller, because a non-zero return meant "fine". So the complaint from the last load is kept, and the front end says it where it would have said "config reloaded". Once at startup too: a config that loaded with a complaint still started the session, and the stderr line goes where nobody attached can read it. The `reload` control verb answers `{"ok":true,"warning":"..."}` -- ok, because the file did apply, with the reason attached for a script that cares. Two things fell out of writing the test for it. An include's complaint was reported by nobody when the file in the middle was fine. apply_includes only looked at the return value, so a theme that loaded while *its* include was missing swallowed the message: the child returned true, and true meant there was nothing to say. The complaint now travels up whether or not the include itself loaded. And a complaint now carries the name of the file it happened in, exactly once -- added by the file that produced it, never by the files that included it, because prefixing at every level builds a chain that truncates before the part that matters. So: t.kdl: unknown shader: bloom keys.kdl: line 2: unknown action fly b.kdl: includes nested too deep (a cycle?) cannot open /home/you/.config/sl0ppty/themes/nope.kdl which is enough to know which file to open, in a message that fits in a toast.
2026-08-15sl0ppty: shaders on the chrome, and two keys for the arrangementuser
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.
2026-08-15sl0ppty: C-a e opens the config, in a pane, in your editoruser
The config watcher's whole point is the loop -- edit, save, watch the session change under you, edit again -- and the loop had a hole in the middle of it: you had to leave the session, or remember where the file lives, to start. So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than across because a config file is lines and half the height beats half the width for reading them. `editor` in the config beats $EDITOR, and vi is the fallback, being the one editor a POSIX system must have -- a wrong guess you can see and change beats a pane that opens empty. The interesting part is what kind of pane it is. Panes given a command keep their corpse (D14), which is right for a dev server and wrong for this: quit the editor and you would be left dismissing a dead pane you opened thirty seconds ago. Rather than gaming that policy by giving the pane no command -- which would be a lie in the one field dump-layout and re-run read -- panes can now be *ephemeral*: a task rather than part of the session's shape. An ephemeral pane keeps no corpse and is not written into a dumped layout, so a restart does not reopen somebody's editor on a file they had finished with. That concept is the reusable half of this commit; the keybinding is the part you notice.
2026-08-15sl0ppty: write the session back out as a layout, and a loop that uses ituser
Dogfooding this while building it has one sharp edge: a running session keeps the binary it started with. `reload` re-reads the config, it cannot re-read the code, so every build you want to try means losing your screen. The only thing worth carrying across a restart is the shape, and sl0ppty already knows how to read one -- it just could not write one. `dump-layout` is the inverse of `--layout`: tabs with their names and purposes, how the panes are split, in what proportion, in which directory, running what, which tab was active and which pane had focus. Two details make it worth trusting. The directory is where the shell *is*, read from /proc, not where the pane was started -- after any amount of `cd` those differ and only one of them is useful. And a pane running the session's default shell is dumped with no `command`, so it comes back as a shell rather than as a shell wrapped in a shell, one layer deeper every restart. contrib/sl0ppty-dev is the loop: attach, and when the session ends because you asked for a restart, come straight back with the layout. It is two halves because it has to be -- `restart` runs inside the session, and the moment the session ends so does the shell it was typed in, so it leaves the layout and a note and the loop outside picks them up. What does not come back is what was running *inside* a program. A shell returns as a shell, in the right directory, with an empty history. That is the limit of restoring a layout rather than a session, and the script says so rather than implying otherwise. Three bugs found by the round-trip test, all the same kind -- a dump that loads without complaint and rebuilds something subtly different: - `split=` belongs on the node that *has* the children, not on the children. Written the other way it parses fine and gives the wrong tree. - a split's weight was never restored, because the edit meant to read it silently matched nothing and was never applied. - `active=true` landed on the wrong tab: the index is recorded while the new tabs sit after the old ones, and `replace` then moves them to the front. Which is why the test asserts that a dump of a reload is byte-identical to the first dump. Anything the dumper loses shows up there at once, where reading it and nodding would not.
2026-08-14sl0ppty: re-emit what the program meant, not what it coversuser
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.
2026-08-14sl0ppty: carry the terminal's cell size, so images can have a sizeuser
"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.
2026-08-14sl0ppty: a tab is something you can name and moveuser
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.
2026-08-14sl0ppty: a pane outlives the program that was in ituser
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.
2026-08-14sl0ptty: kitty graphics passthroughsl0p
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).
2026-08-14sl0ptty: select-to-copy, middle-click paste, and toastssl0p
Drag over text, release, and it is on your clipboard — no chord, no menu, the selection is the copy. The clipboard lives on the client's machine, so the copy travels as OSC 52 and therefore works over ssh; the session keeps its own copy as the primary selection, which is what middle click pastes back. Selected cells are inverted rather than recoloured, so a selection reads the same over whatever theme the program inside is using. A program that is tracking the mouse keeps its own clicks (shift is the escape hatch), which is tested rather than assumed. Toasts: transient announcements in the bottom-right that expire on their own, stacked up to three. 'copied 13 chars', 'config reloaded', a pane's OSC 9 notification, or {"cmd":"notify","text":"..."} from an orchestrator — an agent in a pane can now say 'build finished' and have it appear. OSC 52 from a pane reaches the same clipboard. Two real bugs found on the way: - mouse events are hit-tested against what was painted, and nothing had been painted before the first frame, so a click that arrived first was dropped. Both front ends now compose once before accepting input, which is what really happens in a session anyway. - the plain leaf_new never installed the clipboard and notification handlers; only panes built from a layout file had them. Every ordinary pane was silently missing both. The test escaping traps continue to earn their comments: a real newline inside a driver argument terminates the *command* rather than reaching the shell, \a is not a driver escape (\x07 is), and an interactive shell's line editor eats a typed ESC — so a pane driven with escape sequences must not be one. 356 checks green (329 headless + 27 live).
2026-08-13sl0ptty: M7 — layout files and suspended panessl0p
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).
2026-08-13sl0ptty: M6 — the config file, and a READMEsl0p
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.
2026-08-13sl0ptty: M3 — tabs, purposes, and the JSON control APIsl0p
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.
2026-08-13sl0ptty: M2 — server/client split, detach/reattach, control socketsl0p
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.