aboutsummaryrefslogtreecommitdiffstats
BranchCommit messageAuthorAge
mainsl0p: document --purpose and tab purposesuser3 months
 
 
AgeCommit messageAuthor
2026-08-12sl0p: document --purpose and tab purposesHEADmainuser
2026-08-12sl0p: --purpose flag and tab-level purposeuser
2026-08-12sl0p: document workspace hints for consumersuser
2026-08-12sl0p: per-pane workspace hints (purpose=)user
2026-08-12sl0p: propose per-pane workspace hintsuser
2026-08-12sl0p: hot-reload status_bar_button_styleuser
2026-08-12sl0p: add an indexed style for status bar buttonsuser
2026-08-12sl0p: highlight the status bar button under the pointeruser
2026-08-12sl0p: document the pane status bar protocoluser
2026-08-12sl0p: clickable action buttons in the pane status baruser
2026-08-12sl0p: serialise the pane status bar for resurrectionuser
2026-08-12sl0p: per-pane status bar via OSC 5577 (experimental)user
2026-08-12sl0p: separate the hover resize hint from the hover highlightuser
2026-08-12sl0p(pane-finder): find panes hidden in a stack, not just the visible oneuser
2026-08-12sl0p: add close_if_focused to LaunchOrFocusPluginuser
[...]
 
Clone
https://git.sl0p.foo/sl0ppij.git
ssh://git@git.sl0p.foo:39327/srv/git/sl0ppij.git

sl0ppij — an opinionated Zellij fork

This is not upstream Zellij. It is a fork maintained at https://git.sl0p.foo/sl0ppij.git, tracking zellij-org/zellij with a small set of opinionated additions, mostly about how panes look.

Upstream's README follows below, unchanged.

What this fork adds

Everything lives in the existing ui { pane_frames { ... } } block, and every option defaults to upstream behaviour — an unmodified config renders exactly as stock Zellij.

ui {
    pane_frames {
        // Where the pane title sits in the frame. Upstream hardcodes it flush
        // left for full frames. (Frameless mode already centred it.)
        title_alignment "center"        // "left" | "center" | "right"   (default: left)

        // Outer gap: real empty terminal between panes, WM style.
        // Expressed in ROWS. A pane insets right/bottom always and left/top
        // only at the screen edge, so two neighbours show exactly `gap`
        // between them -- not double -- and the screen edges match.
        gap 1                           // default: 0

        // Inner padding between a pane's frame and its content.
        // CSS-style shorthand, but the 4-value order is UP DOWN LEFT RIGHT,
        // deliberately NOT CSS's clockwise top-right-bottom-left:
        //   padding 1          all four sides
        //   padding 1 2        up/down = 1, left/right = 2
        //   padding 1 2 3 4    up=1 down=2 left=3 right=4
        padding 1                       // default: 0

        // Show no pane title until something actually names the pane: either
        // you rename it, or the running program sets a title. Upstream always
        // shows a "Pane #N" placeholder. Tracked as a flag rather than by
        // matching the string, so a program that genuinely titles itself
        // "Pane #3" is not mistaken for an unnamed pane.
        hide_default_pane_names true

        // Desaturate the CONTENT of unfocused panes, 0-100 (default 0 = off).
        // Colours are blended toward their own perceptual luminance, so an
        // unfocused pane keeps its brightness but drains of colour. This is
        // real colour transformation, not the SGR "faint" attribute.
        //
        // Cost: focus is per client, so clients can no longer share rendered
        // output and each renders separately. Fine for a single user, worth
        // knowing about in a multi-user session. Sixel/kitty IMAGES are not
        // affected and stay in colour.
        inactive_grayscale 85

        // Columns per row. Terminal cells are ~2x taller than they are wide, so
        // 1 row of gap needs ~2 columns to look like the same distance.
        // Applies to `gap`, and to the 1-value form of `padding`. The 2- and
        // 4-value padding forms are taken literally. Set to 1 to disable.
        gap_aspect 2                    // default: 2

        // Collapse to a named swap layout when the terminal gets narrow, and
        // restore the previous one when it grows back. 0 disables (default).
        // See "Responsive layouts" below -- this one needs swap layouts in your
        // layout file to do anything.
        responsive_min_cols 90          // default: 0 (off)
        responsive_layout "stacked"     // default: "stacked"
    }
}

