<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/src/sl0ptty.h, branch master</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/src/sl0ptty.h?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/src/sl0ptty.h?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-14T10:13:18Z</updated>
<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: kitty graphics passthrough</title>
<updated>2026-08-13T23:01:04Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T23:01:04Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b6c175e58ee21e2532d2826411a5351d9a676c09'/>
<id>urn:sha1:b6c175e58ee21e2532d2826411a5351d9a676c09</id>
<content type='text'>
A program in a pane transmits an image and it appears. libghostty-vt parses
the protocol and tracks images and placements; what a multiplexer has to add
is re-emitting them to the client, which is exactly what tmux and zellij do
not do — and the reason tools fall back to sixel under them.

Per frame: walk the visible tab's panes, transmit each image once (a=t),
place every visible placement (a=p,C=1) after the cell diff so a repainted
cell cannot land on top of one, and delete placements that vanished. Image ids
are remapped into a high range, so two panes both using image 7 stay two
images and neither collides with what the client's terminal already had. A
reattaching client is told everything again, because it has seen nothing.

The cropping rule is the part worth remembering: a placement is N pixels drawn
across M cells, so the placement itself tells you the pixels per cell — the
client's font metrics never come into it. Clipping at a pane edge, or at the
top of the viewport when a tall image is partly scrolled off, moves the source
rectangle; asking for fewer columns or rows alone scales the image into them
instead of cropping it. The first version did exactly that, and dropped
partially-visible placements entirely rather than cropping them.

Tests cover the multiplexer-specific case first: two panes, both using image
id 7, must get different client ids. Then placement position in screen cells,
following the layout, cropping at edges and when taller than the pane, nothing
from collapsed panes, and removal when a pane closes or the image scrolls
away. A live check confirms the wire: transmitted once, re-placed per frame,
not re-transmitted.

416 checks green (389 headless + 27 live).
</content>
</entry>
<entry>
<title>sl0ptty: select-to-copy, middle-click paste, and toasts</title>
<updated>2026-08-13T22:04:03Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T22:04:03Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=1532581aa3765512b6629aed94f7f529a53f92b5'/>
<id>urn:sha1:1532581aa3765512b6629aed94f7f529a53f92b5</id>
<content type='text'>
Drag over text, release, and it is on your clipboard — no chord, no menu, the
selection is the copy. The clipboard lives on the client's machine, so the
copy travels as OSC 52 and therefore works over ssh; the session keeps its own
copy as the primary selection, which is what middle click pastes back.

Selected cells are inverted rather than recoloured, so a selection reads the
same over whatever theme the program inside is using. A program that is
tracking the mouse keeps its own clicks (shift is the escape hatch), which is
tested rather than assumed.

Toasts: transient announcements in the bottom-right that expire on their own,
stacked up to three. 'copied 13 chars', 'config reloaded', a pane's OSC 9
notification, or {"cmd":"notify","text":"..."} from an orchestrator — an agent
in a pane can now say 'build finished' and have it appear. OSC 52 from a pane
reaches the same clipboard.

Two real bugs found on the way:

  - mouse events are hit-tested against what was painted, and nothing had been
    painted before the first frame, so a click that arrived first was dropped.
    Both front ends now compose once before accepting input, which is what
    really happens in a session anyway.
  - the plain leaf_new never installed the clipboard and notification
    handlers; only panes built from a layout file had them. Every ordinary
    pane was silently missing both.

The test escaping traps continue to earn their comments: a real newline inside
a driver argument terminates the *command* rather than reaching the shell, \a
is not a driver escape (\x07 is), and an interactive shell's line editor eats
a typed ESC — so a pane driven with escape sequences must not be one.

356 checks green (329 headless + 27 live).
</content>
</entry>
<entry>
<title>sl0ptty: scrollback — wheel, keyboard, and a clickable indicator</title>
<updated>2026-08-13T21:50:24Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:50:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b514f68d9535e0ee882eecbbac217486f999f538'/>
<id>urn:sha1:b514f68d9535e0ee882eecbbac217486f999f538</id>
<content type='text'>
libghostty-vt already keeps the scrollback, so the work was deciding whose
wheel it is:

  - a program tracking the mouse gets the wheel events themselves;
  - a full-screen program without mouse tracking (vim, less) has no scrollback
    of ours to show, so the wheel becomes arrow keys, which is what it means
    to that program;
  - anything else scrolls our viewport.

