Roadmap
Phase 0 — scaffolding ✅
- [x] monorepo with real history: 62 fork commits replayed into
sl0ppij/, 33 pi-ext commits replayed intopi-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
.wasmassets - [ ]
assets/zellij.desktop/zellij.rc, used only by distro/Windows packaging
Phase 2 — config becomes portable
- [x]
config/config.kdlrepointed at~/.config/sl0ppij/and thesl0ppij:plugin scheme; verified to load under the renamed binary - [ ] decide whether the opinionated defaults are also compiled in as
fallbacks, so a bare
sl0ppijwith no config dir still looks right
Phase 3 — prove the artifact ✅
- [x]
make packageend to end →sl0ppi-0.1.0-x86_64-linux.tar.gz(83M, 328M staged) + sha256 - [x] installed into a throwaway
HOMEon a minimal PATH withnode,npm,bun,cargoreplaced by poison stubs that fail loudly:sl0ppij,pi(0.84.1, on the bundled bun) andpirall 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]
muslis now the release target. Thegnubuild demandedGLIBC_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 nowstatically linked, with zeroGLIBC_symbols.stage.shfails loudly if a dynamically linked binary ever sneaks back in.Cross-compiling needs a musl C toolchain, because
isahc/curl drags inlibz-sysandlibnghttp2-sysunconditionally (not behindvendored_curl, so it cannot be feature-flagged away). We usecargo-zigbuild+ theziglangpython 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 infetch-vendor.sh. - [x] the tarball now ships thesl0ppiCLI too, bundled to a single 31K file bybun buildand 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 --compilepi into a single executable instead of shipping 183M ofnode_modules— but only if runtime-loaded.tsextensions 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:
zjstatuswas missing entirely. The config snapshot commit dropped it as "a third-party blob no longer referenced by config.kdl" — true ofconfig.kdl, false oflayouts/default.kdl, which builds the whole status bar out of it. It turned out to be a third fork (sl0pstatus/, now vendored) whose live.wasmwas a custom build. Sessions started with a plugin load error.install.shinstalled a hardcoded plugin list, so even once staged,zjstatus.wasmnever 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 tarballmake 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.shrefuses 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. BumpVERSION.- artifacts upload before pointers, so
latest.jsonnever 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:
-
Client ids were recycled instantly (
sl0p: stop recycling client ids). Every action probes liveness first (assert_socketconnects, sendsConnStatus, 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 queuedConnStatusremoval then deleted the action client, and its reply was dropped bysend_to_client, which returnsOk(())for an unknown id. Result: exit 0, no output, nothing logged. -
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_middlecomputed the right side only if the pane had a title -- andhide_default_pane_names truemeans unnamed panes have none;title_line_with_middledropped 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.wasmis 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 thenbinding 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.kdldefines the bar as apane_templatenamedsl0pbarand places it atsize=3;config/layouts/default.swap.kdldefines atab_template name="mobile"that places the same template atsize=1, and thestackedswap layout — the oneresponsive_layoutcollapses to belowresponsive_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.
