<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/README.md, branch v0.1.0</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/README.md?h=v0.1.0</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/README.md?h=v0.1.0'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-27T14:19:25Z</updated>
<entry>
<title>install docs: one subsection per OS</title>
<updated>2026-08-27T14:19:25Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-27T14:19:25Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b6fb41cea7610e56280a4db6572bbad955a9b667'/>
<id>urn:sha1:b6fb41cea7610e56280a4db6572bbad955a9b667</id>
<content type='text'>
'With Homebrew (macOS or Linux)' followed by a source build made every
reader parse which half was theirs; now macOS and Linux each have a
heading, and you read yours. macOS is the tap; Linux is the same tap
under Homebrew on Linux, or the source build, which the docs landing
page had never mentioned brew at all.

Also fixes the README telling people to run bare 'make', which prints
the help text (the default goal) and builds nothing. install.sh had
this figured out and commented on it; the README had not.
</content>
</entry>
<entry>
<title>README/docs: stop leaning on the aside dash</title>
<updated>2026-08-27T14:11:26Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-27T14:11:26Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=238b1a7eb51c576254537705597f8a039e1d9c86'/>
<id>urn:sha1:238b1a7eb51c576254537705597f8a039e1d9c86</id>
<content type='text'>
Some 230 sentences hung their qualifications between dashes, and at that
density the tic reads as the writing's signature rather than a tool it
occasionally picks up. Each one is now the punctuation the sentence
actually wanted: a colon where the aside defines, parentheses where it
whispers, a comma where it barely pauses, a full stop where two thoughts
were pretending to be one. Definition lists and reference tables take
colons throughout.

Two double hyphens remain on purpose, both literal: the argv separator
in a shell example, and a table cell that means 'takes nothing'.
</content>
</entry>
<entry>
<title>README/docs: spell the dash --, as the rest of the tree does</title>
<updated>2026-08-27T14:04:37Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-27T14:04:37Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=5151e42f22b404cf41dc8b409bcdfcd4b39444ea'/>
<id>urn:sha1:5151e42f22b404cf41dc8b409bcdfcd4b39444ea</id>
<content type='text'>
Every C comment, commit message and generated config in this repo
writes the aside dash as a spaced double hyphen; the Markdown alone
used U+2014, some 220 of them. One spelling, and the ASCII one -- the
prose now matches the code it documents.

Mechanical (every occurrence was the spaced form, checked including
the one inside a code fence, where it was comment text), so the words
themselves are untouched.
</content>
</entry>
<entry>
<title>README: drop the ascii mockup</title>
<updated>2026-08-27T13:58:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-27T13:58:35Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=24a6db01b24b8ef4cfae42e18284e1168cbca0df'/>
<id>urn:sha1:24a6db01b24b8ef4cfae42e18284e1168cbca0df</id>
<content type='text'>
Sixteen lines of box-drawing was the biggest thing on the page and the
least true: a static sketch of two frames says nothing the first
paragraph had not already said, and the real look -- themes, compact
borders, shaders -- is not something a code block can be honest about.
The docs site keeps its copy; the README gets to the install command
sooner.
</content>
</entry>
<entry>
<title>homebrew: a formula, and a script that keeps it honest</title>
<updated>2026-08-26T21:30:58Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-26T21:29:54Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=91526fe1f3482f610b38caa214a91f78564f7d50'/>
<id>urn:sha1:91526fe1f3482f610b38caa214a91f78564f7d50</id>
<content type='text'>
`brew install slosh` is the shortest install there is, and the formula is
small because `make install` already does the work: zig as the only build
dependency, both zig caches redirected into the build directory so the
sandbox does not deny them and the user's real cache is not polluted, and
the commit id handed to the Makefile because a snapshot tarball has no
.git for `git describe` to read.

The url/version/sha256 triple is the part that rots. contrib/brew-release
resolves a ref to a full commit id, fetches the cgit snapshot, hashes what
it actually got, and rewrites those three stanzas -- it does not commit or
push, so the diff is read by a human first. Untagged pins carry the commit
count so `brew upgrade` can tell newer from older; alphabetical comparison
of two short ids cannot.

The formula lives here as the source of truth and is copied into the tap;
contrib/homebrew/README.md says why it is a formula and not a cask, and
what still stands between this and homebrew-core.
</content>
</entry>
<entry>
<title>make install: put the binary and the examples where they belong</title>
<updated>2026-08-26T21:30:58Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-26T21:29:41Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=93ef54703fd30a70faf804e857b2dce272681ee0'/>
<id>urn:sha1:93ef54703fd30a70faf804e857b2dce272681ee0</id>
<content type='text'>
There was no way to install slosh short of copying build/slosh by hand,
which packagers cannot do and users should not have to. PREFIX and
DESTDIR are honoured because that is the contract every package manager
already expects; the Homebrew formula calls exactly this.

Only the binary is needed at runtime. The config, layout, themes, chrome
and shaders are examples you copy into ~/.config/slosh, so they go to
share/slosh and nothing reads them from there.
</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: 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: 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: make docs</title>
<updated>2026-08-15T22:00:45Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-15T22:00:45Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=910d47dc4ae10fb3c388f6883e5107d9659fa727'/>
<id>urn:sha1:910d47dc4ae10fb3c388f6883e5107d9659fa727</id>
<content type='text'>
contrib/gen-docs renders docs/ into a static site: one file of stdlib
Python and one stylesheet, no toolchain, no package manager, no npm. Same
argument as src/kdl.c being a hand-rolled KDL subset rather than a
library -- the input is ours, we know exactly which constructs it uses,
and a docs build that needs a lockfile is a docs build nobody runs.

What the Markdown subset understands is what docs/ actually contains:
headings (with anchors), paragraphs, fenced code, pipe tables, lists one
level deep, blockquotes, rules, `code`, bold, italic, links. Anything
else is escaped and passed through as text. A construct nobody uses is
not supported on purpose: it would be untested code pretending to be a
feature.

The layout is a sidebar of sections from docs/nav, the page, and its own
headings down the right; prev/next chains the nav order; a link to
another page is rewritten to its rendered page, so the same Markdown
reads correctly on a forge and on the site. The colours are the session's
own default theme, so the docs look like the thing they document, and the
light variant follows the reader's system preference rather than a toggle
nobody would find. Zero JavaScript -- the narrow-screen menu is a
checkbox.

Two things came out of looking at it in a browser rather than trusting
the HTML.

The hero diagram was hand-drawn and its lines were 78 and 79 columns
long, which is visible. It is generated from a real session now -- three
panes, a layout file, a text snapshot -- so it is exactly what the
program draws. Terminal art also needs tighter leading and a font whose
box drawing joins, so a fence with box characters in it and no language
is emitted as art rather than code and styled for that. Detected rather
than declared, because the alternative is a marker on every fence and one
of them getting forgotten. (Vertical rules still break between lines in
a browser: the glyph does not fill its em box, and no amount of CSS
changes that. Widths line up, which is the part that matters.)

The narrow-screen menu opened over a page that showed through underneath
it: as a grid child taken out of flow it kept its content height, so
`bottom: 0` did nothing. It has an explicit height now.

tests/test_docs.py checks both jobs. The site: every page in the nav is
rendered, carries the whole sidebar, is marked as current on its own
page, chains prev/next, and no internal link in the output points at a
file that is not there. The subset: each construct, and the one that
matters most -- markup inside a code span is not markup, because half of
these docs are shader expressions full of asterisks. A page missing from
docs/nav fails the build, and so does a nav entry with no file: an
unreachable page whose only symptom is its absence is the failure mode a
hand-written nav has.
</content>
</entry>
</feed>