All three are tested against real programs rather than asserted in principle.

C-a PageUp/PageDown/Home/End, and scroll_lines in the config for the wheel.
Typing snaps back to the live view, because reading scrollback and then typing
into a screen you are not looking at is a trap. New output does not yank the
viewport back — it just grows the gap, which the indicator shows.

The indicator is compact, right-aligned, budgeted after the split button and
before the title (a title is the thing you can most afford to lose), and
clicking it returns to the bottom.

Three test failures on the way, all mine and all instructive: the top of the
scrollback is the shell's first prompt rather than the first line of output;
typing without a newline types rather than runs; and two side-by-side panes
share a screen row, so asserting on line(y) reads both frames at once.

341 checks green.
</content>
</entry>
<entry>
<title>sl0ptty: M7 — layout files and suspended panes</title>
<updated>2026-08-13T21:27:47Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T21:27:47Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=58afd137373fb395fc0479d091c4da9ffa60324a'/>
<id>urn:sha1:58afd137373fb395fc0479d091c4da9ffa60324a</id>
<content type='text'>
A session can be a file:

  layout {
      tab name="api" purpose="project:api.a1b2c3" cwd="~/dev/api" {
          pane purpose="agent:main" command="pi"
          pane split="rows" {
              pane purpose="service:web" command="npm run dev" suspended=true
              pane purpose="shell:scratch"
          }
      }
  }

built by --layout at startup or applied to a running session with
{"cmd":"apply-layout", path|kdl, replace?}. Idempotency stays with the caller,
which is where sl0ppi's CLI design already puts it: diff list-tabs against the
projects on disk, then apply what is missing. close-tab completes that loop.

Purposes a layout declares are locked, so identity comes from the file rather
than from whatever the program inside prints — tested by having a pane try to
take one in-band and be refused.

Suspended panes: a pane that exists, is laid out, and has run nothing, showing
what it would run and starting on its first keystroke (which is consumed, not
forwarded). This is sl0ppi's D6 — a root with twelve projects must not become
twelve running servers — and it is the reason  can be cheap.

Also fixes a test-isolation bug latent since M2: the live tests attached to the
session called "main", which is whatever happens to exist on the box rather
than one they created. They now use a unique session per run, which is how a
run that had somebody else's htop in it became reproducible.

284 checks green (257 headless + 27 live).
</content>
</entry>
<entry>
<title>sl0ptty: 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>
<entry>
<title>sl0ptty: M2 — server/client split, detach/reattach, control socket</title>
<updated>2026-08-13T20:52:33Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:52:33Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7'/>
<id>urn:sha1:7a6fa40a9c49245cf569e10e6ed635f9a6cbbcb7</id>
<content type='text'>
The session now outlives its client, which is the point: agents keep running
while you are gone. The server owns the panes, the layout and the renderer;
the client is a terminal in raw mode and a socket, forwarding bytes and
painting what it is sent. It does not even decode input, so the decoder can
change without redeploying clients.

  sl0ptty            attach to 'main', creating it if nobody is home
  sl0ptty -s NAME    a named session
  sl0ptty ls         live sessions
  sl0ptty cmd LINE   one control command
  C-a d              detach

Connections are not clients. A connection becomes the display client only by
sending HELLO; control commands and liveness probes are just connections and
never displace anybody. This was found the hard way, one hour after the server
first ran: 'sl0ptty ls' kicked the attached client off, because ls connects to
see whether a socket answers. That is precisely the fault that gave the zellij
fork a 20-25% CLI failure rate, rebuilt and then fixed structurally. The
regression runs 20 x (ls + control) against a live client and demands 0
failures and a client that can still type.

cmd.c now holds the command vocabulary once, shared by the headless driver and
the control socket, so a script written against one works against the other —
'sl0ptty cmd "snapshot text"' against a detached session prints its screen.

tests/test_session.py: 18 checks over create, split, probe-resistance, detach,
survival, drive-while-detached, reattach, displacement and cleanup.
123 checks green overall.
</content>
</entry>
<entry>
<title>sl0ptty: M1 — layout tree, splits, focus, frames, and the hit list in use</title>
<updated>2026-08-13T20:35:57Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:35:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d2940b9643496ed898450f8bd2c640aeb6f8b7a1'/>
<id>urn:sha1:d2940b9643496ed898450f8bd2c640aeb6f8b7a1</id>
<content type='text'>
app_t owns the tree, focus, the prefix and what a key means; the interactive
client and the headless driver are now both thin front ends over it, so there
is exactly one implementation of every command.

