# 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: 1. **No title, no right side.** `title_line_without_middle` only computed the right side *inside* the left side's `and_then`, so a pane with an empty title got none. Live sets `hide_default_pane_names true`, so unnamed panes have exactly that. 2. **A "MY FOCUS" middle dropped the right side entirely.** `title_line_with_middle` fell through to `middle_only_title_line` whenever 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). 3. **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. 4. **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 now `Pane::frame_rect()` — the rect the frame is actually drawn in — and both the button and (already) the status bar buttons derive from it. 5. **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 exactly `split_button_columns()` across alignments × rounded × titled/untitled × one/two clients × 4 widths. Positional, not `contains("+")`: 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 with `config/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: **`/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 (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 1. **Phantom client on headless `sl0ppi up`.** `attach --create-background` leaves one client in the session forever, so every pane frame shows `MY 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 clear `connected_clients` with no early return, so instrument `ScreenInstruction::RemoveClient` on 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. 2. **`pir` needs python3 + fzf**, neither shipped. The fix is folding it into `sl0ppi agent --resume` (already designed in `docs/cli-design.md`). 3. **Tab overflow.** `sl0ppi open ` wants to be a fuzzy picker once a root has a dozen projects. 4. Button polish: shift/right-click for the other split direction; reuse `status_bar_button_style` so it looks pressable. Extend `make check-live` rather 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 -f` match your own shell's command line.** I killed my own script three times, and twice killed the user's live clients. Match on `comm` instead: `ps -eo pid,comm,args | awk '$2=="sl0ppij"'`. - **Test sessions live in the runtime dir**, not `TMPDIR`. Only `SL0PPIJ_SOCKET_DIR` isolates them. A stale session called `dev` was silently reused across tests. - **`INSTA_FORCE_UPDATE=1` churns hundreds of snapshots** (strips `assertion_line`, trailing newlines). Update per-test with `INSTA_UPDATE=always cargo test ... ` and verify the diff contains only what you expect. - **git paths in loops**: `git diff --name-only` prints repo-root-relative paths; running `git checkout --` on them from inside `sl0ppij/` 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_template` never read `purpose` off the tab node, so the template's (none) won. Fixed; `sl0ppi` emits `default_tab_template` again, 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_hide` is always empty and stack-related bugs cannot be reproduced in it. Use a scripted pty. - **`action list-panes` hides borderless plugin panes once a client attaches.** The status bar vanishes from its output while being drawn on screen the whole time. `dump-layout` and 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.kdl` refers 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 no `default_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.py` is the pattern. - **`os.read` on a settled pty blocks forever.** `os.set_blocking(fd, False)` and catch `BlockingIOError`, 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 `sl0ppij` you 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-/...`) or `attach sl0ptest-...`, while a bare `sl0ppij` with 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-session` alone leaves them listed as "EXITED - attach to resurrect": 35 of mine had silently filled the session picker before I noticed. The live checks now `delete-session` their own session by name; anything you spawn by hand, clean up the same way. - **`send_cli_scroll_up_action` flakes** (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.foo` currently answers **401 to everyone except 87.209.3.148** (our egress). Lift by deleting the `geo` block and the `if` in the vhost. - new git repos: `ssh root@sl0p.foo git-new-repo ` - releases need `cargo-zigbuild` + the `ziglang` python module (both installed); `protoc` 25.1 is in `~/.local/bin` for `cargo 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: ```python # 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 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.