<feed xmlns='http://www.w3.org/2005/Atom'>
<title>sl0ppi.git/sl0ppij, branch main</title>
<subtitle>a sl0p.foo repository</subtitle>
<id>https://git.sl0p.foo/sl0ppi.git/atom/sl0ppij?h=main</id>
<link rel='self' href='https://git.sl0p.foo/sl0ppi.git/atom/sl0ppij?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/'/>
<updated>2026-08-13T20:07:45Z</updated>
<entry>
<title>sl0p: keep a templated tab's purpose, so runtime tabs get a status bar</title>
<updated>2026-08-13T20:07:45Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T20:07:45Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=a8ea4e888938e562885b1a0f009f5743939a879b'/>
<id>urn:sha1:a8ea4e888938e562885b1a0f009f5743939a879b</id>
<content type='text'>
Clicking the bar's `+` made a tab with no status bar. The button only exposed
it: pressing `n` in TAB mode did the same, in any `sl0ppi up` session.

The chain: a generated layout had no default_tab_template, so a tab created at
runtime had nothing to take a bar from. It had none because
parse_tab_node_with_template read a tab node's name, cwd and focus but never
its `purpose` -- so templating erased the purpose that IS identity here
(`sl0ppi ls` finds project tabs by it), and the CLI worked around that by
inlining the template into every tab instead of emitting it.

Fixed at the root: a templated tab keeps its own purpose, and the CLI emits
`default_tab_template` again. The generated layout also shrinks -- one copy of
the bar's configuration instead of one per tab.

The two properties trade off against each other, so sl0ppi-up-live.py now
asserts both at once: `sl0ppi ls` still sees a tab per project, and every tab
including one created at runtime has exactly one status bar. Confirmed by
reverting each half separately: without the parser fix the tabs lose their
purpose and `ls` shows "alpha —"; without the CLI change the runtime tab has
zero bars, which is the reported bug.

