<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/src/sl0ppty.h, branch docs-new</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/src/sl0ppty.h?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/src/sl0ppty.h?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: clang-format the tree</title>
<updated>2026-08-17T12:22:38Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T12:22:38Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=ec91ccffe631ae34b111bf6161fd0e3c487f684c'/>
<id>urn:sha1:ec91ccffe631ae34b111bf6161fd0e3c487f684c</id>
<content type='text'>
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 &amp;&amp; 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.
</content>
</entry>
<entry>
<title>sl0ppty: double-click a word to select it</title>
<updated>2026-08-17T11:53:57Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T11:53:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2f9135d2810cfb38ccdd4f07e895c66430d066fe'/>
<id>urn:sha1:2f9135d2810cfb38ccdd4f07e895c66430d066fe</id>
<content type='text'>
`pane_select_word()` was written and never wired to anything: no declaration, no
caller, no way to reach it. This is the rest of it -- the gesture, the setting, and
the tests.

**The word is defined by what it stops at, not by what it is made of.** Anything
that is not blank and not a listed separator belongs to a word, so CJK, accented
letters and emoji need no table and come out whole; whitespace always separates,
listed or not, because a double-click that selected the line would be a different
gesture arrived at by surprise. Wide glyphs continue the run they are in rather than
each being their own word -- otherwise every CJK character is a word.

`word_separators` defaults to `"'`()[]{}&lt;&gt;;,|`, and what it leaves *out* is the
decision: `/ . - _ : = @ # % ? &amp; + ~ $` stay inside words, so a path, a dotted
name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are
each one thing you double-clicked to copy whole. `:` is the difference between
copying an error location and copying half of one, which is why kitty keeps it too.

The gesture reuses what selecting already has: the press starts a selection at the
cell -- which anchors a drag out of the double-click -- then widens it to the word,
and the release path that already copies a drag copies this. So the word *is* the
copy, in the one place that rule lives, and the highlight is the copied span rather
than an approximation of it. Tracked per cell as well as per pane, so clicking two
different words quickly selects the second rather than everything between them.
A program that asked for the mouse still keeps its clicks, with shift as the escape
hatch, exactly as the drag has it.

**One thing this needed on the way: quoted values were dumped unescaped.** The
default separator set opens with a quote, and `cb_add(&amp;b, "key \"%s\"\n", ...)`
wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking
the property the dump exists for. The escaping loop already existed inside
`cb_chord`, for the same reason and with the reasoning written down; it is now
`cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`,
`editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those,
load-bearing for this one.

**And `--dump-config` does not do what four comments and the docs said it did.** It
calls `config_dump_defaults()`: every setting with its *default*, never the value
your config gives it. The function name was the honest one. Corrected in
`docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's
own docstring, rather than changing what the flag does -- starting a config from it
is the documented use, and `--check` is what reads yours. There is no way to ask for
the effective config; that is a gap, not a lie, and it can be a flag when somebody
wants it.

Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier
stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight
being exactly as wide as the copy; a separator, a blank cell and a single click each
leaving the clipboard alone; two clicks on different cells not being a double; two
slow clicks not being one; the separator set honoured from a config, including a
quote and a backslash carried through KDL escaping; a word in a scrolled pane coming
from the row on screen; and a mouse-tracking program keeping its double-clicks until
shift takes them back. Three of those failed first because the test asked for the
answer twice -- a second `worded()` call is a second pair of clicks -- and one
because it mapped string indices to columns without counting wide glyphs as two.
</content>
</entry>
<entry>
<title>sl0ppty: workspaces, and layouts that write themselves</title>
<updated>2026-08-17T10:56:42Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-17T10:56:42Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=6a27f7b365d71ab853931fd5ab3c0f2f7ac83d57'/>
<id>urn:sha1:6a27f7b365d71ab853931fd5ab3c0f2f7ac83d57</id>
<content type='text'>
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:&lt;name&gt;.&lt;hash8&gt;` 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)-&gt;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.
</content>
</entry>
<entry>
<title>sl0ppty: a reply is never a request</title>
<updated>2026-08-15T22:52:42Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T22:52:42Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=40edef803e1b162ddc259e74aadd3688631ce1f2'/>
<id>urn:sha1:40edef803e1b162ddc259e74aadd3688631ce1f2</id>
<content type='text'>
`hello` was answered with the verb `hello`. Any pane that echoes what it is
sent -- `cat`, `tee`, a shell with echo on, a REPL waiting for a line --
therefore answered the answer, and the session answered that. Measured
before the fix: a pane running `tee` was sent 4,303,832 bytes and 165,532
hellos in a second and a half. Afterwards, 32 bytes and one.

The fix is the rule rather than the instance: every message the session
sends *to* a program ends its verb in `-reply`, no request verb may, and
`on_osc5577` drops one on sight instead of relying on no handler happening
to exist. So this cannot come back when the next verb gets an answer --
which is the commit after this one, and how I found it.

`hello`'s reply is now `hello-reply;sl0ppty;1`. That is our own extension
to the fork's protocol, not one of the five compatible verbs, so nothing
written against sl0ppi notices. The fork's `click` goes to a program too
and keeps its name: it predates the rule and is safe for the weaker reason
that nothing answers a click.

The suite was hiding this. `emit()` builds its pane out of `cat -v`, so the
existing `hello` test had a pane trading megabytes with the session while
it asserted -- and passed, because the assertion was `in`, and the first
reply was in there along with all the rest. It now counts.

Carries `pane_touch`, two lines, unused until the next commit: a pane whose
shaders changed has to be repainted, and nothing about its cells changed to
say so.
</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>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>sl0ppty: C-a e opens the config, in a pane, in your editor</title>
<updated>2026-08-15T14:13:46Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T14:13:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=ee6288a7eadeb43c83a5ab78f6c65cffa4748a53'/>
<id>urn:sha1:ee6288a7eadeb43c83a5ab78f6c65cffa4748a53</id>
<content type='text'>
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 &lt;config&gt;` 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.
</content>
</entry>
</feed>
