<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git, branch master</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/?h=master</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-10-28T10:19:05Z</updated>
<entry>
<title>brew: pin the build to zig@0.16</title>
<updated>2026-10-28T10:19:05Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-10-28T10:17:53Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=58cddb132e75103ad03ce1ce69a7ba7e0ae6e210'/>
<id>urn:sha1:58cddb132e75103ad03ce1ce69a7ba7e0ae6e210</id>
<content type='text'>
homebrew-core moved "zig" to 0.17.0, and the vendored libghostty-vt
rejects any mismatched major/minor, so every brew install broke with
"Your Zig version v0.17.0 does not meet the required build version of
v0.16.0". Installing zig@0.16 by hand did not help: it is keg-only, so
it is never on PATH, and the formula took the "zig" opt path regardless.

Also stop hardcoding the version in the test block; it had gone stale at
0.1.0 and failed against the 0.1.8 binary.
</content>
</entry>
<entry>
<title>tests: clang-format and ruff catch up on two files</title>
<updated>2026-10-09T10:31:53Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-10-09T10:24:53Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d04228e9124eca3117843dfb5dfcf531d2107d07'/>
<id>urn:sha1:d04228e9124eca3117843dfb5dfcf531d2107d07</id>
<content type='text'>
No behaviour: argument wrapping in path_test, and one check() call in
test_mouse_pixels that was over the line length. Both are what the
pre-commit hook would have done anyway.
</content>
</entry>
<entry>
<title>osc 7501: say what you are doing in somebody else's protocol</title>
<updated>2026-10-09T10:31:53Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-10-09T10:24:48Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=fcf305085343bcb5c5240ed642468461f247f1ed'/>
<id>urn:sha1:fcf305085343bcb5c5240ed642468461f247f1ed</id>
<content type='text'>
OSC 5577 only works for programs that know slosh exists. The program
status protocol is the same idea written down for everybody, by ghostty's
author and implemented by ghostty and Rex, so a program that already
speaks it needs to know nothing about us:

  printf '\033]7501;state=working:app=cargo:msg=YnVpbGRpbmc=\033\\'

Implemented *as well as* 5577, not instead of it. 5577 is a two-way
control channel -- buttons answer on the program's stdin, shaders are
refused with a reason -- and 7501 deliberately is not, so neither absorbs
the other. What 7501 buys is the programs we do not control.

The scanner becomes one pass for four things: 5577, 7501, OSC 133 ; A and
RIS. The last two are here because 7501 ties record lifetime to them -- a
new prompt or a full reset is how a stale `working` dies without a
heartbeat -- and because a pane should pay for that scan once. osc5577_fn
gives way to osc_scan_cbs_t.

Reports are all-or-nothing, which is the spec's rule: no state, an unknown
state, a broken limit, base64 that will not decode, a control character in
decoded text or a malformed id discards the whole report rather than
writing a partial one. A malformed *pair* is skipped and the rest parsed.

A pane keeps up to PANE_PS_MAX records with LRU eviction, and composes the
one line it has from them by precedence -- blocked, error, done, working,
idle -- resolving `app` from the nearest ancestor that has one, the way
the hierarchy is allowed to mean something. It lands in the slot 5577's
`status` already had, because every reader of a pane wants "what is this
doing", not "which protocol said so"; `panes` reports program_state,
program_kind, program_app and progress beside it, with -1 for a progress
nobody reported, since a bar drawn at 0% says something false.

The feature-detection reply is rate-limited here. Note for the next
re-vendor: upstream lib-vt implements 7501 too, newer than our pin, and
answers the support query itself through write_pty without rate-limiting
it. See "What upstream did" under D1 in DESIGN.md before swapping this
half of the file for GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS.
</content>
</entry>
<entry>
<title>tab bar: a sidebar that fits the terminal, and gives up the edge last</title>
<updated>2026-10-09T10:31:53Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-10-09T10:24:23Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=3a0825c6890eff566307c0eaad809cdbcf854efd'/>
<id>urn:sha1:3a0825c6890eff566307c0eaad809cdbcf854efd</id>
<content type='text'>
`tab_bar_width` was a reservation: the configured columns came off the
screen whatever the screen was, and the only fallback was all the way back
to the top row. On a narrow terminal that is chrome displacing the thing it
is chrome for, and the jump to the top moves every target you were aiming
at.

