<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/harness.py, branch docs-new</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/tests/harness.py?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/harness.py?h=docs-new'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-18T19:26:00Z</updated>
<entry>
<title>slosh: rename the project from sl0ppty</title>
<updated>2026-08-18T19:26:00Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T19:26:00Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b9e53cace4eb75263077df8623b011e66f5f2241'/>
<id>urn:sha1:b9e53cace4eb75263077df8623b011e66f5f2241</id>
<content type='text'>
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-&lt;uid&gt;,
$XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards,
src/sl0ppty.h -&gt; src/slosh.h, contrib/sl0ppty-dev -&gt; 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.
</content>
</entry>
<entry>
<title>sl0ppty: the middle of a border is the button, not all of it</title>
<updated>2026-08-18T13:24:25Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T13:24:25Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=8231838e92e5a31e85f53f8efa1b2a1e407e7cd1'/>
<id>urn:sha1:8231838e92e5a31e85f53f8efa1b2a1e407e7cd1</id>
<content type='text'>
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:&lt;id&gt;` takes no side now
and `title:&lt;id&gt;: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.
</content>
</entry>
<entry>
<title>sl0ppty: ruff format the Python</title>
<updated>2026-08-17T12:36:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:36:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c0022776d0912042819e4ea330cf7491a8196b45'/>
<id>urn:sha1:c0022776d0912042819e4ea330cf7491a8196b45</id>
<content type='text'>
The bumpy one, again. Every `.py` outside `vendor/` plus the three `contrib/`
scripts that are Python with no extension: 60 files, 6,714 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.

It is a bigger reformat than the C was, 60% of the lines against 17%, and for a
reason worth writing down rather than tuning away: ruff and black break a call that
does not fit onto one argument per line, where this code aligned the continuation
under the opening paren. The test suite is thousands of `check("some prose", cond,
detail)` calls, so nearly every one of them changed shape and the suite grew 3,308
lines. Line length was the one lever that moved it -- 120 columns would have been a
40% reformat and no growth at all -- and it was left at 88, because picking an
unusual width to dodge a one-time diff is paying forever to avoid paying once.

Import sorting came with it. Two files had `import os, pty, uuid, select, struct,
sys, termios, time, fcntl, signal` on one line and now have ten.

Verified after, not before, because a formatter that alters behaviour would be
worse than none:

- `make retest`, 1801 checks green -- and this reformats the suite that does the
  checking, including `harness.py`, which every one of those files imports.
- `make test-live`, the tty tests the fast suite skips. They matter here more than
  usual: `live_input.py` and `live_m0.py` are two of the files whose comma-imports
  were split, and nothing in the fast suite would have noticed if that broke them.
- All 60 files still compile, `make fmt` twice in a row changes nothing the second
  time, and `ruff check --select I` has nothing left to say.
</content>
</entry>
<entry>
<title>sl0ppty: shaders on the chrome, and two keys for the arrangement</title>
<updated>2026-08-15T21:06:32Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T21:06:32Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=f6281fbec93d92bfc7e9164492e436d0236d9179'/>
<id>urn:sha1:f6281fbec93d92bfc7e9164492e436d0236d9179</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>make coverage: a number, cheaply</title>
<updated>2026-08-15T14:07:32Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T14:07:32Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=5585c0736e876c9c54ec4feb5139a8607139daa7'/>
<id>urn:sha1:5585c0736e876c9c54ec4feb5139a8607139daa7</id>
<content type='text'>
`make coverage` builds a second binary with gcc's --coverage into build/cov,
runs every suite against it, and prints a per-file table from gcov. About a
minute. Nothing installed, nothing about the normal zig build changed.

gcc rather than clang because the counters need a gcov that matches the
compiler, and gcov is what this machine has -- no llvm-profdata, no llvm-cov,
no lcov, no gcovr. The aggregation is forty lines of python reading gcov's own
summaries, which is the whole reason this was cheap.

The suites drive it through $SL0PPTY_BIN, one line in the harness, so there is
no second copy of the test code and no risk of the coverage run testing
something else.

Two things about the result, both in the report itself:

- the C unit tests link the *same* instrumented objects, so input.c, kdl.c,
  shader.c and expr.c are credited for the tests that exercise them. Built
  separately, input.c read 54% while having a dedicated decoder test -- a lie
  about where the tests are rather than about the code.
- server.c reads ~8% and is not untested. The session server is a daemonised
  child ending in _exit(), which is right there and skips the flush gcov needs.
  The counters are lost, not the tests. Said in the output, because an
  unexplained low number invites someone to "fix" working code -- and changing
  correct code to make a tool happier is the wrong trade.

