Handover
Written at the end of a long session. Start here.
Done: the + split button renders (and clicks where it is drawn)
Feature: a + at the right end of every pane's title line; clicking it
splits that pane. Shipped invisible in 0.1.7 and 0.1.8; fixed after both.
It was never one bug. The title line has three builders, and the button was falling out of two of them, while the click was hit-testing a rect the frame is not drawn in:
- No title, no right side.
title_line_without_middleonly computed the right side inside the left side'sand_then, so a pane with an empty title got none. Live setshide_default_pane_names true, so unnamed panes have exactly that. - A "MY FOCUS" middle dropped the right side entirely.
title_line_with_middlefell through tomiddle_only_title_linewhenever the left side was absent. Any second client in the session puts a middle there — including the phantom client of open item 1 below, which is why the live box always took this path (MY FOCUS AND:on every frame was the clue, and it was in the bug report all along). - A long title ate the button. The title was budgeted first and the right side got the remainder; shell titles are long paths. The anchored button is now reserved before the title, since its hit-test is pure geometry and cannot ask the renderer what fitted.
- The click hit-tested
position_and_size(), the pane's allocation, while the frame is drawn inset by the pane gap (gap 1→ 2 columns, 1 row). The clickable columns sat out in the blank gap, two right of the drawn+. This is why "clicking works" and "nothing is drawn" were both true, and why that click test proved nothing. There is nowPane::frame_rect()— the rect the frame is actually drawn in — and both the button and (already) the status bar buttons derive from it. - Stacks put the frame somewhere else again, found only after the above
shipped and the button was still dead on a live session:
- a stack list is drawn over the top rows of its visible member, pushing that
pane's frame down (
reserved_top_rows, applied by the renderer as a frame geom override).frame_rect_with_top_reserved()applies the same shift, or every pane in a stack is clickable one row above its own title; - a stack's collapsed members drew a button on their one-row title, which nothing could honour — that row is the stack's picker. They no longer draw one (120 snapshot lines, all of them exactly that), and the click side mirrors the condition.
The centre-aligned path, the strong lead in the previous handover, was fine.
What was missing was the reproduction, not the analysis. Two things now exist so this cannot come back:
split_button_is_drawn_where_it_is_hit_tested— asserts the button occupies exactlysplit_button_columns()across alignments × rounded × titled/untitled × one/two clients × 4 widths. Positional, notcontains("+"): the short multi-client indication is itself a+.packaging/checks/split-button-live.py(make check-live) — spawns a real session at a pty, reads the glyph off the screen, attaches a second client to force the middle builder, splits and stacks, and then clicks every button it can see, expecting one new pane each time. Run twice: with a minimal config and withconfig/config.kdl, because the minimal one has no stacks and that is where the second round of bugs lived. Each fix was confirmed by putting it back and watching the check fail.
Two things about that check are load-bearing, both learned the hard way when it reported failures that were its own fault:
- read until the pty goes quiet, not for a fixed time. A repaint arrives in many chunks and half of one looks exactly like a button drawn where it is not.
- force a full repaint (resize) and parse only that, because the screen model here is 80 lines of regex, not a terminal, and stale cells from a previous layout are indistinguishable from real ones.
When the check and the code disagree, log::info! in
intercept_split_button_click and read /tmp/sl0ppij-$UID/sl0ppij-log/ — the
server's own numbers settled it in one run, after an hour of screen-scraping
theories.
One more bug fell out of the width assertion: render_my_focus built its full
and short forms from the same separator vectors, and Vec::append drains — so
the short "ME" form rendered 4 cells wide while reporting 6, two cells short of
the pane. Fixed.
Code involved:
| what | where |
|---|---|
| render + geometry | ui/pane_boundaries_frame.rs: render_split_button, split_button_columns, anchored_right_side_len, SPLIT_BUTTON_* |
| click | tab/mouse_handler.rs: intercept_split_button_click |
| where the frame really is | tab/mod.rs: Pane::frame_rect (+ the two impls) |
Not drawn on floating panes on purpose: the PIN control owns that corner and is
hit-tested by its drawn position, so anything to its right moves it out from
under the pointer (test_ctrl_click_on_floating_pin_button_toggles_pin).
Also done: shipped plugins no longer ask for permission
pane-finder opened a three-item consent screen the first time you pressed its
keybinding. Plugins we ship are now granted implicitly --
plugin_is_shipped_with_sl0ppi in zellij-utils/src/input/permission.rs,
checked by packaging/checks/plugin-permissions-live.py.
The part to keep in mind: <config dir>/plugins/ is now a trusted
directory (documented in config/README.md). The same wasm from anywhere else
still prompts, and remote plugins are never trusted. If you ever need the
opposite for a specific plugin, the honest fix is to load it from outside that
directory rather than to widen the rule.
Where the project is
Everything is committed and pushed to ssh://git@git.sl0p.foo:39327/srv/git/sl0ppi.git.
Released artifacts are at https://sl0ppi.sl0p.foo (currently 0.1.9).
| phase | state |
|---|---|
| monorepo, 3 vendored forks with history | done |
| rename (sl0ppij coexists with zellij) | done |
| portable config | done |
| tarball + installer, verified in a clean room | done |
sl0ppi CLI MVP (up/ls/open/close/init/print/doctor) |
done |
| site + release scripts (no CI, by choice) | done |
make for targets, make test for both suites, make release && make publish
to cut and upload a version, packaging/publish.sh verifies against the live
URL afterwards.
Open, in the order I would do them
- Phantom client on headless
sl0ppi up.attach --create-backgroundleaves one client in the session forever, so every pane frame showsMY FOCUS AND:. Worked around by creating the session by attaching when there is a tty; the headless path still leaks. Ruled out: the CLI-action clients, and the creating client exiting too early (making it wait for a server ack changed nothing).screen.remove_client()does clearconnected_clientswith no early return, so instrumentScreenInstruction::RemoveClienton a debug build to find where it is lost. Cheap alternative: give the headless path its own pty (script -qec) and kill the client afterwards — killed clients demonstrably do not leak. pirneeds python3 + fzf, neither shipped. The fix is folding it intosl0ppi agent --resume(already designed indocs/cli-design.md).- Tab overflow.
sl0ppi open <name>wants to be a fuzzy picker once a root has a dozen projects. - Button polish: shift/right-click for the other split direction; reuse
status_bar_button_styleso it looks pressable. Extendmake check-liverather than trusting a snapshot.
Fixed this session, for context: client ids were recycled (silently ate ~25% of
zellij action output), suspended panes spammed resize errors, responsive
layouts resolved against visible_panes_count() and never really expanded,
runtime new-tab dropped pane/tab purposes, and generated sessions had no tab
bar. See docs/roadmap.md for each with its measurements.
Traps worth knowing (all cost me time here)
pkill -f/pgrep -fmatch your own shell's command line. I killed my own script three times, and twice killed the user's live clients. Match oncomminstead:ps -eo pid,comm,args | awk '$2=="sl0ppij"'.- Test sessions live in the runtime dir, not
TMPDIR. OnlySL0PPIJ_SOCKET_DIRisolates them. A stale session calleddevwas silently reused across tests. INSTA_FORCE_UPDATE=1churns hundreds of snapshots (stripsassertion_line, trailing newlines). Update per-test withINSTA_UPDATE=always cargo test ... <test_name>and verify the diff contains only what you expect.- git paths in loops:
git diff --name-onlyprints repo-root-relative paths; runninggit checkout --on them from insidesl0ppij/silently fails. - Single-line KDL needs
;between nodes.tab x { pane }will not parse; emit multi-line. - A tab template used to destroy tab purposes —
parse_tab_node_with_templatenever readpurposeoff the tab node, so the template's (none) won. Fixed;sl0ppiemitsdefault_tab_templateagain, which is also what gives a tab created at runtime a status bar. If you ever revert that line, the CLI has to go back to inlining, and runtime tabs lose their bar. - The tab test harness does no rendering, so
panes_to_hideis always empty and stack-related bugs cannot be reproduced in it. Use a scripted pty. action list-paneshides borderless plugin panes once a client attaches. The status bar vanishes from its output while being drawn on screen the whole time.dump-layoutand the screen are the truth; an hour went into a bug that only existed in that JSON.- The generated layout is a file of its own. Anything
layouts/default.kdlrefers to -- pane templates especially -- has to be copied into it, and its swap layouts must NOT be (see roadmap: the bar disappears when auto_layout swaps a tab that has nodefault_tab_template). - A hit-test that never consults the renderer is not evidence of anything.
The split button "worked" for two releases while being invisible. If an
affordance is drawn, the check has to find the glyph and click where it
found it —
packaging/checks/split-button-live.pyis the pattern. os.readon a settled pty blocks forever.os.set_blocking(fd, False)and catchBlockingIOError, or a live check hangs instead of failing.- The pane's geometry is not where its frame is drawn. With a gap, the frame
is inset from the allocation: use
Pane::frame_rect()for anything the user can see,position_and_size()only for the layout solver. - Never kill a
sl0ppijyou cannot PROVE is yours. I killed the user's live client on the reasoning "there are no user sessions on this box" -- a guess, not evidence, and wrong. The evidence was right there and said the opposite: a check's processes always carry that run's own socket dir (--server /tmp/sl0p-<run>/...) orattach sl0ptest-..., while a baresl0ppijwith a cwd in the repo is a human's. If the args do not contain your own temp dir, it is not yours. - Clean up test sessions, and DELETE them. A background-created session I
left behind is what produced a "MY FOCUS" bug report from the user, and
kill-sessionalone leaves them listed as "EXITED - attach to resurrect": 35 of mine had silently filled the session picker before I noticed. The live checks nowdelete-sessiontheir own session by name; anything you spawn by hand, clean up the same way. send_cli_scroll_up_actionflakes (scrolled 1 line where the snapshot has 2), seen once in ~10 full runs. It is the CLI-action raciness already tracked above, not a rendering bug -- re-run before chasing it.
Access
- test box:
ssh root@10.10.10.130(Debian, 2 vCPU; where every live check ran) - prod:
ssh root@sl0p.foo; nginx config lives in~/dev/sl0pday/nginx/sl0p.conf, deployed with./deploy.sh nginx/sl0p.conf(that repo has no git remote) sl0ppi.sl0p.foocurrently answers 401 to everyone except 87.209.3.148 (our egress). Lift by deleting thegeoblock and theifin the vhost.- new git repos:
ssh root@sl0p.foo git-new-repo <name> - releases need
cargo-zigbuild+ theziglangpython module (both installed);protoc25.1 is in~/.local/binforcargo xtask proto.
How to verify things here
Unit tests are necessary and have repeatedly been insufficient — three of this session's bugs passed them. For anything touching panes, layouts or the mouse, drive a real session:
# pty at a chosen size; resize or inject SGR mouse events mid-session
pid, fd = pty.fork()
if pid == 0: os.execvp("sl0ppi", ["sl0ppi"])
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
os.write(fd, b"\x1b[<0;COL;ROWM") # left press, 1-based coords
then read the truth back out of sl0ppij -s <session> action list-panes --json
(x, y, cols, rows per pane — two panes sharing a rect means a stack) and strip
ANSI from the pty output before grepping for glyphs.