It is a ceiling now. app_tab_bar_cols takes the app, because the answer
depends on the terminal and not only on the config: held to the columns
left after a pane's collapse width, and to a third of the screen, and
narrowed rather than dropped. TAB_BAR_FIT (14) is the narrowest width this
code will invent on its own -- a config that asks for less is taken at its
word -- and 0 means there is no room for a sidebar at all, which is the one
case that still falls back on top.

Height counts too, which it did not before: a sidebar spends a row per tab,
so a wide but short terminal runs out of list before it runs out of screen,
and a tab with no row is not clipped, it is unreachable. tab_bar_min_rows
is that floor. Not when the top strip could not seat them either -- moving
them would gain nothing, so the side you configured stands.

All of it is re-derived per frame, like the layout, so a resize moves the
bar and nothing remembers that it did.

test_tab_bar also stops depending on where the suite is run from. An
unnamed tab borrows the basename of its focused pane's cwd, so eight checks
read `tests` under `make test` (which cds there) and `slosh` from the
repository root, where they failed. They are about padding, alignment and
the index, not about that derivation, so they name the tab first -- by id,
since `set-name` takes 0 for the focused *pane* only. 147 checks, green
from either directory.
</content>
</entry>
<entry>
<title>panes: the focus a closed tab left behind</title>
<updated>2026-10-09T10:23:50Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-10-09T10:23:50Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2cb6dce416c814298f24087fdaafb0c119a584b6'/>
<id>urn:sha1:2cb6dce416c814298f24087fdaafb0c119a584b6</id>
<content type='text'>
A tab's focus outlived the tree it pointed into. close_leaf's last-pane
branch freed the leaf and removed the tab but never cleared `t-&gt;focus`,
and tab_remove only shifts the slots *after* the one it removes -- so with
the last tab gone, `a-&gt;tabs[0]` kept the stale tab_t while `ntabs` was 0
and `cur` was 0. `cur(a)` is `&amp;a-&gt;tabs[a-&gt;cur]` with no bounds check, and
`a-&gt;quit` is a request rather than a barrier: the loop keeps serving
commands, and the next one to ask for the focused pane was handed freed
memory.

From outside it looked like a random crash, because what the freed chunk
held depended on what had been allocated since. Two from one afternoon: a
pane_t recycled into a `panes` JSON reply, whose `term` pointer was then
the ASCII of `k":true,` and faulted as a general protection fault; and the
same node read back zeroed, which left pane_clear_bell testing a flag at
NULL+0x5d. Typing `exit` in the last pane is the whole recipe.

  close_leaf        drops the focus with the tree
  tab_remove        blanks the slot the shift vacated
  app_compose       tests -&gt;pane as well as -&gt;focus, the way the search
                    beside it already did
  app_apply_layout  clears restore_focus up front -- it is a node pointer,
                    and an apply that fails before consuming it leaves it
                    naming a tree the next apply frees

Two more the same reading turned up, both in close_leaf: an emptied split
set the focus to the node just freed, and a leaf whose parent disagreed
with that parent's child list underflowed `p-&gt;nkids - at - 1` into a
memmove of nearly SIZE_MAX.

FOCUS_CHECK -- behind -DSLOSH_FOCUS_CHECK, compiled out otherwise -- is
the invariant all of this turns on: every tab's focus is a leaf of that
tab's own tree. It is what found this one.

Committed with --no-verify: the files are deliberately partly staged, and
the staged blobs were checked against clang-format directly.
</content>
</entry>
<entry>
<title>skill: which pane am I, and how not to land in somebody else's tab</title>
<updated>2026-09-29T12:26:49Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:49Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=84e19fad4810defd9f0fa6d3190197c559f3768f'/>
<id>urn:sha1:84e19fad4810defd9f0fa6d3190197c559f3768f</id>
<content type='text'>
The skill described the API and left the one question an agent actually
gets wrong unanswered. It now leads with it: your pane is $SLOSH_PANE,
`focused` is the human's, and the fallback when the environment is scrubbed
is `pid` -- never focus.

