<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/tests/test_osc5577.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/test_osc5577.py?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/tests/test_osc5577.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: 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: 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: 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: M4 — OSC 5577 pane status bars, buttons and click reports</title>
<updated>2026-08-13T21:07:27Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:07:27Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=105a6178542075ae212d396d10b7bd0fd5649f9c'/>
<id>urn:sha1:105a6178542075ae212d396d10b7bd0fd5649f9c</id>
<content type='text'>
The payoff milestone: sl0ppi's pi extensions work here unmodified.

  printf '\033]5577;1;status;building 3/7\033\\'
  printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\'

  ╰ building 3/7 ──────────────────────[Approve]─[Cancel]─╯

libghostty-vt's UNKNOWN_SEQUENCE reports APC only, so the sequences are picked
up by a scanner (osc5577.c) reading the same bytes the terminal does — lib-vt
discards an OSC it does not know, so nothing is drawn and no pane output has
to be buffered. The scanner is a byte-at-a-time state machine, so a sequence
split across reads still lands.

Buttons are budgeted from the right before the status text gets any columns,
and each registers its hit as it is painted — the split button's rule, for the
split button's reason. A press sends the click report to that pane's stdin.

In-band purposes arrive here too, and by construction can never be declared: a
pane printing one is asking, not telling. A pane cannot relabel a purpose a
layout declared, which is now tested end to end rather than only at the API.

One real bug, found by the hostile-input tests: an over-long button id was
truncated into a *valid* one, so a pane could mint a 40-character id that
collides with the 32-character id its program already trusts. Ids are now
unescaped into a large buffer and validated at full length.

Acceptance test replays answer-picker's exact byte patterns, including
`buttons` with no payload (drop buttons, keep status) and a click report that
matches the extension's own CLICK_RE.

Four test bugs on the way, each worth remembering: printf eats %3B as a format
specifier; the driver's unescaper reads \0 as NUL so \033 became NUL+'33'; a
trailing ST inside a double-quoted shell string escapes the quote (BEL avoids
it); and readline eats a typed ESC as a meta prefix, so tests that drive a
pane must not use an interactive shell.

187 checks green (160 headless + 27 live).
</content>
</entry>
</feed>