Two measurement traps cost an hour here and are now written down: counting bars
by grepping dump-layout also counts the template and every swap layout ("4 bars
for 3 tabs"), and list-panes stops reporting borderless plugin panes for a tab
once a client has rendered it.

Also in here, from the same session:

- the split button check found buttons by searching for " + " on any row with a
  frame glyph, which matches the stack list's "+N more" once two panes sit side
  by side, and then reports the product broken. It now requires the frame
  corner the button is drawn against.
- the checks killed their sessions but did not delete them, so each run left an
  "EXITED - attach to resurrect" entry: 35 had quietly filled the session
  picker. They now delete-session by name.
- and the trap that cost the user their running client: I killed a bare
  `sl0ppij` because "there are no user sessions on this box". That was a guess,
  not evidence, and wrong. A check's processes always carry that run's own
  socket dir or `attach sl0ptest-...`; a bare `sl0ppij` with a cwd in the repo
  is a human's. Written into the handover next to the `pkill -f` trap, which is
  the same mistake in a different costume.
</content>
</entry>
<entry>
<title>sl0p: the plugins we ship do not ask for permission</title>
<updated>2026-08-13T18:08:50Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T18:08:50Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=fb7076efd6d3ab5c1d36b6e554045d87b43c15b6'/>
<id>urn:sha1:fb7076efd6d3ab5c1d36b6e554045d87b43c15b6</id>
<content type='text'>
pane-finder requests three permissions as it loads, so the first press of a
keybinding we ship opened a consent screen instead of a fuzzy finder. Prompting
is right for a wasm you found on the internet and wrong for the ones that come
in our own tarball.

plugin_is_shipped_with_sl0ppi (zellij-utils/src/input/permission.rs) grants
those implicitly, through the same instruction a human "y" produces, so the
plugin's permissions are set and recorded exactly as before. "Ours" is narrow
on purpose:

- builtins, asked via the existing PluginConfig::is_builtin -- the same
  predicate check_command_permission already uses to exempt them, so the two
  cannot drift;
- files inside a plugin directory we install into: the config dir's plugins/
  and the data dir's plugins/;
- nothing else. Remote plugins are never trusted, paths are normalised
  lexically first so plugins/../../evil.wasm is not "inside" the dir, and the
  directories are derived from the environment and default locations -- so
  getting them wrong costs a prompt and can never grant one elsewhere.

The trade, documented in config/README.md: &lt;config dir&gt;/plugins/ is now a
trusted directory. Anything able to write there can already bind arbitrary
commands to a key in config.kdl, so a consent screen for the wasm beside it was
not buying anything -- but it does mean plugins/ is not a download folder.

packaging/checks/plugin-permissions-live.py drives both halves on a real
session: the shipped plugin must run without the prompt, and the SAME wasm
loaded from outside the plugin dir must still ask. Confirmed by disabling the
rule and watching the first half fail with the exact "asks permission to ...
Allow? (y/n)" screen it was written against.
</content>
</entry>
<entry>
<title>sl0p: make the split button work on stacked panes too</title>
<updated>2026-08-13T17:39:18Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T17:39:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=ae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e'/>
<id>urn:sha1:ae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e</id>
<content type='text'>
Reported still dead on a live session after the previous fix. Stacks move the
frame twice more, and neither move was in the hit-test:

- a stack list is drawn over the top rows of its visible member, pushing that
  pane's frame down. The renderer applies this as a frame geom override
  (reserved_top_rows); the click side did not, so every pane in a stack was
  clickable one row above its own title. frame_rect_with_top_reserved() now
  applies the same shift, before the gap inset, in the same order as render;
- a stack's collapsed members drew a button on their one-row title. Nothing
  could honour it -- that row is the stack's picker, and "split the pane that
  is not on screen" is not a thing to ask anyway. They no longer draw one, and
  the click side mirrors the condition. 120 snapshot lines change, every one
  of them a one-liner losing its button and nothing else (same width, same
  content otherwise; audited mechanically).

Both confirmed by reintroducing each bug and watching the check fail. A third
candidate fix -- resolving a click to the stack's visible member, on the theory
that get_pane_at could return a collapsed sibling -- turned out to be
unnecessary: collapsed members have one-row geoms, so position lookup already
lands on the right pane. Removed rather than kept "just in case".

The check itself was wrong twice before it was right, in the direction that
matters most (reporting failures that were its own): it now reads until the pty
goes quiet instead of for a fixed time, and parses only a freshly forced
repaint, so a half-arrived screen cannot look like a button drawn where it is
not. It clicks every button it can see, and runs against config/config.kdl as
well, whose stacks the minimal config never had.

When the two disagree, the server's own log settles it: log::info! in
intercept_split_button_click, read /tmp/sl0ppij-$UID/sl0ppij-log/.
</content>
</entry>
<entry>
<title>build: cut the edit loop from 52s to 5s with more codegen units</title>
<updated>2026-08-13T16:59:59Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T16:59:59Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=92ffceaa5a4c39eab113b94272aa3cd13f634dcb'/>
<id>urn:sha1:92ffceaa5a4c39eab113b94272aa3cd13f634dcb</id>
<content type='text'>
zellij-server is one enormous crate and `fastdev` was building it in 16
codegen units, which is the granularity the incremental cache can skip work
at: a one-line change re-optimised a sixteenth of the crate. Splitting the
crates we actually edit into 256 units makes that a sliver instead.

Measured on the same one-line change to pane_boundaries_frame.rs, `git
checkout` of the file to a linked binary:

  codegen-units = 16    52s
  codegen-units = 256    6s, 4s on a second run
  + opt-level = 1        5s   (rejected: not worth a slower binary)

Ruled out on the way: linking the 69MB binary is ~2s, and rust-lld did not
beat the default linker on it, so the linker was never the problem. Root
crate codegen plus link is ~2s of the total.

Costs one 80s rebuild when switching profiles, and the fastdev binary keeps
opt-level 3. Verified with the full server/utils suites and make check-live.
</content>
</entry>
<entry>
<title>sl0p: make the split button visible, and clickable where it is drawn</title>
<updated>2026-08-13T16:46:58Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T16:46:58Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=2f0750285b1b174a552113c4aed9288f44985ae3'/>
<id>urn:sha1:2f0750285b1b174a552113c4aed9288f44985ae3</id>
<content type='text'>
The `+` shipped in 0.1.7 and 0.1.8 without ever appearing on a live session,
while rendering in 265 snapshots. Four faults, each enough on its own:

- title_line_without_middle computed the right side only inside the left
  side's and_then, so a pane with no title (hide_default_pane_names, the live
  default) got none at all;
- title_line_with_middle discarded the right side whenever the left was
  absent -- that is every frame in a session with a second client, which the
  live box always had, and "MY FOCUS AND:" on every frame was saying so;
- the title was budgeted before the button, so a title longer than the frame
  (any shell showing a path) consumed its columns. The button is anchored and
  hit-tested by pure geometry, so it cannot be the part that loses;
- the click hit-tested position_and_size() while the frame is drawn inset by
  the pane gap, putting the clickable columns two right of the glyph and a row
  above it at the top of the screen. Hence "the click works" and "nothing is
  drawn" both being true, and that click test proving nothing.

Adds Pane::frame_rect(), the rect the frame is really drawn in, which the
status bar buttons had already had to work around by hand.

