<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/HANDOVER.md, branch master</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/HANDOVER.md?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/HANDOVER.md?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-26T20:10:36Z</updated>
<entry>
<title>cleanup: remove pointless md artifacts</title>
<updated>2026-08-26T20:10:36Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-26T20:10:36Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=78ad926b1a66918470c5037f991998358d1104d0'/>
<id>urn:sha1:78ad926b1a66918470c5037f991998358d1104d0</id>
<content type='text'>
</content>
</entry>
<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: answer the terminal when it is asked how big it is</title>
<updated>2026-08-15T17:38:38Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T17:38:38Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=e275a776406343a3a0476d557a5d8c724ccf3593'/>
<id>urn:sha1:e275a776406343a3a0476d557a5d8c724ccf3593</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>sl0ppty: decode the PNGs programs actually send</title>
<updated>2026-08-15T17:14:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T17:14:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2f0b3426d0f40d2cf53740cb597495af2c45bc2e'/>
<id>urn:sha1:2f0b3426d0f40d2cf53740cb597495af2c45bc2e</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>sl0ppty: measure chrome in columns, not characters</title>
<updated>2026-08-15T16:44:07Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T16:44:07Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=93ce0b645babd0851edb34701bbfffa5565daf65'/>
<id>urn:sha1:93ce0b645babd0851edb34701bbfffa5565daf65</id>
<content type='text'>
Good catch: a two-column bell mark shifted the pane title row. Everything in
app.c that positions chrome -- title budgets, button placement, the hit rects
that have to agree with them -- came from a `cells()` that counted codepoints,
and a glyph drawn in two columns was booked as one, so every glyph after it
landed a column early.

Widths now come from lib-vt's own table, via ghostty_unicode_grapheme_width.
That is deliberately the same table the terminal uses for pane content, so
chrome and content cannot disagree about how wide something is, and it is
cluster-aware: an emoji with a variation selector is two codepoints and one
glyph. A wide cell claims its right half as a tail (width 0), which the
renderer already knew how to skip.

HANDOVER said this was a known trap and to dump the actual cells before
touching arithmetic, which is exactly how this went: the first assertion I
wrote passed against a deliberately broken width function, because it measured
the frame's right corner -- drawn at an absolute position, so it does not move
-- rather than the columns the mark claims. The test now reads the mark's own
colour runs and demands 1 column for a narrow mark and 2 for a wide one, and it
fails when the width function is stubbed back to 1.

Two things fell out on the way:

ghostty_unicode_grapheme_width measures only the *first* cluster and returns
the codepoints it consumed. My first loop ignored that and asked "did the width
change when I added the next codepoint?", which concludes that an entire ASCII
string is a single cluster -- the cheatsheet modal collapsed to ten columns
wide with the text clipped. Use the return value.

A cluster can also be longer than a cell holds: a ZWJ family emoji is 18 bytes
against 16. Both the cell writer and the config's mark fields (now 32 bytes)
clamp on a codepoint boundary, because snprintf cuts at a byte and half a
codepoint is invalid UTF-8 -- which showed up as a decoder error in a test far
from the cause, not as a short mark.
</content>
</entry>
<entry>
<title>sl0ppty: render the config instead of shipping a copy of it</title>
<updated>2026-08-15T14:22:27Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T14:22:27Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=95b9b0414a2a8a3b1ec8e91d530c8b7119901257'/>
<id>urn:sha1:95b9b0414a2a8a3b1ec8e91d530c8b7119901257</id>
<content type='text'>
`C-a e` on a machine with no config opened an empty buffer, which tells you
nothing about what you can set. The obvious fix -- embed config/config.kdl and
write it out -- trades one problem for a worse one: a checked-in copy of the
defaults is a second source of truth, and it drifts. Ours already had. Four
theme colours existed in the code and not in that file, and nothing said so.

