# Roadmap ## Phase 0 — scaffolding ✅ - [x] monorepo with real history: 62 fork commits replayed into `sl0ppij/`, 33 pi-ext commits replayed into `pi-ext/`, both verified byte-identical to their source trees - [x] `config/` — the live dotfiles imported as source of truth - [x] `FORKS.md` + `VERSION` — one sl0ppi version, explicit upstream bases - [x] `Makefile` + `packaging/` — build → vendor → stage → tarball - [x] proven: vendored bun runs vendored pi, so the tarball needs no system node ## Phase 1 — the rename ✅ [rename.md](rename.md) is implemented: own binary name, config dir, cache dir, socket dir, log file and plugin URL scheme, all behind a single `branding.rs` seam. `$ZELLIJ*` kept and `$SL0PPIJ*` added on top, ours winning on read. Verified live beside a running stock zellij; both suites green. Left for later (both need work disproportionate to their value right now): - [ ] strings *inside* the default plugins (About screen, status-bar tips) still say "zellij" — they are baked into prebuilt `.wasm` assets - [ ] `assets/zellij.desktop` / `zellij.rc`, used only by distro/Windows packaging ## Phase 2 — config becomes portable - [x] `config/config.kdl` repointed at `~/.config/sl0ppij/` and the `sl0ppij:` plugin scheme; verified to load under the renamed binary - [ ] decide whether the opinionated defaults are also *compiled in* as fallbacks, so a bare `sl0ppij` with no config dir still looks right ## Phase 3 — prove the artifact ✅ - [x] `make package` end to end → `sl0ppi-0.1.0-x86_64-linux.tar.gz` (83M, 328M staged) + sha256 - [x] installed into a throwaway `HOME` on a minimal PATH **with `node`, `npm`, `bun`, `cargo` replaced by poison stubs that fail loudly**: `sl0ppij`, `pi` (0.84.1, on the bundled bun) and `pir` all run. Nothing reached for a system runtime. - [x] real session started under a pty from the installed tree: config loads ("Well defined"), layout + fork pane titles render, both wasm plugins load. Only log line on a fresh profile is the benign "permission cache file not found". - [x] **found and fixed two real packaging bugs** (see below) - [x] **`musl` is now the release target.** The `gnu` build demanded `GLIBC_2.39` (Jan 2024), so the tarball would have failed on Debian 12, Ubuntu 22.04 and RHEL 9 -- the very machines this repo exists to install on. The shipped binary is now `statically linked`, with zero `GLIBC_` symbols. `stage.sh` fails loudly if a dynamically linked binary ever sneaks back in. Cross-compiling needs a musl C toolchain, because `isahc`/curl drags in `libz-sys` and `libnghttp2-sys` unconditionally (not behind `vendored_curl`, so it cannot be feature-flagged away). We use `cargo-zigbuild` + the `ziglang` python module, both already present on this box: no sudo, no docker daemon, no system packages. The tarball's remaining libc floor is **bun's glibc 2.17** (2012), which is effectively universal. If Alpine support is ever wanted, bun also publishes `bun-linux-x64-musl.zip` -- a one-line change in `fetch-vendor.sh`. - [x] the tarball now ships the `sl0ppi` CLI too, bundled to a single 31K file by `bun build` and run on the bundled bun — no source tree, no extra runtime. Re-cut after the protobuf fix, so the artifact is coherent again. - [ ] optional: `bun build --compile` pi into a single executable instead of shipping 183M of `node_modules` — but only if runtime-loaded `.ts` extensions still work in a compiled bundle. Test before committing to it. - [ ] optional: pre-seed the plugin permission cache so a fresh install does not open on a "Allow? (y/n)" prompt. ### What the smoke test caught Both were invisible to `make package` and to any amount of reading: 1. **`zjstatus` was missing entirely.** The config snapshot commit dropped it as "a third-party blob no longer referenced by config.kdl" — true of `config.kdl`, false of `layouts/default.kdl`, which builds the whole status bar out of it. It turned out to be a *third fork* (`sl0pstatus/`, now vendored) whose live `.wasm` was a custom build. Sessions started with a plugin load error. 2. **`install.sh` installed a hardcoded plugin list**, so even once staged, `zjstatus.wasm` never reached the config dir. Now it installs whatever the package contains. Both failure modes only appear *after* a session starts, which is the argument for keeping the clean-room session test in the release routine. ## Phase 4 — the `sl0ppi` CLI Design agreed: [cli-design.md](cli-design.md). One root dir = one session, one project = one tab keyed by its `project:` tab purpose, panes from a `.sl0ppi/layout.kdl` fragment. `up` is idempotent and everything rests on that. Entry point fails open to plain `sl0ppij` so an orchestrator bug can never cost you a terminal. MVP is complete: discovery + idempotent tab diff, `up`/`ls`/`open`/`close`, `init`, `doctor`, and the fail-open entry point. 88 tests. Deferred: `agent --resume` (needs the pir port off python3), fuzzy picker for tab overflow, root order file, per-project eager. ## Phase 5 — distribution ✅ (scripts, not CI) Live at **https://sl0ppi.sl0p.foo**. - `make release` — tests, then a fresh tarball - `make publish` — renders the site, uploads artifact + pointers, then asks the live site whether it agrees (status codes plus a re-downloaded checksum) - `curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh` — verifies sha256 before unpacking, and refuses to install anything on mismatch (tested by pointing it at a doctored latest.json) The vhost lives in the sl0pday repo (`nginx/sl0p.conf`), because that is where that box's nginx config is deployed from. It is static-only: no app, no build on the box. Deliberately still not CI. A build host and a pipeline are worth it once there is more than one target to build, and the publish path is scripted either way. Guard rails worth knowing: - `publish.sh` refuses to overwrite an already-published version whose bytes differ. `/releases/` is cached for a year, so republishing changed bytes under one name would serve two different things to two people. Bump `VERSION`. - artifacts upload before pointers, so `latest.json` never names a file that is not downloadable yet. --- ## Fixed: sl0ppij CLI actions were racy `zellij action` used to fail ~20-25% of the time against a live session on a 2-vCPU box, and attached clients were dropped with "lost connection to the zellij server". Two independent causes, both now fixed and measured: 1. **Client ids were recycled instantly** (`sl0p: stop recycling client ids`). Every action probes liveness first (`assert_socket` connects, sends `ConnStatus`, disconnects), and that probe is a full client. `new_client()` returned the lowest free id, so the action connection got the probe's id back; the queued `ConnStatus` removal then deleted the *action* client, and its reply was dropped by `send_to_client`, which returns `Ok(())` for an unknown id. Result: exit 0, no output, nothing logged. 2. **Suspended panes spammed resize errors** (`sl0p: stop suspended panes spamming resize errors`). A reserved-but-unstarted terminal has no fd, which was treated as an error and logged per pane per resize, on the server's hot path. sl0ppi puts a suspended agent in every project, so it was the heaviest possible user of that path. | | before | after | |---|---|---| | `list-tabs` | ~20-25% fail | **0/50** | | `list-panes` | ~20-25% fail | **0/50** | | `list-tabs`, client attached | ~25% fail | **0/30** | | `sl0ppi ls` | failed most runs | **0/15** | | 5 attach cycles | dropped | **5/5 stable** | | resize errors logged | 126+ | **0** | Worth revisiting: `send_to_client` silently returns `Ok(())` for an unknown client id. An unknown id is a caller bug, and swallowing it is what made this take a day to find instead of a minute. ## Known issue: `attach --create-background` leaves a phantom client A session created with `attach --create-background` keeps one client in its connected set for the rest of its life. Nothing renders that client directly, but it makes `connected_clients.len() > 1`, so `multiple_users_exist_in_session` is true and **every pane frame shows its multi-user form** — `MY FOCUS AND:` followed by one anonymous colour block. It looks like a stranger is in your session. Isolated by comparison, on the test box: | session created by | marker on first attach | |---|---| | `attach --create-background` | **yes**, and on every later attach | | ordinary `attach --create` whose client later dies | no | Exactly one phantom, always the session's first client; it does not accumulate. Not caused by the client-id change — that only made it *visible*, since recycled ids used to hand the phantom's id straight to the next attaching client. Things ruled out by measurement: it is not the CLI-action clients (a session with 15 actions and no attach shows nothing extra), and it is not a race with the creating client exiting too early (making that client wait for a server ack before exiting — verified 94ms, server answered — changes nothing, so that experiment was reverted rather than shipped). **Worked around, not fixed.** `sl0ppi up` now creates the session by *attaching* to it whenever it has a terminal, which is the path that does not leak; the background path remains for headless use, where no one is looking at a frame. The fork bug is still there for anyone calling `zellij attach -b`, and the next step is to instrument `ScreenInstruction::RemoveClient` on a debug build to find why the removal never reaches `screen.connected_clients`. ## Fixed: panes stacked after a responsive collapse/expand cycle A pane added after a narrow->wide cycle was absorbed into a stack at full width. The expand had never actually happened: `SwapLayouts::tiled_layout_at` checked whether a layout fits by positioning it against `visible_panes_count()`, and a stack hides all its members but one, so a layout with no room for the hidden panes passed as feasible. Fixed by checking against every tiled pane. **How to re-run the check** (the tab test harness cannot express this -- it does no rendering, so `panes_to_hide` is always empty and both counts are equal): ```bash # a pty that starts narrow and widens mid-session python3 resz.py 80 24 160 45 30 sl0ppi # see the commit for the script sl0ppij -s dev action new-pane sl0ppij -s dev action list-panes --json # compare (x, y, cols, rows) per pane ``` Two panes sharing one rect means a stack; distinct rects mean it really expanded. Check both directions (create narrow then widen, create wide then narrow) and add panes afterwards. ## Fixed: the `+` split button was drawn nowhere and clicked in the gap Shipped in 0.1.7 and 0.1.8 without ever being visible on a live session, while rendering in 265 unit snapshots. Four separate faults, each individually enough to hide it: - `title_line_without_middle` computed the right side only if the pane had a title -- and `hide_default_pane_names true` means unnamed panes have none; - `title_line_with_middle` dropped the right side whenever the left was absent, which is every frame in a session with a second client (`MY FOCUS AND:`); - the title was budgeted before the button, so a title longer than the frame (i.e. any shell showing a path) consumed its columns; - the click hit-tested the pane's allocation while the frame is drawn inset by the pane gap, putting the clickable columns two to the right of the glyph; - and, found after that shipped, stacks move the frame twice more: a stack list pushes the visible member's frame down by its reserved rows, and the collapsed members drew a button on a one-row title that nothing could ever honour. The first three are fixed in `ui/pane_boundaries_frame.rs` by reserving the anchored button before anything else and never discarding the right side; the rest by `Pane::frame_rect_with_top_reserved()`, the rect the frame is actually drawn in, and by not drawing a button on a stack's collapsed members. **How to re-run the check** -- unit tests were necessary and insufficient here for the fourth time, so this one reads the screen: ```bash make check-live # packaging/checks/split-button-live.py ``` It starts a session at a pty, finds the `+` on the frame, attaches a second client (the middle builder), splits and stacks, then clicks every button it can see and expects one new pane per click. It runs twice, with a minimal config and with `config/config.kdl` -- the real one, whose stacks are where the second round of bugs was. Every fix was confirmed by reintroducing it and watching the check fail. If it ever disagrees with the code, do not theorise about the screen: put a `log::info!` in `intercept_split_button_click` and read `/tmp/sl0ppij-$UID/sl0ppij-log/`. The server's own geometry settles in one run what screen-scraping cannot. ## Fixed: the shipped plugins asked for permission on first launch pane-finder requests three permissions as it loads, so the first press of its keybinding on any new machine opened a consent screen instead of a fuzzy finder -- a nag about our own software, on a binding we ship. Plugins we ship are now granted implicitly: builtins (via the existing `PluginConfig::is_builtin`, the same predicate `check_command_permission` already uses) and any wasm inside a plugin directory we install into -- the config dir's `plugins/` and the data dir's `plugins/`. The rule is `plugin_is_shipped_with_sl0ppi` in `zellij-utils/src/input/permission.rs`, and it is deliberately narrow: - the same wasm from any other path still prompts; - remote plugins are never trusted; - paths are normalised lexically first, so `plugins/../../evil.wasm` is not "inside" the plugin dir; - the trusted directories are derived from the environment and the default locations, so getting them wrong costs a prompt and can never grant one somewhere else. The trade is that `/plugins/` is now a trusted directory, documented in `config/README.md`: anything that can write there can already put arbitrary commands on a keybinding in `config.kdl`. **How to re-run the check**: ```bash make check-live # includes packaging/checks/plugin-permissions-live.py ``` It launches the real pane-finder wasm from a temp config dir and asserts it runs without the prompt, then launches the *same file* from outside that dir and asserts it still asks. Confirmed by disabling the rule and watching the first half fail with the exact "asks permission to ... Allow? (y/n)" screen. ## Status bar: first pass of the overhaul Two things, both in `sl0pstatus/` (our zjstatus fork) plus the layout that configures it: - **`margin_left` / `margin_right`** — the bar is a full-width pane while every pane above it is inset by the pane gap, so its ends stuck out past every frame on screen. The margins pull its content in. They are applied by rendering the bar into an inset viewport (`ModuleConfig::inset_state`), which is also what click positions are translated by, so the drawing and the hit-testing cannot disagree — the lesson from the split button, applied up front this time. - **`{new_tab}`** — a clickable button that opens a tab and focuses it, the mouse spelling of the `n` binding in TAB mode. Clicking a tab already switches to it and double-clicking renames it; making one was the gap. Keep `margin_*` in step with `config.kdl`'s `gap` x `gap_aspect` (1 x 2 = 2). The plugin cannot see that config, and inferring it from pane geometry would break on the first borderless pane. **How to re-run the check**: ```bash make check-live # includes packaging/checks/status-bar-live.py ``` It reads the bar off a real session and asserts it spans *exactly* the columns the pane frames span (2..97 at 100 columns), then clicks the `+` and expects one more tab, and clicks beside it and expects none. Confirmed by setting the margins to 0 and watching the span assertion fail with `(0, 99)` against the frames' `(2, 97)`. That check tracks painted cells, not ink: a bar segment starts with a padding space inside its coloured pill, so stripping colour puts the left edge one column off — the exact size of the thing being measured. ### The bar as a framed box Second pass: given three rows the bar draws the same rounded box the panes do, and given one it stays the single line it always was. Verified live — its borders span exactly the columns the pane frames span, and the corners are the same glyphs. The height is the **layout's** decision, because a plugin cannot resize its own pane: - `config/layouts/default.kdl` defines the bar as a `pane_template` named `sl0pbar` and places it at `size=3`; - `config/layouts/default.swap.kdl` defines a `tab_template name="mobile"` that places the same template at `size=1`, and the `stacked` swap layout — the one `responsive_layout` collapses to below `responsive_min_cols` — uses it. That reuse is only possible because zellij parses a layout and its `.swap.kdl` with the **same parser**, deliberately, "so that we can reuse assets (eg. pane and tab templates)". Without it the collapsed template would have to duplicate the entire 100-line zjstatus configuration. The plugin adapts to the rows it is handed (`ModuleConfig::viewport`): three or more and it frames itself, fewer and it does not, so a `size=1` bar can never render half a box. That one function also returns where the content line starts, and click routing reads it — which is why a click on the new box's border hits nothing instead of the widget underneath. ### A tab made at runtime had no status bar Reported as "creating a new tab with the icon from the status bar created a tab without statusbar" -- but the button was innocent: the `n` keybinding did it too. A generated session had no `default_tab_template`, so there was nothing for a runtime tab to take a bar from. The reason it had none was the trap recorded below: templating a tab dropped its `purpose`, and purpose is identity, so the CLI inlined the template into every tab instead. That trap turned out to be one missing line in the fork -- `parse_tab_node_with_template` read `name`, `cwd`, `focus` and `split_direction` off the tab node but never `purpose`, so the template's (None) won. It now keeps the tab's own, and both properties hold at once: | | inlined (before) | templated (before) | templated (now) | |---|---|---|---| | tab keeps `purpose` | yes | **no** | yes | | runtime tab gets a bar | **no** | yes | yes | So `sessionLayoutKdl` emits `default_tab_template` again, and the generated layout shrank further: one copy of the bar's configuration for the session rather than one per tab. `singleTabLayoutKdl` still inlines -- that layout is handed to a session that already has a template, and emitting another would give the new tab two bars. `sl0ppi-up-live.py` checks both halves, because fixing either one alone regresses the other: every tab (including one made at runtime) has exactly one bar, and `sl0ppi ls` still finds a tab per project. Confirmed by reverting each half in turn and watching the matching assertion fail. Counting bars needs care: `dump-layout` includes the template itself and every swap layout, so grepping it reports definitions as if they were panes -- that cost an hour of chasing a doubled bar that did not exist. Count panes per tab from `list-panes`, and do it BEFORE any client attaches (see the trap about what that hides). ### `sl0ppi up` and the generated layout Making the bar a `pane_template` broke `sl0ppi up` outright: ``` × Failed to parse Zellij configuration 43 │ sl0pbar size=3 · ╰── Invalid tab property: sl0pbar ``` The CLI writes its own layout file and inlines the tab template into each tab (a `default_tab_template` would destroy the tab purposes that are its identity), so a name the template body refers to has to travel with it. `extractPaneTemplates` does that now, and the generated layout is smaller than before as a bonus: one copy of the bar's configuration instead of one per tab. **Do not "fix" the collapsed bar in generated sessions by copying the user's `default.swap.kdl` next to the generated layout.** It looks right — at 70 columns the bar becomes one line — and then `auto_layout` swaps the tab to one of the other swap layouts, whose bare `tab` nodes expect a `default_tab_template` that a generated layout does not have, and the bar disappears completely. Measured on screen, both ways. So in a generated session the bar stays framed on a narrow terminal; the panes still collapse, via the synthesized stack layout. That measurement is also a warning about `action list-panes`: once a client is attached it stops reporting the borderless plugin pane, which had me believing the bar was gone in cases where it was on screen the whole time. `dump-layout` and the screen agree; `list-panes` does not. ### A tab made at runtime now gets a status bar Clicking the bar's `+` (and pressing `n` in TAB mode) produced a tab with no status bar in `sl0ppi up` sessions. The button only made it visible: a generated layout had no `default_tab_template`, so there was nothing for a new tab to take a bar from. The reason it had none was `parse_tab_node_with_template`, which read a tab node's `name`, `cwd` and `focus` but not its `purpose` -- so templating a tab erased the purpose that is the CLI's notion of identity, and the CLI worked around it by inlining the template into every tab. That is now fixed at the root (`sl0p_a_templated_tab_keeps_its_own_purpose`), and the CLI emits the template again: | | inlined (before) | template (now) | |---|---|---| | tab keeps `purpose=` | yes | yes | | runtime `new-tab` has a bar | **no** | yes | | copies of the bar's config in the layout | one per tab | one | Both halves are checked by `sl0ppi-up-live.py`, because they trade off against each other: it asserts `sl0ppi ls` still sees a tab per project AND that every tab, including one created at runtime, has exactly one status bar. Count bars per tab from `list-panes`, not by grepping `dump-layout`: that dump also contains the template itself and every swap layout, so substring counting there reports "4 bars for 3 tabs" and sends you looking for a doubling bug that does not exist. And do it before any client attaches, because `list-panes` stops reporting borderless plugin panes for a tab a client has rendered. ### Still open on the bar Content and further click targets: a close-`x` per tab, session name → session manager, mode indicator → unlock, drag-to-reorder tabs. Also: the pane-split `+` and the new-tab `+` are currently the same glyph for different verbs. ## Fixed: the vendored bun required AVX2 The tarball shipped bun's default x64 build, which needs AVX2 — so on a plain KVM guest sl0ppi installed cleanly and then died with `Illegal instruction` the first time anything ran pi. `packaging/fetch-vendor.sh` now fetches `bun-linux-x64-baseline.zip`. Verified without needing such a machine, under an emulated CPU that has no AVX2: ```bash qemu-x86_64 -cpu Nehalem vendor/cache/bun-*/bun --version # baseline: 1.3.14 qemu-x86_64 -cpu Nehalem /tmp/bun-linux-x64/bun --version # default: Illegal instruction ``` (That qemu-user binary is the one the tenet tracer builds, at `~/dev/tenet/tenet-original/tracers/qemu-user/qemu-build/build/qemu-x86_64`.) The baseline binary still contains 641 AVX2-only instructions against the default's 10980 — those are runtime-dispatched paths in bundled libraries, which is why running it is the check that matters rather than counting opcodes. ## Standing risks **`sl0ppij/target` is a symlink to `~/lol/zellij/target`.** Introduced when the box had 12G free, to reuse the old checkout's compiled dependencies instead of building a second multi-GB tree. `CARGO_TARGET_DIR` alone was not enough, because the debug-plugin `include!` path resolves relative to the source tree. This is the one thing tying the monorepo to the old checkout: **deleting `~/lol/zellij` leaves a dangling symlink and confusing build errors.** To cut the cord (costs one ~5min cold release build): ```bash rm sl0ppij/target && make build plugins ``` `sl0pstatus/target` is already a real directory (572M). **Release builds are slow by design.** `make build` is 5m16s here (LTO, `codegen-units = 1`); `make dev` is the ~5s iteration loop. Do not put `make build` in an edit cycle. That loop was 52s until the `fastdev` profile started splitting the crates we edit into 256 codegen units. zellij-server is one enormous crate, so with 16 units a one-line change re-optimised a sixteenth of it; the incremental cache can only skip work at codegen-unit granularity. Measured on the same one-line change, checkout to linked binary: 52s at `codegen-units = 16`, 6s at 256, 5s at 256 plus `opt-level = 1` (not worth a slower binary). Linking the 69MB binary is only ~2s, and lld did not improve on it -- the linker is not the problem here, and neither is opt-level. **pi version coupling.** `pi-ext/` targets pi's extension API, pinned at 0.84.1. Bumping the pin is an integration test, not a version bump. **The OSC 5577 protocol is unversioned.** Fork and extensions are currently kept in sync by being released together. If sl0ppij and pi-ext ever get installed independently, this needs a handshake.