Responsive layouts

When the terminal is narrower than responsive_min_cols, the tab switches to the swap layout named by responsive_layout; when it grows back, the layout that was active before the collapse is restored. Splitting a wide window into thirds is great until the window is 70 columns wide and each pane gets 23.

If no swap layout by that name exists, one is synthesized: every pane flattened into a single stack, in the order they appear in the layout. That is the normal case, because a custom layout file only picks up swap layouts from a hand-written sibling <layout>.swap.kdl — so the collapse works out of the box for a bare layout, and growing the terminal back restores the original arrangement. It is logged once per tab so a name that does not match its swap file is not mistaken for the fallback.

Expanding falls back too. A tab restores into whatever layout was active before it collapsed, which for a bare layout is the implicit BASE — and BASE is pinned to the pane count it was written with, so adding or closing a single pane leaves nothing to expand into. When that happens the panes are spread into a grid rather than left stacked.

Define the layout yourself when you want something other than a flat stack:

layout {
    pane split_direction="vertical" {
        pane
        pane
        pane
    }
    swap_tiled_layout name="tiled" {
        tab {
            pane split_direction="vertical" {
                pane
                pane
                pane
            }
        }
    }
    swap_tiled_layout name="stacked" {
        tab min_panes=2 {
            pane stacked=true {
                children
            }
        }
    }
}