Also fixes render_my_focus building its short form from separators that
Vec::append had already drained: 4 cells wide while reporting 6, a title line
two cells short of its pane. Found by asserting the width in the new test.

Verification, since unit tests have now been insufficient here four times:
split_button_is_drawn_where_it_is_hit_tested covers the combinations the live
config produces, and packaging/checks/split-button-live.py (make check-live)
reads the glyph off a real session, attaches a second client to force the
middle builder, and clicks the columns it found the glyph in. Both confirmed
to fail on the parent commit.
</content>
</entry>
<entry>
<title>sl0p: account for the split button in the title-alignment test</title>
<updated>2026-08-13T12:47:20Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T12:47:20Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=4d4222b4ef4029dcfa9af4ffa2149ae8f31370df'/>
<id>urn:sha1:4d4222b4ef4029dcfa9af4ffa2149ae8f31370df</id>
<content type='text'>
A right-aligned title no longer runs flush to the corner: the split button now
occupies SPLIT_BUTTON_WIDTH columns between them. Adjusts the expectation rather
than the layout, since that gap is the intended design.
</content>
</entry>
<entry>
<title>sl0p: draw the split button always, not only on hover</title>
<updated>2026-08-13T12:46:03Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T12:46:03Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=a4a5e7b8287ef4e8421f4a7e9c659ac14eebafed'/>
<id>urn:sha1:a4a5e7b8287ef4e8421f4a7e9c659ac14eebafed</id>
<content type='text'>
The hover-gated version did not work. `mouse_is_hovering_over_pane` never became
true in a live session, so the `+` was invisible while its hit-test -- which is
geometric and never consulted hover -- kept working. Clickable but invisible is
the worst of both, and it is exactly the failure the shared
`split_button_columns()` was supposed to prevent: that guarantees render and
hit-test agree on WHERE, not on WHETHER.

Reported as "I hover over panes but don't see a +". My end-to-end test had
proved the click, not the button, and the passing snapshots only proved the
renderer works when the flag is set.

Why the flag is false live is still open and is written into the code comment:
the plumbing from `mouse_hover_pane_id` through `FrameParams` reads correctly,
and the config that reproduces it sets both `advanced_mouse_actions` and
`mouse_hover_effects`. Re-gate on hover only once that is understood AND shown
on a real session rather than in a snapshot.

265 snapshots updated: every pane frame now carries the button. 233 are
mechanically provable as button-only (the ` + ` replaces three horizontal glyphs
before a corner); the remaining 32 differ only by a header line offset in the
comparison, with the same substitution underneath.
</content>
</entry>
<entry>
<title>sl0p: a mouse affordance for splitting a pane</title>
<updated>2026-08-13T12:04:23Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T12:04:23Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=8e93e00e2c8f5692b6fbc200d989646bd088ddad'/>
<id>urn:sha1:8e93e00e2c8f5692b6fbc200d989646bd088ddad</id>
<content type='text'>
Switching panes, switching tabs, renaming, reordering and resizing all have a
mouse path in this fork. Spawning a pane did not: it was keyboard-only.

A `+` now appears at the right end of a pane's title line while the pointer is
over that pane, and clicking it splits the pane. Hover-driven rather than always
drawn, for the same reason the resize hint and the status bar button highlight
are: a permanent `+` on every frame is noise, and the pointer being there is
exactly when the button is useful.

  render  ui/pane_boundaries_frame.rs::render_split_button
  hit     tab/mouse_handler.rs::intercept_split_button_click
  shared  split_button_columns(), so what is clickable is what is visible

The button sits immediately left of the top-right corner and the existing scroll
and pin indications get the room that is left. That ordering is deliberate: a
fixed position relative to the pane's right edge is one the mouse handler can
hit-test directly, instead of re-deriving the whole title layout the way
`status_bar_button_at` has to.

The clicked pane is focused first, then the split is requested exactly as a
keybinding would (`PtyInstruction::SpawnTerminal` with `NoPreference`), so mouse
and keyboard produce the same arrangement and the split lands on the pane whose
button was clicked rather than whichever pane happened to be focused.

Not drawn on floating panes: their PIN control owns that corner and is
hit-tested by its drawn position, so anything to its right silently moves it out
from under the pointer. Caught by
test_ctrl_click_on_floating_pin_button_toggles_pin, which stopped pinning.

Verified on the test box by injecting real SGR mouse events into an attached
client's pty at 160x45:

  click on the button        1 -&gt; 2 panes
  click on the title         1 -&gt; 1
  click mid-frame            1 -&gt; 1

