aboutsummaryrefslogtreecommitdiffstats

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 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. 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):

# 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:

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 <config dir>/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:

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:

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:

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):

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.