So the file is generated. `sl0ppty --dump-config` renders every setting with
the value it currently holds -- geometry, behaviour, all 48 theme colours, the
state shader chains, and every binding including the leader-less ones -- and
`C-a e` seeds a missing config with exactly that, creating the directories on
the way (a fresh container has no ~/.config either).

config/config.kdl stays, and is now explicitly the *prose*: what a setting is
for, what it cost to get right, which ones are opinions. Values are generated,
essays are written, and the file says so at the top.

Three things keep the two honest:

- the theme colours became one file-scope table that the parser and the
  renderer both walk. 48 names that used to exist in one place and be typed
  again in another now exist once.
- the dump must parse back to itself byte-for-byte. That is what caught
  `bind \ split-cols` rendering unquoted -- valid-looking output that would
  not have survived being read back.
- a test scans config.c for `kdl_child(root, "...")`, which is the parser's
  own vocabulary, and fails if a key is missing from the dump. Add a knob,
  forget to render it, and the suite tells you rather than a user.

It never overwrites a config that exists: fopen(..., "wx") loses the race
rather than an edit, and there is a test for that too, because it is the one
thing here that must never go wrong.
</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: only panes that were given a command keep their corpse</title>
<updated>2026-08-15T13:21:24Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T13:21:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=8379c0e7e92eb31a351e5023a1364ec8e95b402c'/>
<id>urn:sha1:8379c0e7e92eb31a351e5023a1364ec8e95b402c</id>
<content type='text'>
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".
</content>
</entry>
<entry>
<title>sl0ppty: write the session back out as a layout, and a loop that uses it</title>
<updated>2026-08-15T11:13:34Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T11:13:34Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d9dee7f725b68332982f34ab6c52da0552b821d3'/>
<id>urn:sha1:d9dee7f725b68332982f34ab6c52da0552b821d3</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>sl0ppty: bindings that need no leader</title>
<updated>2026-08-15T09:45:56Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T09:45:56Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=e9dddc549de1ecd068fa152bf735a72d3a3d03c9'/>
<id>urn:sha1:e9dddc549de1ecd068fa152bf735a72d3a3d03c9</id>
<content type='text'>
Every binding needed the prefix first, which is the safe design and the
reason a leader exists: a multiplexer that takes chords away from the
programs inside it is a multiplexer you fight. But it is the user's keyboard.
Somebody who wants ctrl+alt+arrow to move between panes should be allowed to
have it, including the part where that chord stops reaching vim.

    keys {
        direct {
            bind "ctrl+alt+left" "focus-left"
            bind "ctrl+alt+right" "focus-right"
        }
    }

A block rather than a flag on each line, because these take a chord away from
every program in every pane, and that is worth seeing at a glance -- and
worth having somewhere to write the warning down, which the config now does
in full: prefer chords no program wants, a bare letter certainly is not one,
`none` gives it back.

Three ordering decisions hold it together, none of them obvious from reading
the code, all three tested:

- the direct lookup happens *last* in the key path -- after the rename editor,
  the finder, the cheatsheet, and the prefix itself. An overlay still owns the
  keyboard, and a direct binding cannot shadow the leader, which would make
  the leader unusable with no way back. There is a test that tries exactly
  that and finds the prefix still working.
- `direct` is part of a binding's identity rather than a property of it. `x`
  after the leader and `x` on its own are two bindings; binding one must not
  silently redefine the other.
- the after-the-leader lookup falls through to direct bindings, so pressing
  the prefix first never makes a binding stop working. A rule you have to
  keep track of is a rule that will catch you out.

The cheatsheet lists them under "without the leader", apart from the rest,
because the caption at the top of it says "&lt;prefix&gt; then:" and these are
precisely the ones that is not true of.

The tests state the bargain rather than just the mechanism: the chord fires
with no leader, and what it fires on, the program never sees -- asserted with
`cat -v`, so a keystroke arriving in the pane is visible on screen rather
than an invisible control byte that would prove nothing either way.
</content>
</entry>
</feed>