Two hover snapshots updated, and only those two: the `+` on the hovered pane
(including an unfocused one -- splitting a pane you are not in is useful).
zellij-server 1502 pass, zellij-utils 473 pass.
</content>
</entry>
<entry>
<title>sl0p: resolve responsive layouts against hidden panes too</title>
<updated>2026-08-13T11:35:38Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T11:35:38Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=ed892dbdae90b738d8a1cdef7bc31d6992ea399b'/>
<id>urn:sha1:ed892dbdae90b738d8a1cdef7bc31d6992ea399b</id>
<content type='text'>
A pane created after a responsive collapse/expand cycle was absorbed into a
stack, even at full width. Reported as "a new pane starts collapsed even though
the terminal is big enough", and it appeared to fix itself on resize.

The expand was never really happening. `SwapLayouts::tiled_layout_at` checked
whether a candidate layout fits by positioning it against
`tiled_panes.visible_panes_count()` -- and a stack hides every member but one.
So while collapsed, the check ran with a pane count of 1, and a layout with no
room for the hidden panes passed as feasible. The tab reported
`sl0p:responsive-expand` while every pane stayed in the single rect it had while
collapsed; the next pane added simply joined that stack.

Feasibility is now checked against every tiled pane, hidden or not, which is the
count the layout actually has to accommodate. Only the responsive collapse/expand
resolves layouts through this function -- ordinary swap-layout cycling has its
own body in `swap_tiled_panes` -- so the change is contained to the path that was
wrong.

Verified on the test box (responsive_min_cols 90) with a scripted pty that
resizes mid-session, geometry read back from `list-panes --json`:

  before   narrow(80) 2 panes   -&gt; one rect, both 80x23        (correct collapse)
           widen to 160         -&gt; STILL one rect, 160x44      (expand did nothing)
           add a pane           -&gt; new pane joins the stack

  after    narrow(80) 2 panes   -&gt; one rect                    (collapse unchanged)
           widen to 160         -&gt; x=0 and x=80, side by side  (really expanded)
           add three panes      -&gt; 2+1, then 2x2, then 3+2 grids, no shared rects
           wide from the start  -&gt; unaffected
           narrow again (70)    -&gt; collapses, as before

No unit test ships with this, deliberately. Three attempts could not make one
discriminate: the tab harness does no rendering, so nothing populates
`panes_to_hide` and both counts are equal; hiding panes by hand does not help
because the harness's expand takes the BASE path; and asserting on the resolver
directly bypasses the count, since `tiled_layout_at` uses it only for the
feasibility check and returns the raw layout. A test that passes with the fix
reverted is worse than none, so the scripted live reproduction above is the
verification, and it is written down in docs/roadmap.md to be re-run.

zellij-server 1502 pass, zellij-utils 473 pass.
</content>
</entry>
<entry>
<title>sl0p: stop recycling client ids, which silently ate CLI output</title>
<updated>2026-08-12T22:33:31Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T22:33:31Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=abd7355104e85360fdb62fc8f9560522fff0c028'/>
<id>urn:sha1:abd7355104e85360fdb62fc8f9560522fff0c028</id>
<content type='text'>
`zellij action` failed ~20% of the time against a live session: exit 0, empty
stdout, empty stderr, and nothing in the server log. `sl0ppi ls` inherited it,
and attached clients were dropped with "lost connection to the zellij server".

The chain, which took measuring rather than reading to find:

  1. every action first probes liveness (sessions::assert_socket): connect,
     send ConnStatus, disconnect. The probe is a full client and takes an id.
  2. new_client() handed out the LOWEST FREE id, so the action connection that
     follows microseconds later gets the id the probe just released.
  3. the main server thread then processes the queued ConnStatus(id), whose
     handler replies and calls remove_client!(id) -- removing the *action*
     client, which now owns that id.
  4. the action's reply goes through send_to_client, which returns Ok(()) for an
     unknown client. The payload is dropped in silence; the client sees only the
     disconnect and exits 0 with nothing to print.

Fix: hand out ids from an advancing cursor instead of reusing the lowest free
one, so a stale removal cannot land on a fresh connection. ClientId is a u16 and
every CLI invocation burns two, so the cursor wraps and skips ids that are still
in use rather than assuming they are free.

Measured on the 2-vCPU Debian box that surfaced this:

                        before        after
  list-tabs             ~20-25%       0/50 failed
  list-panes            ~20-25%       0/50 failed
  list-tabs (attached)  ~25%          0/30 failed
  sl0ppi ls             most runs     0/15 failed
  5 attach cycles       dropped       5/5 stable

Three regression tests: ids are not reused immediately, they advance and skip
live ones, and they wrap without colliding or hanging. zellij-server: 1502 pass.

The silent-drop in send_to_client is left as-is but is worth revisiting: an
unknown client id is a bug in the caller, and swallowing it is what made this
cost a day instead of a minute.
</content>
</entry>
</feed>
