aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAge
* sl0p: document --purpose and tab purposesHEADmainuser2026-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-11
| | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | | `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-11
| | | | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | `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-11
| | | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | 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-11
| | | | | | | | | | | | | | | | | | | | | | | | 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-10
| | | | | | | | | | | | | | | | | | | | | | | | | | 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-09
| | | | | | | | | | | | | | | | | | | | | | | 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.
* sl0p: preview pane reordering live while dragginguser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Dragging showed only a green border on the target. The swap is now applied as you drag, so the real layout IS the preview and releasing just keeps it. Dragging elsewhere undoes it and previews the new arrangement; dragging off all panes restores the original. Because swap_panes is symmetric, applying it twice is the identity, so 'undo the preview' is just the same call again. Drop needs no work at all now - the swap already happened - which also removes the old drop-time swap. THE TRAP, which a naive version hits immediately: once the swap is applied the dragged pane sits under the cursor, so the next motion computes the dragged pane as its own target, undoes the swap, which puts the original pane back under the cursor, which re-applies it - oscillating on every motion event and spamming both PTYs with resizes. Avoided by treating two cases as 'the layout already shows what you would get' and doing nothing: - the cursor is over the dragged pane, wherever the preview has moved it; - the cursor is over the pane it is currently swapped with, which after a preview sits at the dragged pane's original position. So a swap is only re-applied when the cursor reaches a THIRD pane, which also keeps PTY resizes down to one pair per target change rather than per event. The dragged pane is kept highlighted rather than the target, so it stays identifiable while the layout moves around it. Note: the state machine lives in the executor and needs a real Tab, so it is reasoned through in the commit and README rather than unit-tested; the decision-level tests cover only which action is produced. Baselines held: zellij-server 1441/30.
* sl0p: highlight the drop target while dragging a paneuser2026-08-08
| | | | | | | | | | | | | | | | | | | | Dragging a pane by its title was blind until release. The pane a drop would land on is now highlighted with the theme's frame_highlight colour. Reuses the existing hover highlight rather than adding a second highlighting mechanism, but sets tab.mouse_hover_pane_id directly instead of going through execute_update_hover, which is gated on the mouse_hover_effects option. During a drag this is essential feedback rather than decoration, so it must not be switchable off. The pane being dragged is deliberately NOT highlighted when hovered: dropping onto itself is a no-op, so showing no highlight is the honest signal that releasing there changes nothing. The highlight is cleared on drop, so a completed drag does not leave a pane looking hovered. Baselines held: zellij-server 1441/30.
* sl0p: rearrange tiled panes by dragging their title baruser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Dragging a tiled pane by its title bar and dropping it on another pane swaps the two. Previously the only way to rearrange panes was MOVE mode. The underlying operation already existed: the directional move_pane_* functions are all the same geometry swap, differing only in how they pick the other pane. Extracted as TiledPanes::swap_panes(a, b) so a pane can be swapped with an arbitrary target - the one a drag was dropped onto - which also de-duplicates what was four copies of the same swap. Gesture: the title row EXCLUDING its corners. Corners stay resize handles, so two-axis resize from the top is preserved. Plain top-edge resize is gone and costs nothing: every boundary is shared, so the boundary above a pane is the bottom edge of the pane above and can be dragged from there. A pane at the top of the viewport has the screen edge above it, which was never resizable. Ordering in determine_mouse_action matters three ways: - an in-progress drag is checked FIRST, so motion cannot be reinterpreted as text selection or a resize; - the drag check sits AFTER the double-click check, so the second click of a rename is not swallowed; - and BEFORE the resize branch, which would otherwise claim the title row. Two behaviours had to be preserved deliberately: - Dropping a pane on itself is a no-op, which is what keeps a plain click and a double-click working on the title bar. - Starting a drag focuses the pane. Pressing a title used to start a resize, which focused it as a side effect; without this a plain click on the title would do nothing at all. Stacked panes are excluded from the handle. Their title row is the affordance for focusing/expanding a stack member, and intercepting it broke focus_stacked_pane_under_flexible_pane_with_the_mouse - caught by the suite, not by hand. Baselines held: zellij-server 1440/30 (the 30 are pre-existing).
* sl0p: optionally hide the 'Pane #N' placeholder titleuser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ui { pane_frames { hide_default_pane_names true } }`, default false. A pane now shows no title at all until something actually names it: the user renaming it (including by double-clicking the title row) or the running program setting a title. Upstream always shows an auto-generated 'Pane #N'. Implemented by tracking whether pane_title is still the constructor's placeholder, NOT by matching the string: a program that legitimately titles itself 'Pane #3' must not be mistaken for an unnamed pane. set_title clears the flag, since something then chose that title. current_title reads the option at render time rather than baking an empty title in at construction, so it hot-reloads and turning the option back off restores the placeholder on existing panes. Display precedence is unchanged and now explicit in the code: user-given name > title set by the running program > placeholder render_terminal_title already fell back to an empty string rather than the placeholder, so the terminal WINDOW title is unaffected. Empty titles were already handled by the frame renderer (render_title_left_side returns None for an empty title), including the sl0p centred/right alignment path. Baselines held: zellij-server 1436/30, zellij-utils 436/5.
* sl0p: grayscale idle panes too (neovim stayed in colour)user2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | Reported: grayscale worked for most panes but not one running neovim. Nothing to do with neovim specifically - it was the only pane sitting IDLE. refresh_for_focus_change called render_full_viewport(), which marks the grid's output BUFFER so the whole viewport is emitted rather than a diff. But Pane::render() early-returns unless grid.should_render is set, and render_full_viewport() does not set it. So an idle pane never re-rendered at all and kept its previous colours indefinitely. Shell panes hid the bug: they set should_render constantly on their own (prompt redraws, cursor), so they picked up the change almost immediately and looked correct. A pane parked in neovim with no output produced nothing, so it never repainted. Both calls are now made and the reason each is needed is documented at the call site, because 'mark the buffer' and 'schedule a render' being two separate flags is exactly the kind of thing that reads as redundant and gets 'cleaned up' later. Baselines held: zellij-server 1436/30.
* sl0p: grayscale the contents of unfocused panesuser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ui { pane_frames { inactive_grayscale 0-100 } }`, default 0 (off). Real colour transformation, not the SGR 'faint' attribute zellij already uses for frames: faint is terminal-dependent, several terminals only apply it to the 16 ANSI colours, and it does not remove colour. Each cell's colour is resolved to RGB and blended toward its own Rec.709 luminance, so an unfocused pane keeps its brightness but drains of colour. Colour resolution covers the cases that actually occur: RgbCode directly, ColorIndex via eightbit_to_rgb, and NamedColor by mapping to its 0-15 index. Cells with NO explicit colour are deliberately left alone - they render in the terminal default, which is already unsaturated, and rewriting them would mean emitting an explicit colour for nearly every cell for no visible gain. Two non-obvious pieces were needed to make it actually work: - The output buffer only emits CHANGED cells, so an idle pane would keep its old colours indefinitely after losing focus. Pane::refresh_for_focus_change forces a full repaint, called from ActivePanes::insert (the single place focus changes). It is a no-op when the feature is off, so it costs nothing by default. - Terminal panes render through the SHARED multi-client path. Focus is per client, so once unfocused panes are desaturated two clients may need different output for the same pane. Both tiled and floating panes now fall back to per-client rendering when grayscale is enabled - the reason this is opt-in. Known limitation: sixel/kitty image chunks are not colour-transformed and stay in colour. Hot-reloads like the other sl0p options. Tests cover the transform: full strength yields a true neutral grey, partial sits between original and grey, indexed and named colours resolve rather than being skipped, luminance is perceptual (green brighter than blue) not a flat average, already-grey is unchanged, and non-colour codes are untouched. Baselines held: zellij-server 1436/30, zellij-utils 436/5.
* chore: stop tracking transient bgrun job artifactsuser2026-08-08
| | | | | | | | | A `git add -A` swept in the per-job files bgrun writes beside SPEED.md, including a 37KB build log, none of which belong in the repo. SPEED.md stays tracked on purpose: the narrow test commands, measured timings and the pre-existing test-failure baselines are useful to anyone working on this fork, not just to an agent.
* sl0p: route mouse rename through the server, fixing stuck rename modeuser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Enter/Esc still did not leave rename mode after entering it by double-click. The previous attempt fixed a real trap (a None base_mode makes change_to_default_mode a silent no-op) but not this one. The actual cause: zellij tracks the client's input mode in TWO places. - Screen's ModeInfo, which drives what is rendered. - SessionData::current_input_modes on the server, which is what ROUTES key input to the right handler. The double-click path sent ScreenInstruction::ChangeMode directly, so only the first was updated: the rename prompt appeared and accepted text, while the server still interpreted keys in the previous mode - so Enter and Esc were never treated as rename-mode keys and there was no way out. Action::SwitchToMode (the rename keybind) sends ServerInstruction::ChangeMode instead, and the server handler updates current_input_modes AND derives base_mode from the session config before forwarding to Screen. The mouse path now sends exactly that, so it is the same code path as the keybind rather than an imitation of it. Tab::client_base_mode is dropped: the server supplies base_mode correctly, so it is no longer needed. Lesson worth keeping: 'send the instruction the existing feature sends' beats reconstructing its effects downstream. Both bugs here came from entering the system one layer too low. Baselines held: zellij-server 1429/30.
* sl0p: fix getting stuck in rename mode (base_mode wiped)user2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Double-click-to-rename trapped the client: once in RenamePane, Esc and Enter did nothing and there was no way out without killing the session. Cause: Screen::change_mode() does `mode_info.base_mode = base_mode`, i.e. it OVERWRITES the client's base mode with whatever it is handed. The double-click path passed None, believing that meant 'leave it alone'. It does not - ModeInfo::change_to_default_mode() is: if let Some(base_mode) = self.base_mode { self.mode = base_mode; } so a None base_mode makes leaving ANY temporary mode a silent no-op. The legitimate caller in lib.rs passes Some(base_mode) from the session config; this path had no equivalent. Fix: Tab::client_base_mode() reads the client's existing base_mode (falling back to the tab default) and the rename path passes it through, so entering rename via the mouse leaves the return path exactly as the keybind does. Added a test on ModeInfo pinning the trap itself: change_to_default_mode is a no-op with base_mode None, and returns to the base mode when it is set. That is the property that makes passing None dangerous, and it now fails loudly if anyone assumes otherwise. Immediate escape hatch for a session stuck on an older build: zellij action switch-mode locked (that goes through the server path, which sets base_mode correctly) Baselines held: zellij-server 1429/30, zellij-utils 436/5.
* sl0p: don't let hover steal focus while typing into a rename promptuser2026-08-08
| | | | | | | | | | | | | | | | | | Reported after double-click-to-rename landed: rename one pane, then hovering over other panes puts them in 'edit title' state too. Cause is focus_follows_mouse, not the rename itself. RenamePane is a CLIENT mode that acts on whatever pane is ACTIVE, so with focus-follows-mouse enabled every pane the mouse passes over becomes active and inherits the prompt. The double-click entry point just made it easy to hit, since you finish the gesture with the mouse sitting on a pane frame. Focus-follows-mouse is now suppressed for a client that is in a modal text-entry mode: RenamePane, RenameTab, EnterSearch or Prompt. Those all act on the active pane/tab, so moving focus underneath them is never what the user meant. Keyboard focus movement is untouched. Baselines held: zellij-server 1429/30.
* sl0p: fix double-click-to-rename, which never fireduser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two independent bugs, either of which alone made the feature dead code. 1. WRONG EDGE. The check was `details.edge == Some(PaneEdge::Top)`, but get_edge_at_position() quadrant-splits a pane and only ever returns the four CORNER variants - PaneEdge::Top exists in the enum and is never produced. A title-row click reports as TopLeft/TopRight, so the condition was never true. Replaced with Pane::position_is_on_title_row(), which tests the geometry directly the same way position_is_on_frame() does for the top border. 2. RESET ON RELEASE. Click recording reset the tracker on any event that was not a frame press - which includes the RELEASE between the two clicks of a double-click. The sequence is press/release/press, so the count went 1 -> reset -> 1 and never reached 2. The tests passed anyway because they hand-built a MouseEventContext with edge: Some(PaneEdge::Top) and called the decision function directly. They asserted my assumption rather than the behaviour, and exercised neither the edge lookup nor the click sequence. Click recording is now a small pure helper (record_frame_click) covered by tests that replay real event sequences: press/release/press is a double-click, motion in between does not break it, a press off the frame does break it, and two clicks at different positions are not a double-click. The decision tests now use the corner edge value that is actually produced. Baselines held: zellij-server 1428/30 (7 new tests).
* sl0p: double-click a pane title to rename ituser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Double-clicking the top edge of a pane's frame focuses that pane and switches the client into RenamePane mode. Everything needed already existed; this is wiring, not new machinery: - grid.rs already tracks repeated clicks (Click, 400ms threshold). It is now pub and reused rather than inventing a second notion of 'double click' that could drift from the one used for text selection. A frame click never reaches the grid, so Tab tracks frame clicks per client separately. - pane.position_is_on_frame() / get_edge_at_position() already give the hit edge, and mouse_handler already computes both. - Screen::change_mode() already calls store_pane_name() when entering RenamePane, so issuing ChangeMode(RenamePane) does the whole setup exactly as the keybind path does. No rename logic is duplicated. Placement in determine_mouse_action matters twice: - AFTER the floating 'start moving' branch, so floating panes keep dragging by their title (that is their move handle). Tiled panes only for now. - BEFORE the resize branch, which would otherwise consume the click as a top-edge resize drag. Only PaneEdge::Top renames; corners stay resize handles. The pane is focused first because RenamePane acts on the ACTIVE pane, so renaming an unfocused pane would otherwise rename the wrong one. Tests cover all three: double-click on Top renames, single click on Top still resizes, and double-click on any other edge or corner does not rename. Baselines held: zellij-server 1424/30 (3 new tests), zellij-utils 435/5.
* sl0p: ignore gap/padding when a pane draws no frameuser2026-08-08
| | | | | | | | | | | | | | | | | | | | | | | | Closes the frameless-mode hole documented in the README. In frameless mode (pane_frame_style "titles" or "none") the cells around a pane's content are owned by the inter-pane boundary renderer, not the pane. The ghost-fix commit correctly stopped panes from BLANKING that region - blanking it erased the boundaries and broke three tests - but nothing stopped them from CREATING it: padding still inset the content, so the vacated cells had no owner and kept whatever was drawn there before. Half the job done in each direction produced exactly the ghosting the blanking was meant to prevent, just in a mode we do not use by default. Fix: a pane that is not drawing a frame is not inset at all - no gap, no padding, no moat, nothing unowned. Pane::set_frame() already received the right boolean from both the tiled and floating containers and was throwing it away; it now records it. Config values are preserved, they simply take effect only with full frames. README updated to describe this as a defined limit rather than a known bug. Baselines held: zellij-server 1421/30, zellij-utils 435/5.
* sl0p: tag fork builds as 0.45.0+sl0p1user2026-08-08
| | | | | | | | | | | | | | | | | | | | | `zellij --version` was identical to upstream, so there was no way to tell a fork binary from a stock one. Uses BUILD METADATA (+sl0p1) rather than a prerelease (-sl0p1): cargo ignores build metadata when matching version requirements, so the intra-workspace `zellij-utils = "0.45.0"` dependencies still resolve. The prerelease form was tried first and fails to resolve - it would require updating every member manifest to depend on the exact prerelease. Checked against the places VERSION is actually consumed: - shared::version_number() yields 4500 for both 0.45.0 and 0.45.0+sl0p1, so terminal version reporting is byte-identical, and still orders below 0.46.0. - client/server compatibility keys off CLIENT_SERVER_CONTRACT_VERSION, not this, so existing sessions are unaffected. - ZELLIJ_PLUGIN_ARTIFACT_DIR is keyed by VERSION, so the bundled wasm plugins are recompiled once on first run after a bump. One-time, harmless. Baselines held: zellij-server 1421/30, zellij-utils 435/5.