Where it lands today: 85.4% of 6298 lines. The gaps that are real rather than
mechanical are jsonval.c (58%), screen.c (64%) and pty.c (59%) -- error paths
and the parts of the diff emitter that only a resize or a weird terminal
reaches.
</content>
</entry>
<entry>
<title>sl0ppty: the test cycle was 80s, it is now 5.5s</title>
<updated>2026-08-14T13:16:06Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T13:16:06Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=c20b226d44af64ac476173361dad9676dbc74043'/>
<id>urn:sha1:c20b226d44af64ac476173361dad9676dbc74043</id>
<content type='text'>
Measured before changing anything, because the obvious suspect was wrong: the
build is not slow. zig caches objects, so even a clean rebuild is ~0.15s and an
incremental one is 0.08s. The C unit tests are 0.00s. All eighty seconds were
in the python suite, and almost all of that was waiting on durations that were
guesses.

Four things, in order of what they bought.

Run the python tests in parallel. They are separate processes that share
nothing — script mode opens no socket — so each is now its own make target with
a stamp, and `make -j` runs them at once. 78s -&gt; 21s for free. The stamps are
worth as much as the parallelism day to day: an untouched test whose binary has
not changed does not run again, so `make test` after editing one test file is
1.1s and after editing nothing is 0.02s. The binary is a prerequisite of every
stamp, so changing the code still re-runs everything.

Then stop guessing at durations. `settle N` is the only thing that reads a
pane's pty, so it is how you wait for a program's output — but `send` and
`snapshot` are already synchronous with the app, which means a settle after a
mouse event, waiting to assert on our own chrome, is waiting for nothing at
all. Those are removed. Where a program's output really is the thing being
waited for, harness.until()/until_text() polls in 10ms steps and returns the
instant it lands: measured, that is one poll, against the 200-300ms those calls
used to donate. The default settle drops 120ms -&gt; 20ms on the same reasoning.

Three effects are gated on a real clock rather than on anything observable —
the hover dwell, and a toast's lifetime. Those genuinely have to wait, so the
tests that are not *about* the timing now configure the clock down instead
(hover_delay_ms 20, toast_ms 150) and the two tests that are about it keep the
real defaults, because a dwell test that cannot observe not-yet-elapsed proves
nothing.

Two tests turned out to be passing by accident, which is the useful kind of
thing a speedup shakes loose: both asserted the resize hint was gone after a
drop while the pointer was still sitting on the handle. It was only gone
because the dwell had not re-elapsed — resting on a handle *should* re-arm the
hint. They now move off the handle first and assert the thing they meant.

Unrelated but found on the way: the Makefile tracked no header dependencies, so
editing a .h rebuilt nothing and the only safe move was `make clean`. -MMD -MP
fixes it; touching shader.h now rebuilds exactly the six objects that include
it. That one was a correctness bug wearing a performance costume.

.fastfeedback/SPEED.md records the measurements and how to write a fast test
here, so the next person does not rediscover that settle-after-a-click is free
to delete.
</content>
</entry>
<entry>
<title>sl0ppty: rename from sl0ptty</title>
<updated>2026-08-14T10:13:18Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-14T10:13:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=20f269a0cdf65f196aea09f0279f5927f26d8192'/>
<id>urn:sha1:20f269a0cdf65f196aea09f0279f5927f26d8192</id>
<content type='text'>
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-&lt;uid&gt;), 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.
</content>
</entry>
<entry>
<title>sl0ptty: M7 — layout files and suspended panes</title>
<updated>2026-08-13T21:27:47Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:27:47Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=58afd137373fb395fc0479d091c4da9ffa60324a'/>
<id>urn:sha1:58afd137373fb395fc0479d091c4da9ffa60324a</id>
<content type='text'>
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).
</content>
</entry>
<entry>
<title>sl0ptty: M6 — the config file, and a README</title>
<updated>2026-08-13T21:21:04Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:21:04Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=1e69602895bf1771086dd2d961c594ecc985bc5e'/>
<id>urn:sha1:1e69602895bf1771086dd2d961c594ecc985bc5e</id>
<content type='text'>
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) -&gt; 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.
</content>
</entry>
<entry>
<title>sl0ptty: M3 — tabs, purposes, and the JSON control API</title>
<updated>2026-08-13T20:58:24Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:58:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40'/>
<id>urn:sha1:7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40</id>
<content type='text'>
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.
</content>
</entry>
</feed>