(One node per line: zellij's KDL parser rejects the { pane; pane; } one-liner form.)

Name-based rather than a new size-aware LayoutConstraint on purpose: that type crosses both the plugin protobuf API and the client/server contract, and the committed prost assets cannot be regenerated without protoc.

Two deliberate behaviours:

  • Hysteresis. Collapse happens below responsive_min_cols, but restore only above responsive_min_cols + 10. Without that 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.
  • Both directions are resolved from the panes as they are, never replayed from a remembered layout. Collapsing picks your named layout, or a synthesized stack if that one is missing or cannot currently apply. Expanding picks the tab's implicit BASE layout when it still applies — which reproduces your original arrangement exactly — and otherwise a synthesized grid. Both chains end in something with no constraints, so neither direction can fail to find a target and leave the tab stuck.
  • The only state kept is one bit: which side of the threshold you are on, and it is flipped only after a layout has actually been applied. Every bug this feature had came from remembering a layout position instead: positions stop applying when the pane count changes, and a failed apply used to leave the tab claiming a transition that never happened.
  • A manual arrangement does not suppress it. Zellij's "damaged layout" flag is also set whenever a relayout finds nothing applicable, so honouring it disabled the feature in several situations that had nothing to do with intent. It protected little in any case: expanding applies BASE or a grid, so a manual arrangement is replaced by a collapse/expand round trip either way.

Note this affects frame rendering only. Panes still report their Pane #N placeholder to the plugin API, so anything that lists panes — the session manager, a pane finder — can still label and search them. Blanking it there too left such plugins with rows they could neither identify nor fuzzy-match.

Capping the stacked-pane picker

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

ui {
    pane_frames {
        stack_list_max_rows 3        // default: 0 (show every member)
        stack_list_layout "grid"     // "rows" (default) | "grid"
    }
}

In grid mode entries are packed several per row, so the cap becomes a space budget rather than a count: eight panes in a wide terminal fit on one row, and +N more never appears. Cell width comes from the widest label, so the column count adapts to the titles rather than being configured; long titles simply yield fewer columns, and one column is the floor.

stack_list_style controls how entries look:

style entry active entry
boxed (default) bracketed in vertical bars prefixed with >
minimal no brackets, one space of padding inverted, using the theme's ribbon_selected

minimal reads better at a glance and costs 4 fewer columns per entry, so more entries fit per row in grid mode. It uses ribbon_selected rather than text_selected because themes give the former real contrast — it is what marks a selected item elsewhere in zellij — while the latter's background is often a hair off the terminal's own and would read as no highlight at all.

The list windows around the focused entry and reports the remainder as +N more in the padding row it already reserves, so the indicator never costs a row or steals one from a real entry. Click that row to expand the stack to its full length; it becomes - fewer, which collapses it again. Entries keep their real position in the stack rather than renumbering from 1, so the numbering still means something once the window has scrolled. Ordinary focus movement slides the window, which is why there is no separate binding for it.

Expansion is a click rather than hover on purpose: the row count feeds pane geometry, so growing the list resizes the visible pane's PTY. Once per deliberate click is fine — the same cost as any pane resize — whereas on hover it would reflow a real terminal, and any full-screen program in it, on every mouse move.

Nothing is unreachable while collapsed: the window slides as focus moves, so the picker is a scrolling viewport rather than a truncation.

Pane arrangements

A children placeholder normally inserts every pane as a flat sibling, which is why a layout can only express "N equal columns", "N equal rows" or "one stack" without hand-writing a variant per pane count (see the max_panes=5 / max_panes=8 / max_panes=12 ladders in a typical swap layout file). An arrangement builds a tree for N panes instead:

swap_tiled_layout name="grid" {
    tab min_panes=3 {
        pane arrangement="grid" {
            children
        }
    }
}

swap_tiled_layout name="master-stack" {
    tab min_panes=2 {
        pane arrangement="master-stack" {
            children
        }
    }
}
arrangement result
grid roughly square: ceil(sqrt(n)) columns, rows filled left to right (4 panes → 2x2, 5 → 3 then 2)
master-stack one master pane taking 60% of the width, the rest stacked beside it

Requires a children node (an arrangement has nothing to act on without one, so omitting it is a config error rather than a silent no-op), and cannot be combined with stacked=true.

It generates a TiledPaneLayout rather than geometry directly, so everything downstream comes along for free: the minimum-size feasibility check, swap-layout constraints, damage tracking, and the responsive collapse above. That last one composes especially well — a grid is simply infeasible on a narrow terminal, so it is skipped and the collapse takes over.

The arrangement has no protobuf representation (like default_fg/default_bg), so plugins cannot set it and it does not survive a round trip through the plugin API.

Drag a pane title to rearrange it

Dragging a tiled pane by its title bar and dropping it on another pane swaps the two, so panes can be rearranged with the mouse instead of only through MOVE mode.

The title bar EXCLUDING its corners is the move handle; the corners remain resize handles, so two-axis resize from the top still works. Plain top-edge resize is gone, which costs nothing in practice: every boundary is shared, so the boundary above a pane is the bottom edge of the pane above it and can be dragged from there.

The reordering is previewed LIVE: the swap is actually applied as you drag, so the real layout shows what you would get, and releasing simply keeps it. Dragging away undoes the preview and restores the original arrangement. The pane being dragged stays highlighted so it remains identifiable as the layout moves around it.

A drag that never leaves its own pane previews nothing, so a plain click still just focuses and double-click-to-rename still works. Stacked panes are excluded — in a stack the title row is how you focus a member.

Double-click a pane title to rename it

Double-clicking the top edge of a pane's frame focuses that pane and drops straight into rename mode — the same state the rename keybind produces.

Frame corners are excluded (they are resize handles), and a single click on the top edge still starts the usual resize drag. Currently tiled panes only: on floating panes the title is the drag handle for moving the pane, so that path is left alone for now.

Also in the fork, not configurable:

  • Renaming a tab REPLACES the existing name on the first keystroke, the way a text field with its contents selected behaves, instead of appending to it. Backspace and delete count as editing rather than replacing, so they still work on the old name.

  • A compact scroll indicator (↑42) in place of upstream's SCROLL: 42/1000, shown only when actually scrolled up rather than whenever scrollback exists.

  • sl0p.foo branding in the default tab-bar plugin.

Which changes apply live

config.kdl is watched, but only some things reach a running session:

change applies
title_alignment, gap, padding, gap_aspect live, no restart
responsive_min_cols, responsive_layout live, applies on the next resize
status_bar_button_style live, on the next render
rounded_corners, and other stock options live (upstream behaviour)
anything requiring a new binary new session — a running server keeps its old inode

Building

Upstream's [profile.release] uses fat LTO and codegen-units = 1, which costs ~3 minutes for a one-line change. For iteration use the fastdev profile added here — same opt-level = 3, no LTO, parallel codegen, incremental cache kept:

cargo build --profile fastdev -p zellij     # ~3s incremental  -> target/fastdev/zellij
cargo build --release -p zellij             # ~3min            -> ship this one

See .fastfeedback/SPEED.md for narrow test commands and measured timings.

The test suite is green (cargo test -p zellij-server -p zellij-utils), so any failure is a real one. It was not always: the fork carried 35 failing snapshot tests from its first commit until 5f2c28ab, because aa51848d changed the frame rendering without refreshing the insta snapshots. If you change anything about how frames or pane titles render, expect to regenerate snapshots — and read the diffs before accepting them.

Per-pane status bar (OSC 5577) — experimental

A program can put one row of its own text in its pane's bottom frame row:

printf '\033]5577;1;status;building 3/7 · 12s elapsed\033\\'          # text
printf '\033]5577;1;buttons;approve:Approve;reject:Reject\033\\'      # buttons
printf '\033]5577;1;clear\033\\'                                     # remove both
╰ awaiting review ──────────────────────────[Approve]─[Reject]╯

Workspace hints

A pane can carry an opaque purpose an agent can discover, so a project's layout also says what each pane is for:

pane name="server" purpose="dev-server" command="npm" { args "run" "dev"; }

Tabs can carry one too (tab name="work" purpose="agent-work", reported by list-tabs --json), and an agent can tag panes it spawns with zellij run --purpose agent:build -- ….

It shows up in zellij action list-panes --json, survives resurrection, and can also be declared in-band by the program in the pane (printf '\033]5577;1;purpose;agent:build\033\\') — though a layout-declared purpose always wins, since in-band data is whatever the pane happens to be showing. Zellij never interprets a purpose; the consuming agent owns the vocabulary. See sl0p/workspace-hints-handover.md.

Buttons render as [Approve] by default, or 1:Approve with ui { pane_frames { status_bar_button_style "indexed" } } — useful when something else binds those numbers to the same actions.

Full protocol reference, worked shell/Python consumers and the security model: sl0p/pane-status-bar.md.

Clicking a button writes a fixed-format report to the program's stdin:

OSC 5577 ; 1 ; click ; <id> ST

Button ids are restricted to [A-Za-z0-9_-] and labels are %-escaped on the wire (%3B for ;). Positions come from one layout_status_bar function that both the renderer and the mouse hit-test read, so a button can never be drawn somewhere a click does not resolve. Buttons are right-aligned and get first call on the row: they are actionable, the text is informational and truncates.

Rendered in the existing frame row, not in reserved rows. Reserving a row would shrink the pane's content area and so resize its PTY every time the bar appeared or disappeared — firing SIGWINCH at the very program that asked for it. The frame row is already a text surface and costs nothing.

It outranks the frame's other hints (resize help, mouse shortcuts), since those are discoverable elsewhere and this was explicitly requested by the program. It lives on the pane's Grid like the window title, so it survives re-renders, resizes and detach/reattach — and it is serialised with the session, so a pane resurrected from disk comes back showing what it showed before:

pane focus=true contents_file="initial_contents_2" status_bar="building 3/7"

The text is sanitised again when that layout is parsed back, because a layout file on disk is no more trustworthy than the escape sequence that produced it and may have been hand-edited since. One sanitiser serves both paths.

The payload is versioned (;1;) and unknown versions or verbs are ignored, so adding verbs later cannot be misinterpreted by an older build. The text is the remaining params rejoined with ;, so a single text field needs no escaping — the same trick OSC 0/2 uses for titles. A structured payload will need a real escaping rule.

The text is untrusted. It arrives in-band, so cat of a hostile file emits it just as well as a cooperating program. Control characters are stripped (a raw ESC would let it repaint anything; a newline would corrupt the row) and the length is capped. Keep that in mind for the planned action buttons: a click must send a fixed-format report, never bytes the payload chose, or viewing a file could inject into a shell's stdin.

mouse_hover_help_text

mouse_hover_help_text false     // default: true

Hovering a pane shows Ctrl <MouseScroll> or <drag borders> to resize. Upstream governs that with mouse_hover_effects, which also controls the hover frame highlight — so silencing the hint costs you the highlight too. This separates them: the hint is a beginner reminder you outgrow, the highlight stays useful.

Config-file only. It has no protobuf slot, so it does not cross the client/server IPC contract — the server reads it from the config file itself, and merge() keeps the file value when the client sends None. A --mouse-hover-help-text CLI flag would therefore be silently ignored.

close_if_focused on LaunchOrFocusPlugin

Makes one chord both open and dismiss a plugin:

bind "Ctrl ;" {
    LaunchOrFocusPlugin "file:~/.config/zellij/plugins/pane-finder.wasm" {
        floating true
        close_if_focused true
    };
}

Upstream's action focuses an already-running plugin, so the launch chord can only ever open it. A plugin cannot fix this itself: keybindings are resolved into actions in route.rs before a key can reach a pane, so the chord that launched a plugin never arrives at it — not even via intercept_key_presses, which only sees keys already destined for a pane.

Closes only when the plugin is the focused pane in the client's active tab. Hidden behind a floating-layer toggle, or focused in another tab, it is revealed or brought over instead — so the chord can never close whatever pane you happen to be on.

Defaults to false, so existing bindings behave exactly as before. It is a config-only option with no protobuf representation, and is dropped when an action crosses the plugin API.

Bundled plugin: sl0p/zj-pane-finder

A floating fuzzy finder for every pane in the session, filtering on pane metadata and on what is visible inside each pane. See sl0p/zj-pane-finder/README.md.

It lives in this repo because it path-depends on this checkout's zellij-tile, so the plugin API and its protobuf assets always match the binary built beside it — the two cannot drift. It is its own workspace root rather than a member, so carrying it touches no upstream-tracked file. ./install.sh builds, installs and hot-reloads it.

Known rough edges

  • gap and padding only apply with pane_frame_style "full". With "titles" or "none" they are ignored: the cells around a pane's content there belong to the inter-pane boundary renderer (ui/boundaries.rs), and insetting into that region would leave cells nobody repaints (stale glyphs on resize) or paint over the boundaries themselves. Rather than half-apply, a frameless pane is simply not inset. Your config keeps its values; they just take effect if you switch back to full frames.
  • Floating panes get padding but no gap: they are positioned explicitly, so an inter-pane gap does not apply.
  • Stacked panes apply the gap uniformly; stack header spacing may look slightly off at larger gaps.
  • Responsive layouts need a swap layout with a matching name. responsive_layout selects by name; if no swap layout has that name the tab silently stays as it is, with nothing logged. It is also column-only — there is no responsive_min_rows.
  • The fork's options are not exposed over the plugin protobuf API. Plugins have no use for them and adding proto fields would mean regenerating the committed prost assets in zellij-utils/assets/prost/.

Version string

Fork builds report a +sl0pN build-metadata suffix, so zellij --version is distinguishable from upstream:

$ zellij --version
zellij 0.45.0+sl0p1

Build metadata rather than a -sl0pN prerelease on purpose: cargo ignores build metadata when matching version requirements, so the intra-workspace zellij-utils = "0.45.0" dependencies still resolve. A prerelease suffix does not, and would require editing every member manifest.

Bump the suffix per published fork build; reset to 1 when rebasing onto a new upstream release. Note that a version bump changes ZELLIJ_PLUGIN_ARTIFACT_DIR, so the bundled wasm plugins are recompiled once on first run.

Staying current with upstream

origin     ssh://git@git.sl0p.foo:39327/srv/git/sl0ppij.git   (this fork; main = fork work)
upstream   https://github.com/zellij-org/zellij.git           (vanilla)

The local main branch is kept clean on upstream/main so fork commits can be rebased onto new Zellij releases.



logo
Zellij

demo

[Installation] [Screencasts & Tutorials] [Configuration] [Layouts] [FAQ]

Discord Chat Matrix Chat Zellij documentation


Sponsored by     gresearch logo

What is this?

Zellij is a workspace aimed at developers, ops-oriented people and anyone who loves the terminal. Similar programs are sometimes called "Terminal Multiplexers".

Zellij is designed around the philosophy that one must not sacrifice simplicity for power, taking pride in its great experience out of the box as well as the advanced features it places at its users' fingertips.

Zellij is geared toward beginner and power users alike - allowing deep customizability, personal automation through layouts, true multiplayer collaboration, unique UX features such as floating and stacked panes, and a plugin system allowing one to create plugins in any language that compiles to WebAssembly.

Zellij includes a built-in web-client, making a terminal optional.

You can get started by installing Zellij and checking out the Screencasts & Tutorials.

For more details about our future plans, read about upcoming features in our roadmap.

How do I install it?

The easiest way to install Zellij is through a package for your OS.

If one is not available for your OS, you could download a prebuilt binary from the latest release and place it in your $PATH. If you'd like, we could automatically choose one for you.

You can also install (compile) with cargo:

cargo install --locked zellij

Try Zellij without installing

bash/zsh:

bash <(curl -L https://zellij.dev/launch)

fish/xonsh:

bash -c 'bash <(curl -L https://zellij.dev/launch)'

Installing from main

Installing Zellij from the main branch is not recommended. This branch represents pre-release code, is constantly being worked on and may contain broken or unusable features. In addition, using it may corrupt the cache for future versions, forcing users to clear it before they can use the officially released version.

That being said - no-one will stop you from using it (and bug reports involving new features are greatly appreciated), but please consider using the latest release instead as detailed at the top of this section.

How do I start a development environment?

  • Clone the project
  • In the project folder, for debug builds run: cargo xtask run
  • To run all tests: cargo xtask test

For more build commands, see CONTRIBUTING.md.

Configuration

For configuring Zellij, please see the Configuration Documentation.

About issues in this repository

Issues in this repository, whether open or closed, do not necessarily indicate a problem or a bug in the software. They only indicate that the reporter wanted to communicate their experiences or thoughts to the maintainers. The Zellij maintainers do their best to go over and reply to all issue reports, but unfortunately cannot promise these will always be dealt with or even read. Your understanding is appreciated.

Roadmap

Presented here is the project roadmap, divided into three main sections.

These are issues that are either being actively worked on or are planned for the near future.

If you'll click on the image, you'll be led to an SVG version of it on the website where you can directly click on every issue

roadmap

Origin of the Name

From Wikipedia, the free encyclopedia

Zellij (Arabic: الزليج, romanized: zillīj; also spelled zillij or zellige) is a style of mosaic tilework made from individually hand-chiseled tile pieces. The pieces were typically of different colours and fitted together to form various patterns on the basis of tessellations, most notably elaborate Islamic geometric motifs such as radiating star patterns composed of various polygons. This form of Islamic art is one of the main characteristics of architecture in the western Islamic world. It is found in the architecture of Morocco, the architecture of Algeria, early Islamic sites in Tunisia, and in the historic monuments of al-Andalus (in the Iberian Peninsula).

License

MIT