The rest follows the verbs that shipped with it: `capture` for reading a
pane in any tab, `raw id` for typing into one, `apply-layout focus:false`
and `move-pane beside` for building work beside yourself without dragging
the view along.
</content>
</entry>
<entry>
<title>docs: the pane you are in, the pane you name, and not moving the view</title>
<updated>2026-09-29T12:26:43Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:43Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=e67131c52f91b01758cd56359848fbb11e303958'/>
<id>urn:sha1:e67131c52f91b01758cd56359848fbb11e303958</id>
<content type='text'>
$SLOSH_PANE in the man page's environment list, next to SLOSH and
SLOSH_SESSION, and the scripting guide grows the section that follows from
it: read yourself with `capture`, build with `apply-layout focus:false`,
place with `move-pane beside`, and never read `focused` as "where do I
live". `pid` is written down as the answer for anything that cannot read
its own environment.

(Three roff \(em escapes had got into the markdown table; they are em
dashes like the rest of the file.)
</content>
</entry>
<entry>
<title>layouts, moves: build panes without pulling the view to them</title>
<updated>2026-09-29T12:26:28Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:28Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=ca1b5bdf5bf7b5ff01ab515846802b3487599d3a'/>
<id>urn:sha1:ca1b5bdf5bf7b5ff01ab515846802b3487599d3a</id>
<content type='text'>
Applying a layout landed on the first tab it made, and moving a pane into a
tab took focus there. For a program building a pane in the background that
is two failures at once: it yanks the view off the human mid-sentence, and
it leaves the *next* caller to read focus as "where I am" and put its work
in a stranger's tab.

`apply-layout focus:false` builds the tabs and keeps looking at whatever was
on screen; a layout naming an `active` tab still wins, because that is a
statement about where to be. `move-pane` gains `beside` (which pane in the
destination to split, rather than whatever it has focused) and `focus:false`
-- the two things a caller placing a pane next to *itself* needs.

That uncovered a sizing bug that was unreachable before: a pane's size comes
from a layout pass, and a tab nobody has looked at never had one, so a pane
built in the background stayed 1x1 and its program drew into a single cell.
The jump is what used to size it. layout_tab() gives a background tab a real
pass, borrowing the tab cursor for its length -- nothing composes, reads
input or draws a frame in between.
</content>
</entry>
<entry>
<title>panes: read and write a pane by id, without taking focus</title>
<updated>2026-09-29T12:26:18Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=43fa063445821470cbf55f85aa8116c11470396f'/>
<id>urn:sha1:43fa063445821470cbf55f85aa8116c11470396f</id>
<content type='text'>
`raw` wrote into the focused pane and `snapshot` composited the active tab,
so driving a pane anywhere else meant focusing it or selecting its tab
first. Both of those move the view of whoever is watching, and both lose to
anybody doing the same thing at the same time.

`raw` now takes an `id` (0 is the focused pane, as before), and `capture`
answers one pane's viewport as text -- from the pane's own terminal, so it
works for a pane in a background tab, changes no focus, and leaves the
human's selection alone. It builds its selection as a snapshot rather than
setting one on the terminal, because a read with a visible side effect is
not a read.

`capture` tells "no such pane" apart from "that pane is empty": a caller
polling a dead id would otherwise wait forever on a pane that is quiet
because it is gone.
</content>
</entry>
<entry>
<title>panes: $SLOSH_PANE, so a program knows which pane it is in</title>
<updated>2026-09-29T12:26:06Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-29T12:26:06Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=e779c1fafa556330752b2c5f43c56415f655ff24'/>
<id>urn:sha1:e779c1fafa556330752b2c5f43c56415f655ff24</id>
<content type='text'>
A program in a pane could name every pane but its own. `panes` says which
one is *focused*, and focus belongs to the human, so anything that inferred
"where do I live" from it did its work in whichever tab somebody else
happened to be looking at -- and lost a race with anybody else asking the
same question.

So export the id into the child, the way tmux has $TMUX_PANE and zellij
$ZELLIJ_PANE_ID. The id is taken before the pane is made, because pane_new
spawns the program and assigning it afterwards is one instruction too late;
it lives on the pane rather than the layout, so a rerun keeps it -- an
identity that changed under a rerun would be no identity at all. Unset, not
zero, when there is none: a stale value inherited from an outer session
would read as this one's.

`panes` also reports each pane's pid now, which is the same question
answered for anything that cannot read its own environment: walk your
parents up until one of them is on that list.
</content>
</entry>
</feed>