Layout is the pure function D6 promised: layout(tree, rect) recomputed every
frame, remainder spread across children so nothing drifts, and splitting in a
direction that already exists grows that split rather than nesting — three
splits give three equal columns, not 1/2 + 1/4 + 1/4.

Chrome: rounded frames, aspect-corrected gap, centred titles. The split button
is budgeted BEFORE the title and hit-tested from the rect it was drawn in,
which is the fix the fork needed four attempts to land.

Every painted element registers (rect, action) as it is painted; a click is a
lookup. Mouse into a pane is translated to pane-local coordinates.

Three bugs the harness caught, none of which unit tests would have:
- one click on the split button split twice: press *and* release both fired.
  Chrome now acts on press; forwarding to a pane still gets every event.
- callbacks were registered as &amp;fn, but the option is pointer-typed, so the
  stored 'callback' was a stack address. Any OSC 2 or DSR jumped into a dead
  frame. Now a hard segfault turned into two regression tests.
- a keystroke that changed nothing still emitted a cursor hide/show pair.
  The cursor is hidden lazily now, so an idle frame costs zero bytes.

96 checks green in 6s (40 unit, 23 screen, 33 layout), plus 9 live.
</content>
</entry>
<entry>
<title>sl0ptty: M0.5 — the headless driver and the harness everything rests on</title>
<updated>2026-08-13T20:26:02Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:26:02Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=34fcd16e59e3087e9bf84ebce4e05d439eea9198'/>
<id>urn:sha1:34fcd16e59e3087e9bf84ebce4e05d439eea9198</id>
<content type='text'>
--script runs the whole compositor with no tty: commands in (send, raw,
settle, resize, snapshot, alive), composited screen out as JSON — rows of
text, style runs, cursor, and the hit list.

Styles are emitted as runs rather than per-cell objects, because a test wants
'columns 0..5 of row 0 are bold red', not 1920 objects.

The hit list is empty until M1 paints something clickable, but the type, the
JSON, and hit_test() exist now: one geometry, established before there is
anything to get wrong.

Also fixes two host-policy defaults found by the cursor never appearing.
lib-vt is a VT library, not a terminal, so it starts with DECTCEM (mode 25)
off and grapheme clustering (2027) off. Both are now set via MODE_DEFAULT, so
they survive RIS.

tests/harness.py + tests/test_screen.py: 19 checks over composition, styling,
wide chars/clusters, scrollback, resize and the full input round trip —
including the kitty/legacy re-encoding matrix, which no longer needs a pty.

make test is now 2.4s for 59 checks; make test-live keeps the four that
genuinely need a tty.
</content>
</entry>
<entry>
<title>sl0ptty: input decoder + per-pane re-encode</title>
<updated>2026-08-13T20:08:30Z</updated>
<author>
<name>sl0p</name>
<email>sl0p@sl0p.foo</email>
</author>
<published>2026-08-13T20:08:30Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=7521f217a5ed98cef16db5837a72fae32dce9552'/>
<id>urn:sha1:7521f217a5ed98cef16db5837a72fae32dce9552</id>
<content type='text'>
The half libghostty-vt does not do: decode what the outer terminal sends us
into semantic events, then re-encode each event for the target pane against
that pane's own negotiated modes.

Decoder (src/input.c): legacy keys and modifiers, SS3, tilde keys, the kitty
keyboard protocol (CSI u, shifted/base layout sub-params, press/repeat/release,
associated text), SGR mouse incl. drag and wheel, bracketed paste, focus
events, alt-prefix, UTF-8, and partial sequences retained across reads.

Re-encoding (pane_send_key/mouse/paste) drives lib-vt's key and mouse encoders
via setopt_from_terminal, so a pane gets exactly what it negotiated. The mouse
encoder wants surface pixels; we declare a 1x1 pixel cell so cell coordinates
are exact rather than approximated.

The prefix key is handled on decoded events, not bytes, so it cannot be
confused by a byte that merely looks like C-a inside a UTF-8 or escape
sequence.

Tests: 40 table-driven decoder cases (tests/input_test.c) + a live check that
drives the same client into a legacy pane and a kitty/mouse pane and asserts
each receives its own encoding (tests/live_input.py). 59 checks, all green.
</content>
</entry>
</feed>
