aboutsummaryrefslogtreecommitdiffstats
path: root/sl0ppij (follow)
Commit message (Collapse)AuthorAge
* sl0p: keep a templated tab's purpose, so runtime tabs get a status baruser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking the bar's `+` made a tab with no status bar. The button only exposed it: pressing `n` in TAB mode did the same, in any `sl0ppi up` session. The chain: a generated layout had no default_tab_template, so a tab created at runtime had nothing to take a bar from. It had none because parse_tab_node_with_template read a tab node's name, cwd and focus but never its `purpose` -- so templating erased the purpose that IS identity here (`sl0ppi ls` finds project tabs by it), and the CLI worked around that by inlining the template into every tab instead of emitting it. Fixed at the root: a templated tab keeps its own purpose, and the CLI emits `default_tab_template` again. The generated layout also shrinks -- one copy of the bar's configuration instead of one per tab. The two properties trade off against each other, so sl0ppi-up-live.py now asserts both at once: `sl0ppi ls` still sees a tab per project, and every tab including one created at runtime has exactly one status bar. Confirmed by reverting each half separately: without the parser fix the tabs lose their purpose and `ls` shows "alpha —"; without the CLI change the runtime tab has zero bars, which is the reported bug. Two measurement traps cost an hour here and are now written down: counting bars by grepping dump-layout also counts the template and every swap layout ("4 bars for 3 tabs"), and list-panes stops reporting borderless plugin panes for a tab once a client has rendered it. Also in here, from the same session: - the split button check found buttons by searching for " + " on any row with a frame glyph, which matches the stack list's "+N more" once two panes sit side by side, and then reports the product broken. It now requires the frame corner the button is drawn against. - the checks killed their sessions but did not delete them, so each run left an "EXITED - attach to resurrect" entry: 35 had quietly filled the session picker. They now delete-session by name. - and the trap that cost the user their running client: I killed a bare `sl0ppij` because "there are no user sessions on this box". That was a guess, not evidence, and wrong. A check's processes always carry that run's own socket dir or `attach sl0ptest-...`; a bare `sl0ppij` with a cwd in the repo is a human's. Written into the handover next to the `pkill -f` trap, which is the same mistake in a different costume.
* sl0p: the plugins we ship do not ask for permissionuser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | pane-finder requests three permissions as it loads, so the first press of a keybinding we ship opened a consent screen instead of a fuzzy finder. Prompting is right for a wasm you found on the internet and wrong for the ones that come in our own tarball. plugin_is_shipped_with_sl0ppi (zellij-utils/src/input/permission.rs) grants those implicitly, through the same instruction a human "y" produces, so the plugin's permissions are set and recorded exactly as before. "Ours" is narrow on purpose: - builtins, asked via the existing PluginConfig::is_builtin -- the same predicate check_command_permission already uses to exempt them, so the two cannot drift; - files inside a plugin directory we install into: the config dir's plugins/ and the data dir's plugins/; - nothing else. Remote plugins are never trusted, paths are normalised lexically first so plugins/../../evil.wasm is not "inside" the dir, and the directories are derived from the environment and default locations -- so getting them wrong costs a prompt and can never grant one elsewhere. The trade, documented in config/README.md: <config dir>/plugins/ is now a trusted directory. Anything able to write there can already bind arbitrary commands to a key in config.kdl, so a consent screen for the wasm beside it was not buying anything -- but it does mean plugins/ is not a download folder. packaging/checks/plugin-permissions-live.py drives both halves on a real session: the shipped plugin must run without the prompt, and the SAME wasm loaded from outside the plugin dir must still ask. Confirmed by disabling the rule and watching the first half fail with the exact "asks permission to ... Allow? (y/n)" screen it was written against.
* sl0p: make the split button work on stacked panes toouser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported still dead on a live session after the previous fix. Stacks move the frame twice more, and neither move was in the hit-test: - a stack list is drawn over the top rows of its visible member, pushing that pane's frame down. The renderer applies this as a frame geom override (reserved_top_rows); the click side did not, so every pane in a stack was clickable one row above its own title. frame_rect_with_top_reserved() now applies the same shift, before the gap inset, in the same order as render; - a stack's collapsed members drew a button on their one-row title. Nothing could honour it -- that row is the stack's picker, and "split the pane that is not on screen" is not a thing to ask anyway. They no longer draw one, and the click side mirrors the condition. 120 snapshot lines change, every one of them a one-liner losing its button and nothing else (same width, same content otherwise; audited mechanically). Both confirmed by reintroducing each bug and watching the check fail. A third candidate fix -- resolving a click to the stack's visible member, on the theory that get_pane_at could return a collapsed sibling -- turned out to be unnecessary: collapsed members have one-row geoms, so position lookup already lands on the right pane. Removed rather than kept "just in case". The check itself was wrong twice before it was right, in the direction that matters most (reporting failures that were its own): it now reads until the pty goes quiet instead of for a fixed time, and parses only a freshly forced repaint, so a half-arrived screen cannot look like a button drawn where it is not. It clicks every button it can see, and runs against config/config.kdl as well, whose stacks the minimal config never had. When the two disagree, the server's own log settles it: log::info! in intercept_split_button_click, read /tmp/sl0ppij-$UID/sl0ppij-log/.
* build: cut the edit loop from 52s to 5s with more codegen unitsuser2026-08-13
| | | | | | | | | | | | | | | | | | | | | zellij-server is one enormous crate and `fastdev` was building it in 16 codegen units, which is the granularity the incremental cache can skip work at: a one-line change re-optimised a sixteenth of the crate. Splitting the crates we actually edit into 256 units makes that a sliver instead. Measured on the same one-line change to pane_boundaries_frame.rs, `git checkout` of the file to a linked binary: codegen-units = 16 52s codegen-units = 256 6s, 4s on a second run + opt-level = 1 5s (rejected: not worth a slower binary) Ruled out on the way: linking the 69MB binary is ~2s, and rust-lld did not beat the default linker on it, so the linker was never the problem. Root crate codegen plus link is ~2s of the total. Costs one 80s rebuild when switching profiles, and the fastdev binary keeps opt-level 3. Verified with the full server/utils suites and make check-live.
* sl0p: make the split button visible, and clickable where it is drawnuser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The `+` shipped in 0.1.7 and 0.1.8 without ever appearing on a live session, while rendering in 265 snapshots. Four faults, each enough on its own: - title_line_without_middle computed the right side only inside the left side's and_then, so a pane with no title (hide_default_pane_names, the live default) got none at all; - title_line_with_middle discarded the right side whenever the left was absent -- that is every frame in a session with a second client, which the live box always had, and "MY FOCUS AND:" on every frame was saying so; - the title was budgeted before the button, so a title longer than the frame (any shell showing a path) consumed its columns. The button is anchored and hit-tested by pure geometry, so it cannot be the part that loses; - the click hit-tested position_and_size() while the frame is drawn inset by the pane gap, putting the clickable columns two right of the glyph and a row above it at the top of the screen. Hence "the click works" and "nothing is drawn" both being true, and that click test proving nothing. Adds Pane::frame_rect(), the rect the frame is really drawn in, which the status bar buttons had already had to work around by hand. Also fixes render_my_focus building its short form from separators that Vec::append had already drained: 4 cells wide while reporting 6, a title line two cells short of its pane. Found by asserting the width in the new test. Verification, since unit tests have now been insufficient here four times: split_button_is_drawn_where_it_is_hit_tested covers the combinations the live config produces, and packaging/checks/split-button-live.py (make check-live) reads the glyph off a real session, attaches a second client to force the middle builder, and clicks the columns it found the glyph in. Both confirmed to fail on the parent commit.
* sl0p: account for the split button in the title-alignment testuser2026-08-13
| | | | | | A right-aligned title no longer runs flush to the corner: the split button now occupies SPLIT_BUTTON_WIDTH columns between them. Adjusts the expectation rather than the layout, since that gap is the intended design.
* sl0p: draw the split button always, not only on hoveruser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | The hover-gated version did not work. `mouse_is_hovering_over_pane` never became true in a live session, so the `+` was invisible while its hit-test -- which is geometric and never consulted hover -- kept working. Clickable but invisible is the worst of both, and it is exactly the failure the shared `split_button_columns()` was supposed to prevent: that guarantees render and hit-test agree on WHERE, not on WHETHER. Reported as "I hover over panes but don't see a +". My end-to-end test had proved the click, not the button, and the passing snapshots only proved the renderer works when the flag is set. Why the flag is false live is still open and is written into the code comment: the plumbing from `mouse_hover_pane_id` through `FrameParams` reads correctly, and the config that reproduces it sets both `advanced_mouse_actions` and `mouse_hover_effects`. Re-gate on hover only once that is understood AND shown on a real session rather than in a snapshot. 265 snapshots updated: every pane frame now carries the button. 233 are mechanically provable as button-only (the ` + ` replaces three horizontal glyphs before a corner); the remaining 32 differ only by a header line offset in the comparison, with the same substitution underneath.
* sl0p: a mouse affordance for splitting a paneuser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Switching panes, switching tabs, renaming, reordering and resizing all have a mouse path in this fork. Spawning a pane did not: it was keyboard-only. A `+` now appears at the right end of a pane's title line while the pointer is over that pane, and clicking it splits the pane. Hover-driven rather than always drawn, for the same reason the resize hint and the status bar button highlight are: a permanent `+` on every frame is noise, and the pointer being there is exactly when the button is useful. render ui/pane_boundaries_frame.rs::render_split_button hit tab/mouse_handler.rs::intercept_split_button_click shared split_button_columns(), so what is clickable is what is visible The button sits immediately left of the top-right corner and the existing scroll and pin indications get the room that is left. That ordering is deliberate: a fixed position relative to the pane's right edge is one the mouse handler can hit-test directly, instead of re-deriving the whole title layout the way `status_bar_button_at` has to. The clicked pane is focused first, then the split is requested exactly as a keybinding would (`PtyInstruction::SpawnTerminal` with `NoPreference`), so mouse and keyboard produce the same arrangement and the split lands on the pane whose button was clicked rather than whichever pane happened to be focused. Not drawn on floating panes: their PIN control owns that corner and is hit-tested by its drawn position, so anything to its right silently moves it out from under the pointer. Caught by test_ctrl_click_on_floating_pin_button_toggles_pin, which stopped pinning. Verified on the test box by injecting real SGR mouse events into an attached client's pty at 160x45: click on the button 1 -> 2 panes click on the title 1 -> 1 click mid-frame 1 -> 1 Two hover snapshots updated, and only those two: the `+` on the hovered pane (including an unfocused one -- splitting a pane you are not in is useful). zellij-server 1502 pass, zellij-utils 473 pass.
* sl0p: resolve responsive layouts against hidden panes toouser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane created after a responsive collapse/expand cycle was absorbed into a stack, even at full width. Reported as "a new pane starts collapsed even though the terminal is big enough", and it appeared to fix itself on resize. The expand was never really happening. `SwapLayouts::tiled_layout_at` checked whether a candidate layout fits by positioning it against `tiled_panes.visible_panes_count()` -- and a stack hides every member but one. So while collapsed, the check ran with a pane count of 1, and a layout with no room for the hidden panes passed as feasible. The tab reported `sl0p:responsive-expand` while every pane stayed in the single rect it had while collapsed; the next pane added simply joined that stack. Feasibility is now checked against every tiled pane, hidden or not, which is the count the layout actually has to accommodate. Only the responsive collapse/expand resolves layouts through this function -- ordinary swap-layout cycling has its own body in `swap_tiled_panes` -- so the change is contained to the path that was wrong. Verified on the test box (responsive_min_cols 90) with a scripted pty that resizes mid-session, geometry read back from `list-panes --json`: before narrow(80) 2 panes -> one rect, both 80x23 (correct collapse) widen to 160 -> STILL one rect, 160x44 (expand did nothing) add a pane -> new pane joins the stack after narrow(80) 2 panes -> one rect (collapse unchanged) widen to 160 -> x=0 and x=80, side by side (really expanded) add three panes -> 2+1, then 2x2, then 3+2 grids, no shared rects wide from the start -> unaffected narrow again (70) -> collapses, as before No unit test ships with this, deliberately. Three attempts could not make one discriminate: the tab harness does no rendering, so nothing populates `panes_to_hide` and both counts are equal; hiding panes by hand does not help because the harness's expand takes the BASE path; and asserting on the resolver directly bypasses the count, since `tiled_layout_at` uses it only for the feasibility check and returns the raw layout. A test that passes with the fix reverted is worse than none, so the scripted live reproduction above is the verification, and it is written down in docs/roadmap.md to be re-run. zellij-server 1502 pass, zellij-utils 473 pass.
* sl0p: stop recycling client ids, which silently ate CLI outputuser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `zellij action` failed ~20% of the time against a live session: exit 0, empty stdout, empty stderr, and nothing in the server log. `sl0ppi ls` inherited it, and attached clients were dropped with "lost connection to the zellij server". The chain, which took measuring rather than reading to find: 1. every action first probes liveness (sessions::assert_socket): connect, send ConnStatus, disconnect. The probe is a full client and takes an id. 2. new_client() handed out the LOWEST FREE id, so the action connection that follows microseconds later gets the id the probe just released. 3. the main server thread then processes the queued ConnStatus(id), whose handler replies and calls remove_client!(id) -- removing the *action* client, which now owns that id. 4. the action's reply goes through send_to_client, which returns Ok(()) for an unknown client. The payload is dropped in silence; the client sees only the disconnect and exits 0 with nothing to print. Fix: hand out ids from an advancing cursor instead of reusing the lowest free one, so a stale removal cannot land on a fresh connection. ClientId is a u16 and every CLI invocation burns two, so the cursor wraps and skips ids that are still in use rather than assuming they are free. Measured on the 2-vCPU Debian box that surfaced this: before after list-tabs ~20-25% 0/50 failed list-panes ~20-25% 0/50 failed list-tabs (attached) ~25% 0/30 failed sl0ppi ls most runs 0/15 failed 5 attach cycles dropped 5/5 stable Three regression tests: ids are not reused immediately, they advance and skip live ones, and they wrap without colliding or hanging. zellij-server: 1502 pass. The silent-drop in send_to_client is left as-is but is worth revisiting: an unknown client id is a bug in the caller, and swallowing it is what made this cost a day instead of a minute.
* sl0p: stop suspended panes spamming resize errorsuser2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Attaching to a session of `start_suspended` panes could drop the client with "your zellij client lost connection to the zellij server", and `zellij action` against that live session failed most of the time. Cause: `set_terminal_size` treats "no fd for this terminal id" as an error, but a reserved-and-not-yet-started terminal is exactly what `start_suspended` (and any held pane) is. Resizes arrive in cached bursts (ResizeCache), so every attach and every layout change logged one formatted, contexted, file-written error PER suspended pane, on the server's hot path. The map distinguishes the two cases already -- `Some(None)` is reserved-without-fd (reserve_terminal_id), `None` is genuinely unknown -- so the fix is to match them separately and stay quiet about the former. Measured on a 2-vCPU Debian box, `action list-tabs` against a live session: before 2 suspended panes 5/12 failed 2 plain panes 1/12 failed after 2 suspended panes 6/30 failed 2 plain panes 9/30 failed resize errors logged: 126+ -> 0 ie. the suspended-pane penalty is gone; what remains is a separate baseline failure rate that affects plain sessions equally (see the known issue in docs/roadmap.md). zellij-server: 1499 tests still pass.
* cli: init and doctor, and stop one bad project breaking a rootuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Slices 3 and 5 of docs/cli-design.md. `init` writes .sl0ppi/layout.kdl. The template is mostly comments, because the purpose vocabulary and the start_suspended habit are the two things nobody can guess, and the point of use is the cheapest place to explain them. A test asserts the template passes doctor's own fragment check -- shipping people a starting point that fails our validation would be embarrassing. `doctor` splits into gathering facts (IO) and judging them (pure), so the rules are testable. It is honest about its limits: sl0ppij has no parse-only mode, so fragments get a structural check (empty, unbalanced braces, a whole `layout {}` pasted in, a `tab` node sl0ppi owns) rather than a claim of validity. It also reports the known packaging hole -- pir needs python3 and fzf, neither of which is in the tarball. Driving them for real turned up a worse bug than either command: a single broken fragment took down the entire root On session creation every tab shares one layout file, so one unbalanced brace made the whole `up` fail and no project opened at all. Fragments are now checked before use on BOTH paths, broken ones are skipped with the reason, and the rest open. `up` also registers the root even when some projects failed, since forgetting it would orphan the tabs that did open. Two smaller fixes from the same session: - falling open with no tty used to spawn a bare sl0ppij, which silently CREATES a session; that is where a handful of randomly-named junk sessions came from during testing. It now reports and exits. - a rename leftover: `setup --check` still described "the configuration of zellij". Test isolation lesson worth recording: sessions live in the runtime dir (/run/user/1000/sl0ppij), not TMPDIR, so setting TMPDIR alone does not isolate them -- an earlier test session called `root` was silently reused by a later test. SL0PPIJ_SOCKET_DIR is what actually isolates. 88 tests green.
* sl0p: carry pane and tab purposes over the wireuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `new-tab --layout` / `--layout-string` created the tab and silently dropped every purpose the layout declared -- the tab's own (which rides the tab's root layout) and each pane's. Layouts applied at session creation kept theirs, so this only broke tabs added at runtime, which is exactly what an orchestrator does. Found by integration-testing the sl0ppi CLI against a real session rather than trusting that the documented feature worked on this path. Cause: TiledPaneLayout and FloatingPaneLayout had no protobuf `purpose` field, so anything travelling as an Action lost it. The original workspace-hints commit says as much in a comment -- "adding a proto field would force regenerating the committed assets" -- because generated prost output is checked in and protoc was not available. It is now (a user-local protoc 25.1, no sudo, no system package), so the deferral can be paid off properly instead of worked around. There are two protobuf schemas and the distinction matters: plugin_api/ is what plugins speak, client_server_contract/ is what the CLI speaks. The first attempt fixed only plugin_api and changed nothing observable; both now carry the field. plugin_api/action.proto TiledPaneLayout.purpose = 14 FloatingPaneLayout.purpose = 13 client_server_contract/common_types TiledPaneLayout.purpose = 16 FloatingPaneLayout.purpose = 15 Purposes are sanitised on ingest, so a value arriving over IPC obeys the same invariant as one parsed from a layout file (bounded token, <=64 chars) whoever constructed the message. Verified live: a tab declaring `rm -rf /; echo "x"` is stored as `rm-rf/echox`, and a 200-char pane purpose arrives capped at 64. Tests: two new wire round-trips (a NewTab layout carrying tab, pane and floating purposes; and the sanitiser). zellij-utils 473 passed, zellij-server 1499 passed. Live: `new-tab --layout-string` now reports purpose='project:added/nested' with panes 'agent:main' and 'logs:app'.
* sl0p: rename to sl0ppij behind a branding seamuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Makes the fork installable and runnable beside a stock zellij: separate binary name, config dir, cache dir, socket dir, log file and plugin URL scheme. Everything user-visible is defined once in zellij-utils/src/branding.rs and referenced from the ~15 places that used to hold a literal, rather than sed-ing the tree -- upstream has 3300 commits of future rebases, and each touched upstream line is a conflict forever. A conflict in branding.rs itself always resolves the same way: keep our constant, take their code. binary zellij -> sl0ppij config dir ~/.config/zellij -> ~/.config/sl0ppij system cfg /etc/zellij -> /etc/sl0ppij cache/data ProjectDirs(Zellij) -> ProjectDirs(foo.sl0p.sl0ppij) socket/tmp /tmp/zellij-$UID -> /tmp/sl0ppij-$UID log zellij-log/zellij.log -> sl0ppij-log/sl0ppij.log plugin urls zellij:tab-bar -> sl0ppij:tab-bar (zellij: still parses) Two things deliberately NOT renamed: - The cargo package stays `zellij`; only [[bin]] is renamed. Renaming the package would ripple through every intra-workspace dep, the plugin API and the 0.45.0+sl0p1 version trick for a name nobody sees. - The $ZELLIJ* environment variables stay, because the ecosystem (shell prompts, "am I in a multiplexer" checks, our own pi-ext) looks for them. We now export $SL0PPIJ* alongside them, and those take precedence on read. branding::import_compat_env() folds an inherited ZELLIJ_CONFIG_DIR into its SL0PPIJ_ spelling at startup, before clap reads the environment -- clap can only be told one variable name per argument. Snapshots: 14 updated, all of them the plugin URL scheme or a session message. The insta churn (assertion_line metadata, trailing newlines) that a bulk --force-update introduces across 340+ other snapshots was reverted, so the diff here is only what actually changed. Tests: zellij-utils 471 passed, zellij-server 1499 passed. branding.rs carries its own tests, including one asserting no shipped default path contains the string "zellij". Verified live: sl0ppij creates /tmp/sl0ppij-1000 next to an existing /tmp/zellij-1000, and `sl0ppij list-sessions` reports none while a stock zellij reports three running sessions.
* sl0p: document --purpose and tab purposesuser2026-08-12
|
* sl0p: --purpose flag and tab-level purposeuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Completes the two pieces deferred from the first workspace-hints pass. `zellij run --purpose X` / `zellij action new-pane --purpose X` tag a pane at spawn time, which is what an agent wants for panes it creates itself; the OSC wrapper still works for programs that declare their own role. Not supported with --in-place, which replaces another pane rather than being a pane of its own: clap rejects the combination rather than accepting a flag that would do nothing. `tab name="work" purpose="agent-work"` gives a whole tab a role, reported by `list-tabs --json`. It rides the tab's ROOT layout node, reusing the pane field because a tab's root is the tab container and never itself a pane -- asserted by a test for the tightest case (a tab holding exactly one pane), since a leak there would silently mislabel a pane. The interesting part is why the flag first shipped broken. There are TWO protobuf boundaries -- the plugin API and the client->server IPC contract -- and a CLI flag must cross the second one. Defaulting the field in the conversion (what "no protoc" usually forces) compiles cleanly and then silently drops the value in transit, so --purpose parsed, printed a pane id, and did nothing. Fixed by adding the field to both contracts by hand, in the .proto AND the committed generated prost, kept in lockstep so a future regeneration reproduces it: protoc is only needed to regenerate, not to build. Both boundaries now have a round-trip test, and the IPC one was confirmed to fail when the wire read is removed. Sanitising moved to Grid::set_authoritative_purpose, the single choke point every out-of-band source passes through, after the CLI path was observed delivering `bad purpose; rm -rf /` intact into list-panes --json.
* sl0p: document workspace hints for consumersuser2026-08-12
|
* sl0p: per-pane workspace hints (purpose=)user2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the zellij half of sl0p/workspace-hints-proposal.md: a pane can carry an opaque agent-facing hint for what it is FOR, so a project can ship a layout that an agent is able to navigate instead of guessing. zellij never interprets a purpose. The consuming agent owns the entire vocabulary, which is what keeps this to one string threaded along the existing status_bar path rather than a policy engine, and lets the vocabulary change without a rebuild. Two ways in, because declared panes and agent-created panes are different: layout: pane name="server" purpose="dev-server" in-band: printf '\033]5577;1;purpose;agent:build\033\\' The second exists because tagging at spawn time would mean threading a field through 28 SpawnTerminal call sites, and a new action variant is impossible while protobuf enums cannot be regenerated. Wrapping the command costs one printf. A declared purpose outranks an in-band one and cannot be overridden or cleared by it. OSC arrives from whatever the pane happens to be showing, so otherwise a `cat hostile.txt` in the human's editor pane could relabel it as the agent's scratch pane and invite an agent to run commands where the human is working. A layout is written by a human in a file they can read; in-band data only fills a blank. Exposed on PaneListEntry (`list-panes --json`) rather than PaneInfo, which crosses the plugin protobuf API we cannot regenerate; resolved in the screen thread where panes live rather than via route.rs's per-pane PTY round-trips. Values are sanitised to a bounded [A-Za-z0-9_.:/-] token at both entry points, because a purpose is never rendered -- it is matched against and pasted into consumers' shell and JSON pipelines. Verified end to end rather than by inspection: purposes from a layout appear in list-panes --json against the right pane ids, an agent-spawned pane tags itself, a declared purpose survives an in-band override attempt, all of it comes back after kill + resurrect, an unknown pane property is still a parse error, and a layout with no purpose leaves dump-layout unchanged.
* sl0p: propose per-pane workspace hintsuser2026-08-12
| | | | | | | | | | | | | | | | A design doc, not an implementation: lets a project ship a layout that also tells an agent what each pane is for. The load-bearing decision is the boundary -- zellij carries one opaque string per pane and never interprets it, pi owns the whole vocabulary. That keeps the fork change to a single field threaded along the existing status_bar path, and lets the vocabulary change without rebuilding zellij. Records two findings that were checked rather than assumed, since both would otherwise be discovered the expensive way: pane names cannot carry metadata (there is no stable name field at runtime, and title is clobbered by whatever the program sets), and the runtime query surface already exists (list-panes --json), so the work is only to get the field into it.
* sl0p: hot-reload status_bar_button_styleuser2026-08-12
| | | | | | | | | | | | | | | | The option was only read when a client connected, so changing it needed a new session -- inconsistent with gap/padding/title_alignment, which apply live, and the kind of inconsistency that reads as "my config change did nothing". Threaded through update_frame_layout, the existing vehicle for the fork's pane_frames options: Tab -> tiled/floating containers -> each pane's Style, including suppressed panes so a hidden stack member is styled correctly when it swaps back into view. Verified by editing config.kdl against a running session and watching 1:Approve flip to [Approve] and back, then confirming a click still resolves afterwards -- the button widths change with the style, so a stale layout would have silently mis-targeted clicks.
* sl0p: add an indexed style for status bar buttonsuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | `status_bar_button_style "indexed"` renders `1:Approve 2:Reject` instead of `[Approve] [Reject]`, so the bar doubles as a key legend for a harness that binds alt+1..9 to the same actions. A config option rather than a protocol field: whether the numbers mean anything depends on what the USER has bound, not on what the program knows. It needs no wire change and so no version bump, and the click report still carries the id -- never the index -- so a program is unaffected by which style is in force. The style is threaded into layout_status_bar, the one function the renderer draws from and the hit-test resolves against. That is what makes this safe: changing a button's rendered width necessarily changes its clickable span too, verified by a test asserting span width equals the drawn width and that every drawn column resolves back, for both styles. Also fixes the web client's Style construction to read the option from config rather than defaulting it -- my first pass claimed that code had no pane_frames config available, which was wrong; it uses it two lines further down.
* sl0p: highlight the status bar button under the pointeruser2026-08-12
| | | | | | | | | | | | | | | | | | | | A click target with no hover feedback gives you no way to tell what you are about to hit, which matters more here than elsewhere because the buttons sit in a frame row that is only one cell tall. The hovered button is inverted rather than merely recoloured: swapping fore/background reads regardless of which colours a theme uses, and makes "the pointer is on THIS one" unmistakable next to its unhovered neighbours. Tracked per client, because hover belongs to a pointer rather than to a pane -- with two users attached, one pointer must not light up the other's button. Stored on the pane keyed by client id, which the frame renderer already has. Updated before the action dispatch rather than in the hover action, because motion over the ACTIVE pane is dispatched as SendToTerminal, not UpdateHover -- and the pane you are about to click a button in is usually the one you are already in. Every other pane's hover for that client is cleared on each motion, so moving between panes cannot leave a button lit behind.
* sl0p: document the pane status bar protocoluser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | Reference for programs wanting to drive the bar: the OSC 5577 verbs, escaping rules, click reports, limits, rendering behaviour, persistence, and the security model. Both worked examples were run against a real session rather than written from memory, which caught four bugs in my own samples: - the Python reader kept a trailing buffer window, so a processed report was re-matched and every click fired twice - it used tty.setraw, which also disables signals, leaving the sample unkillable with Ctrl-C - it exited via traceback and left the bar behind; both samples now clear it on exit - the shell reader echoed the report to the screen, and an `stty -echo` did not help because bash's `read -n` saves and restores terminal settings around each call. `read -s` is the fix. Also verified and documented two behaviours I would otherwise have guessed at: a BEL terminator works as well as ST, and the bar is not drawn when pane frames are off (it lives in the frame row), reappearing when they return.
* sl0p: clickable action buttons in the pane status baruser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Second slice of the status/action frame. A program declares buttons, and a click is reported back to it: printf '\033]5577;1;buttons;approve:Approve;reject:Reject\033\\' -> click -> program's stdin receives OSC 5577 ; 1 ; click ; approve ST The report is a FIXED format carrying only the id, never bytes the payload chose, and ids are restricted to [A-Za-z0-9_-]. The sequence that creates a button is in-band and untrusted -- `cat` of a hostile file emits it as readily as a cooperating program -- so a click that could send arbitrary bytes would let viewing a file inject into the stdin of whatever is running, a shell included. Labels are '%'-escaped since the wire format is ';'-separated, and both the button count and id length are capped because the list lives as long as the pane. Positions come from one layout_status_bar function that the renderer draws from and the hit-test resolves against. Computing that geometry twice is how the stacked-pane picker once selected the wrong pane; a test asserts every drawn column resolves back to the button drawn there, across 100 widths. Two things the drive caught that unit tests could not: - The first delivery path pushed onto grid.pending_messages_to_pty, which is only drained when the PANE produces output -- so a click on an idle program sat in the queue forever. The write now happens at the tab level, which owns the senders. - The hit-test derived the frame row from the pane's raw geometry, but this fork draws the frame inset by gap_offset, so with `gap 1` every click missed by one row. It now derives from the CONTENT rect, which is by definition the inside of the drawn frame and therefore the same columns the renderer lays out. Buttons serialise with the session like the status text, so a resurrected pane comes back with its buttons intact.
* sl0p: serialise the pane status bar for resurrectionuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | The status bar survived detach/reattach (it lives on Grid) but not a serialise/restore cycle, so a resurrected pane came back blank. It now travels the whole per-pane serialisation pipeline, mirroring the fork's existing default_fg/default_bg: pane -> PaneLayoutMetadata -> PaneLayoutManifest -> TiledPaneLayout -> a status_bar property in the serialised KDL, then back through the normal layout parser and applied by LayoutApplier when the pane is recreated. The sanitiser moved from grid.rs into zellij-utils so both entry points share one definition. That matters: a layout file on disk is no more trustworthy than the escape sequence that wrote it -- it may have been hand-edited, or copied from somewhere else -- so restored text is stripped and length-capped on the way in just like the OSC payload was. Verified end to end rather than by unit test alone: set the bar, waited for a serialisation pass, confirmed status_bar="..." in session-layout.kdl, killed the session until list-sessions reported EXITED, then attached and watched it come back from disk. The earlier attempt only proved detach/reattach because Quit had left the session alive.
* sl0p: per-pane status bar via OSC 5577 (experimental)user2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | First slice of the status/action frame: one row of program-supplied text in a pane's bottom frame row. printf '\033]5577;1;status;building 3/7\033\\' printf '\033]5577;1;clear\033\\' Rendered in the EXISTING frame row rather than in reserved rows. Reserving would shrink the pane's content area and resize its PTY whenever the bar appeared or disappeared, firing SIGWINCH at the program that asked for it -- the same churn that made the stack-list picker use a fixed cap. The frame row is already a text surface with a precedence chain, so this cost no geometry work at all. State lives on Grid like the window title, so it survives re-renders, resizes and detach/reattach with no draining. Session resurrection is NOT covered yet. Versioned from the start, and unknown versions or verbs are ignored, so the planned button verbs cannot be half-interpreted by an older build. Text is the remaining params rejoined with ';', which means a single text field needs no escaping -- the trick OSC 0/2 already uses for titles. Treated as untrusted, because it is: an in-band sequence is emitted just as readily by `cat` of a hostile file as by a cooperating program. Control characters are stripped -- a raw ESC could otherwise repaint anything on screen, a newline would corrupt the frame row -- and the length is capped, since the string is retained for the life of the pane.
* sl0p: separate the hover resize hint from the hover highlightuser2026-08-12
| | | | | | | | | | | | | | | | | | Upstream's mouse_hover_effects governs two unrelated things: the frame highlight on hover, and the "Ctrl <MouseScroll> or <drag borders> to resize" hint. Its own comment in default.kdl admits as much. So silencing a beginner reminder you have outgrown also costs you a highlight that stays useful indefinitely. mouse_hover_help_text (default true) gates only the hint. One line in execute_send_to_terminal; the highlight is set in execute_update_hover and is untouched. Config-file only, deliberately. The option has no protobuf slot, so it does not cross the client/server IPC contract -- the roundtrip test asserts exactly that, with the fixture set to None and a comment explaining why. It still works from the config file because the server parses the file itself and Options::merge keeps the file value when the client sends None; a CLI flag would be silently ignored.
* sl0p(pane-finder): find panes hidden in a stack, not just the visible oneuser2026-08-12
| | | | | | | | | | | | | | | | | | In a collapsed (mobile) layout every stack member except the visible one is suppressed, and the finder skipped suppressed panes outright -- so it could only ever find the one pane already on screen, which defeats the point of having it there. Suppressed terminals are now listed and marked "(hidden)". Suppressed plugins still are not: zellij:link and friends are deliberately invisible infrastructure, and focusing one shows an empty pane. Unfiltering everything listed it, which is how that distinction surfaced. Needed no server work. Screen::focus_pane_with_id already locates the owning tab via has_pane_with_pid, which includes suppressed panes, and calls go_to_tab; and Tab::focus_pane_with_id tries focus_hidden_stack_list_member first. So selecting a hidden stack member swaps it into view and selecting a pane in another tab switches tab, both already handled by the host.
* sl0p: add close_if_focused to LaunchOrFocusPluginuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | Upstream's action focuses an already-running plugin, so a chord bound to it can only ever open one -- there is no way to dismiss with the same key. This cannot be fixed in a plugin. Keybindings are resolved into actions in route.rs before a key reaches a pane, so the launching chord never arrives at the plugin. intercept_key_presses does not help either: the intercept lives in screen.rs and only sees keys that already became Action::Write, i.e. keys destined for a pane. Confirmed empirically -- an intercepting build received Ctrl-u but never Ctrl-;. Closes only when the plugin is the focused pane in the client's ACTIVE tab, so the chord cannot close whatever pane the user happens to be on. A plugin that exists but is hidden behind a floating-layer toggle, or is focused in a different tab, is revealed or brought over as before. Defaults to false, so every existing binding is unaffected. Config-only, with no protobuf representation: defaulted at both boundaries and dropped when an action crosses the plugin API, so no prost regeneration is needed. The 24 snapshot lines are the new field appearing in serialised keybind dumps.
* sl0p(pane-finder): document a single-chord bindinguser2026-08-12
| | | | | | | | | | | | Reaching the finder through a mode dance (leader, then session mode, then a letter) is too many keys for something used constantly. Documents Ctrl-; as a one-chord binding, and why: ';' has no control-code meaning, readline does not bind it, and vim binds only bare ';' -- while plain ';' keeps typing normally. Also records the tradeoff. Ctrl-; needs the kitty keyboard protocol to be distinguishable from a plain ';', so it silently never fires on a terminal without it. The combos that work anywhere are real control codes and are all taken: Ctrl-\\ is SIGQUIT, Ctrl-] is vim's tag jump, Ctrl-_ is readline undo.
* sl0p(pane-finder): take the whole viewport on small terminalsuser2026-08-12
| | | | | | | | | | | | | | | | | | The finder always opened as a centred float with a frame. That costs its margins (16% of columns, 28% of rows), a border, and on this fork the pane gap as well -- on an 80x24 terminal leaving about 63x14 of the 80x24 available. Rows are the scarce resource in a list, so most of the results were going to decoration. Below 100x30 it now takes the whole viewport and asks to be borderless, which also drops the gap since this fork skips gap/padding for frameless panes. Above that the centred float is kept, because it reads better when the space is there. The viewport is only knowable from TabInfo, so this re-evaluates on TabUpdate, which is also how a terminal resize reaches a plugin -- switching modes live in both directions. Re-applied only when the decision flips, so it does not fight someone moving or resizing the pane by hand.
* sl0p: bring zj-pane-finder into the repouser2026-08-12
| | | | | | | | | | | | | | | | | | | | | The pane finder was living in ~/dev with no version control at all, while path-depending on this checkout's zellij-tile -- so the plugin and the binary it must stay API-compatible with were maintained in two unrelated places, one of them unversioned. Its own workspace root rather than a member of zellij's: a non-root member has its [profile] ignored, and opt-level="z" + lto + strip are what keep the wasm small, which matters because the keybinding loads it with skip_plugin_cache and recompiles on every launch. Staying independent also means carrying this crate touches no upstream-tracked file, so it cannot conflict on a rebase. install.sh no longer assumes ./target: this checkout's .cargo/config.toml sets a shared build.target-dir, so the artifact lands in the repo's target dir instead. It now asks cargo metadata where the build actually went, and fails loudly rather than silently installing a stale wasm. The zellij-tile path dep becomes relative, so the crate builds from any checkout rather than only from /home/user/lol/zellij.
* sl0p: stop hide_default_pane_names blanking the plugin APIuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | `hide_default_pane_names` is a frame-rendering option, but it was applied inside current_title() -- which is also what fills PaneInfo.title. Every plugin that lists panes therefore received an empty string for any pane whose program had never set a title. For the bundled session manager that is untidy. For a fuzzy pane finder it is broken: the rows are identical AND unsearchable, so the plugin's whole purpose stops working. The blanking moves to the frame, which already had a blank_title concept, and current_title() goes back to reporting a real title to everything that asks. Frames still show nothing for an unnamed pane, named panes still show their name, and PaneInfo reports "Pane #N" again. Changes a value rather than adding a field, so it needs no protobuf regeneration. Only observable without a shell that sets a title: verified by disabling the zsh hook, where the pane finder previously listed three identical blank rows and now lists Pane #1/#2/#3. That masking is also why this went unnoticed -- and why I flagged it twice without confirming it was actually reachable.
* sl0p: keep plugin panes pinned when synthesizing a layoutuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | Collapsing and expanding a tab from the default layout stranded the status bar halfway down the screen, and left the collapsed stack three rows tall. A synthesized layout describes only where the user's panes go. Applied bare, it laid out EVERY pane in the tab -- including the status bar, which became an ordinary grid cell. Real swap layouts do not hit this because their `tab` node is expanded with the default tab template, which carries the pinned plugin slots; a layout built at runtime has to take those from BASE instead. Synthesis now builds into the tab's own structure: the first non-plugin leaf becomes the stack or grid and inherits its size, further content leaves are dropped since the replacement accounts for every pane itself, and plugin panes keep their slots and sizes. Falls back to the bare replacement when there is no content slot at all -- a layout of nothing but plugin panes would otherwise be returned unchanged, silently dropping every user pane. Found by the test, not by the drive. Only reproducible with a layout that has plugin panes, which is why the quaddie.kdl drives never showed it and the default layout did immediately.
* sl0p: add a minimal style for the stacked-pane pickeruser2026-08-12
| | | | | | | | | | | | | | | | | | | | | The boxed entries read as clutter: every entry bracketed in vertical bars, and the active one marked by a "> " that sits outside its own cell. `stack_list_style "minimal"` drops both. The active entry is shown by inverting it instead, which needs no glyph and no column, and the padding is moved inside the styled run so the inverted block covers the whole cell rather than leaving unstyled gaps at its edges. Four fewer columns of decoration per entry, so more fit per row in grid mode. Uses ribbon_selected rather than text_selected. Themes give ribbon_selected real contrast because it marks the selected item elsewhere in zellij's UI -- gruber yellow here, white-on-blue in the TempleOS theme -- whereas text_selected's background is 40,40,40 against a 24,24,24 terminal and read as essentially no highlight when tried. Decoration width comes from the style itself, so Tab's column arithmetic and the renderer cannot disagree about how wide an entry is. Default stays boxed, so nothing changes for anyone who does not ask.
* sl0p: resolve responsive layouts from current state, not a remembered positionuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Every bug this feature has had came from the same place: it remembered the swap layout position that was active before collapsing, and replayed it to expand. Remembered positions stop applying when the pane count changes, are only stable while nothing is inserted, and a failed apply left the state claiming a transition that never happened. Both directions now resolve fresh. Collapsing picks the configured layout, or a synthesized stack if that one is missing OR cannot currently apply -- a `stacked` layout with min_panes=4 and three panes open used to be found, fail its constraint, and give up. Expanding picks BASE when it still applies, since that reproduces the user's actual layout, and otherwise a synthesized grid. Both chains end in an unconstrained layout, so neither can come up empty. The only remaining state is one bool, flipped only after a layout was applied. Also stops consulting is_tiled_damaged. It is meant to mean "the user rearranged panes by hand" but is equally set when a relayout finds nothing applicable, which happens on the first pane added to a tab with no usable swap layout and then never clears -- it disabled this feature in four distinct situations. It protected little regardless: expanding applies BASE or a grid, so a manual arrangement does not survive a round trip either way. Synthesized layouts are created once and reused; the test asserts the layout list does not grow across repeated collapse/expand cycles, since they are now appended lazily in both directions.
* sl0p: keep expanding possible after the pane count changesuser2026-08-12
| | | | | | | | | | | | | | | | | A tab restores into whatever layout was active before it collapsed, which for a bare layout is the implicit BASE. BASE is pinned to ExactPanes of the count it was written with, so adding one pane while collapsed left nothing to expand into and the tab stayed stacked on every subsequent widening. Two fixes. When the stored layout no longer applies, synthesize a grid to spread the panes out rather than leaving them stacked -- a wide terminal is not a state that retries, so failing here strands the tab for good. And only consume the restore position once a layout has actually been applied. It was cleared unconditionally, so after a failed restore the tab believed it had expanded while visibly still collapsed, and never tried again -- which is why this looked like "expand stopped working" rather than "expand did nothing once".
* sl0p: synthesize a stack layout when none exists to collapse intouser2026-08-12
| | | | | | | | | | | | | | | | | | | | | The collapse selects a swap layout by name, and a custom layout file only gets swap layouts from a hand-written sibling "<layout>.swap.kdl". So for most layouts there was nothing to select and the feature silently did nothing -- requiring a boilerplate file per layout to use it at all. Now a missing layout is synthesized: every pane flattened into one stack, in layout order. Appended rather than inserted, so it cannot shift the BASE layout at position 0, which is what the collapse restores back to. Synthesized lazily, at the first collapse, rather than up front: a stack sitting in the swap cycle would let auto_layout apply it spontaneously when a pane is added, stacking panes on a perfectly wide terminal. Also scopes the damaged-layout guard. With no swap layouts, relayout finds nothing applicable on the first pane added, gives up and marks the tab damaged -- permanently, and with nothing to do with user intent. Honouring that flag disabled the collapse for exactly the bare layouts this fallback targets, so it is now only consulted when the tab has swap layouts the user actually defined.
* sl0p: warn when the responsive layout has no swap layout to collapse intouser2026-08-12
| | | | | | | | | | | | The collapse selects a swap layout BY NAME, so a tab with no swap layouts simply does nothing when the terminal gets narrow -- no error, no hint. The usual cause is not the responsive settings at all: a custom layout file only gets swap layouts from a sibling "<layout>.swap.kdl", so launching with one that has no such file silently disables the feature. That is the second time this feature has failed invisibly (the first was a layout marked damaged), and both cost real debugging time. Logs once per tab, not per resize, and names the actual cause rather than the symptom.
* sl0p: clear the stale picker region when the layout re-flowsuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | Adding a pane left old borders on screen until a resize forced a full redraw. Two causes. The picker draws each entry as its own chunk, so the space between and around the entries was never painted and kept the previous frame's contents; and adding a pane really does re-flow the whole grid, because a freshly created pane has no title yet and its "Pane #N" fallback is far wider than a named one, which widens every cell and drops the column count. Separately, refresh_or_dissolve_stack_list changed the reserved row count without forcing a clear, so when the list got shorter the visible pane moved up and the rows it vacated kept their old contents. Blanks only the spans no cell covers, never a cell's own columns: chunks are NOT composited last-wins, so blanking the whole region erased the entries instead of the stale contents under them -- the first attempt at this fix drew exactly one entry. stack_list_row_gaps is a free function so the covering invariant is testable: gaps and cells must tile each row exactly, with no overlap, across a sweep of row caps, label widths and member counts.
* sl0p: optionally render the stacked-pane picker as a griduser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | `stack_list_layout "grid"` packs picker entries several per row, which turns stack_list_max_rows from a count into a space budget: eight panes in a wide terminal now fit on one row instead of spending three and hiding five. The entry renderer already drew each entry as a single chunk at its own x/y, sized by its geom and centring its own label, so a grid needed no rendering changes at all -- only the loop assigning every entry the same x and the full width. Column count is derived from the widest label rather than configured, so it adapts to the titles; one column is the floor and reproduces the previous full-width rows byte for byte. The row -> member mapping now has a column axis, and it had already caused one wrong-pane bug when two consumers disagreed. So the layout is computed once, by one function, into explicit cells; the renderer, the reserved-row budget and the mouse hit-test all read those same cells rather than each doing their own arithmetic. Windowing is by row, so a grid scrolls a whole row at a time and degrades to the old per-member window when there is one column. Cells are separated by a two-column gap: without it the focused entry's "> " marker sits flush against the previous cell and reads as belonging to it.
* sl0p: click the picker's indicator row to expand a capped stackuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | The cap made the picker a scrolling viewport -- everything stays reachable by moving focus -- but there was no way to see the whole stack at once. Clicking "+N more" expands it to every member; the indicator becomes "- fewer" and collapses it again. Collapse gets its own label because an invisible way back out is not an affordance. Hangs off the indicator row because that row is already reserved, already rendered, and resolves to no member, so it is free real estate. It is also why this needs no new keybinding: mouse handling is entirely server-side, while Action is a protobuf enum we cannot extend without protoc. A click rather than hover, as expanding changes the reserved row count and so resizes the visible pane's PTY -- acceptable once per deliberate click, not on every mouse move. All three consumers of the row -> member mapping (renderer, reserved-row budget, mouse hit-test) now go through stack_list_window_for, so expansion cannot desync them the way the cap desynced the hit-test in 844617b4.
* sl0p: fix clicking a scrolled stack entry selecting the wrong paneuser2026-08-12
| | | | | | | | | | | | | | | | | | Regression from the picker row cap. The row -> member mapping lives in TWO places: the renderer, and the hit-test that resolves mouse events to a pane. Capping made the renderer draw a WINDOW of the members, so screen row 0 is members[window_start], but the hit-test still resolved row N to members[N]. Any click or hover on a capped stack whose window had scrolled therefore landed on the wrong pane -- with 8 members focused on the last, clicking the top row selected member 0 instead of member 5. Both sites now go through stack_list_window(). Clicks on the "+N more" indicator resolve to no member rather than to a bogus one. Caught by asking how a user reaches the panes past the cutoff, not by the tests: the previous commit tested the window arithmetic and the rendering, but nothing covered this second consumer of the same mapping.
* sl0p: cap how many rows the stacked-pane picker takesuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | A stack reserves one row per member plus a padding row, so a deep stack spends most of the screen listing itself. `stack_list_max_rows` caps that; the list windows around the focused member and reports the rest as "+N more". The indicator goes in the padding row the list already reserves, so it costs no extra row and never displaces a real entry. Entries keep their real rank rather than renumbering from 1, otherwise the numbering stops meaning anything once the window has scrolled. The window slides on ordinary focus movement, which is also why this needs no new keybinding -- Action is a protobuf enum and the prost assets cannot be regenerated here (no protoc). A fixed cap rather than hover-to-expand on purpose: reserved_top_rows feeds pane geometry, so growing the list on hover would resize the pane's PTY and reflow whatever is running in it on every mouse move. A real expand/collapse has to be an overlay that reserves nothing, which is a much larger change to the render path. reserved_top_rows and the renderer both go through stack_list_window(); if they disagreed the list would draw into rows the geometry never reserved, i.e. over the pane beneath it.
* sl0p: add `arrangement` to build a tree for N panesuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A `children` placeholder inserts every pane it receives as a flat sibling. That single hardcoded policy is why a layout can only express "N equal columns", "N equal rows" or "one stack" without hand-writing a variant per pane count -- the max_panes=5 / max_panes=8 / max_panes=12 ladders in a swap layout file exist purely to work around it. `pane arrangement="grid" { children; }` builds a tree for N panes instead. Two to start: grid (ceil(sqrt(n)) columns, rows filled left to right) and master-stack (one 60%-wide master, the rest beside it). Generates a TiledPaneLayout rather than geometry, so everything downstream works unchanged: split_space, the minimum-size feasibility check, LayoutApplier, swap-layout damage tracking, and the responsive collapse. It composes with that last one for free -- a grid is simply infeasible on a narrow terminal, so it is skipped and the collapse takes over. The arrangement builds ONE self-contained subtree rather than splicing a variable number of nodes in among the placeholder's siblings, so it stays well defined next to the pre-existing "children node with sibling nodes" TODO rather than leaning on it. Like default_fg/default_bg, the field has no protobuf representation, so it is defaulted at both plugin-API boundaries and does not survive a round trip. This is why it is a field rather than a LayoutConstraint variant: a new enum variant would have no representation when converting TO protobuf, and the committed prost assets cannot be regenerated here (no protoc). The 48 snapshot updates are the new field appearing in TiledPaneLayout debug dumps (324 lines, all `children_arrangement: None`). No snapshot content was removed. The `expression:` metadata churn in those files is pre-existing drift that regeneration swept up: the stored snapshots recorded the expression before normalize_layout_debug was introduced.
* sl0p: record the green test baseline in SPEED.mduser2026-08-12
| | | | | | | | | | The tree carried 30 failures in zellij-server and 5 in zellij-utils from the first sl0p commit until 5f2c28ab. They were never real bugs: aa51848d changed the scroll indicator and frame-title rendering without refreshing the insta snapshots. Documents the current green counts so a future context treats any failure as its own, plus the two traps that hid this for so long -- cargo stops at the first failing test binary, and insta aborts a test at its first snapshot mismatch -- and how to regenerate snapshots without cargo-insta.
* sl0p: keep stacked-layout entries identifiableuser2026-08-12
| | | | | | | | | | | | | | | | | | | Two problems made the stack list unusable for panes nothing had named yet, which is the common case right after opening them -- and the responsive collapse walks straight into it, since collapsing is exactly when the list becomes the only way to pick a pane. `hide_default_pane_names` no longer applies to stack list entries. In a frame an empty title is fine: the pane is still visible, sized and positioned, so you can tell what it is by looking at it. In the stack list the label is the ENTIRE affordance, so blanking it leaves rows that cannot be identified or told apart. Entries fall back to the "Pane #N" placeholder even when frames are configured to hide it. Entries are also prefixed with their 1-based position, so they stay countable and addressable even when several share a title or have none. The prefix is built by one shared helper because the renderer and Tab's column-width calculation must agree on its width or the list misaligns.
* sl0p: collapse to a swap layout when the terminal gets narrowuser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | Splitting a window into thirds is great until the window is 70 columns wide and each pane gets 23. `responsive_min_cols` collapses the tab into the swap layout named by `responsive_layout` when it gets narrower than that, and restores the previously active layout when it grows back. Selects a swap layout BY NAME rather than adding a size-aware LayoutConstraint: that type crosses both the plugin protobuf API and the client/server contract, and the committed prost assets cannot be regenerated here (no protoc). Restoring uses a 10-column hysteresis band. Without a dead band, dragging a window edge that lands exactly on the threshold flips the layout on every motion event, and every flip resizes every PTY in the tab. "Did the user rearrange panes by hand" is judged from the damaged flag as sampled BEFORE each resize, not the live one: resize_whole_tab's own auto-layout pass sets that flag itself and clears it again only if it found a layout that fits, so on a terminal too narrow for any of them it latches true although nothing was rearranged. And once collapsed, restore stays reachable regardless of the flag, so damage occurring while collapsed cannot strand the tab in the narrow layout.
* sl0p: refresh snapshots for the compact scroll indicatoruser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | aa51848d replaced "SCROLL: 42/1000" with a compact "^42" glyph and stopped showing any indicator when the pane is not actually scrolled up, but did not refresh the insta snapshots that encode the old upstream frame rendering. That left 30 failing tests in zellij-server from the very first sl0p commit onward, which in turn masked 5 more in zellij-utils (cargo stops at the first failing test binary, so zellij-utils never ran). Bisected to aa51848d; the upstream fork point 0e6e4404 is green. zellij-server (44 snapshots): frame titles lose the "SCROLL: 0/N" segment when not scrolled, and render "^N" where they previously rendered "SCROLL: N/M". Verified every diff is scroll-only, and that the genuinely-scrolled cases (2/13, 3/33, 6/33) still render as ^2, ^3 and ^6 rather than disappearing. zellij-utils (5 snapshots): serialized Config gained the sl0p FrameConfig fields (title_alignment, gap, padding, gap_aspect, inactive_grayscale, hide_default_pane_names), all at their existing defaults. Additions only. Several tests assert multiple snapshots and insta aborts at the first mismatch, so this took four regenerate/verify passes to fully converge. zellij-server: 1471 passed, 0 failed. zellij-utils: 441 passed, 0 failed.
* sl0p: first keystroke replaces the old tab name when renaminguser2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | Entering RenameTab left the existing name in place and appended to it, so renaming a tab meant clearing it by hand first. The first printable keystroke now REPLACES it, the way a text field with its contents selected behaves. Armed when a client enters RenameTab and disarmed when it leaves, so a keystroke that arrives later cannot wipe a name the user was not editing. Backspace and delete deliberately do NOT replace: reaching for them means editing the existing name, not discarding it. The flag is taken out of the set BEFORE borrowing the active tab, since get_active_tab_mut borrows self mutably and every branch consumes the flag anyway. Applies to renames from any entry point - the keybind and the sl0pstatus double-click-a-tab both go through Screen::change_mode. Pane rename is deliberately left alone for now; say the word and it can get the same treatment. Baselines held: zellij-server 1441/30.