# Zellij

> Session, layout, and pane management for Zellij terminal multiplexer. Use when managing Zellij sessions, layouts, or automating terminal workflows.

- Skill: `ryanmaclean/zellij` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ryanmaclean/zellij`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ryanmaclean/zellij/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ryanmaclean (https://skillmd.com/u/ryanmaclean)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ryanmaclean/zellij

---


# Zellij — Session, Layout, and Pane Management

## Core rule: never hardcode the session name

Zellij injects `$ZELLIJ_SESSION_NAME` into every process inside a session.
Always read it — never write `"pmr"`, `"bop"`, or any literal name.

```nu
# Nushell — canonical zellij_session function used in all dispatch.nu files
def zellij_session [] {
  if "ZELLIJ_SESSION_NAME" in $env {
    $env.ZELLIJ_SESSION_NAME
  } else {
    # Running outside Zellij — auto-detect
    let sessions = (^zellij list-sessions --no-formatting 2>/dev/null
      | lines | where { |l| ($l | str trim) != "" })
    if ($sessions | length) == 1 {
      $sessions | first | str trim
    } else if ($sessions | length) > 1 {
      error make { msg: $"Multiple sessions active: ($sessions | str join ', '). Set ZELLIJ_SESSION_NAME." }
    } else {
      error make { msg: "No Zellij session found. Start one with: zellij --session <name>" }
    }
  }
}
```

```bash
# Bash equivalent
SESSION=${ZELLIJ_SESSION_NAME:-$(zellij list-sessions --no-formatting | head -1 | awk '{print $1}')}
```

## Sessions

```bash
# Start or resume (canonical pattern — no hardcoded name)
zellij attach --create bop        # bop project
zellij attach --create efi        # efi/dub project
zellij attach --create zam        # zam project

# List active sessions
zellij list-sessions
zellij list-sessions --no-formatting   # machine-readable, no ANSI

# Detach without killing
Ctrl+p d

# Kill a session
zellij kill-session <name>
```

## Layouts

Each project has a `.kdl` layout file:

| Project | Layout | Launch |
|---------|--------|--------|
| bop | `layouts/bop.kdl` | `zellij --session bop --layout layouts/bop.kdl` |
| efi | `layouts/efi.kdl` | `zellij --session efi --layout layouts/efi.kdl` |
| zam | `layouts/zam.kdl` | `zellij --session zam --layout layouts/zam.kdl` |

### bop.kdl structure

Two tabs: `bop` (primary) and `dispatch` (engine runners).

**bop tab** (7-pane card lifecycle layout):
```
┌──────────┬──────────────────┬──────────────────┐
│  BOARD   │  SPEC            │  QA              │
│  watch   │  what to build   │  did it work?    │
│  status  ├──────────────────┼──────────────────┤
│          │  STDOUT          │  STDERR          │
│          │  agent output    │  agent errors    │
├──────────┴──────────────────┴──────────────────┤
│  INSPECTOR — bop inspect <id>                  │
├─────────────────────────────────────────────────┤
│  SHELL — bop commands (focus/retry/approve)    │
└─────────────────────────────────────────────────┘
```

**dispatch tab**: dispatcher (75%) | merge-gate (75%) | dispatch-shell (25%)

### KDL quick reference

```kdl
layout {
    default_tab_template {
        children
        pane size=2 borderless=true {
            plugin location="zellij:status-bar"
        }
    }

    tab name="main" focus=true {
        pane split_direction="horizontal" {
            pane name="top" size="70%" { command "nu" }
            pane name="bottom" size="30%" { command "nu" }
        }
    }

    tab name="logs" {
        pane name="log-tail" {
            command "nu"
            args "-c" "tail -f /tmp/bop.log"
        }
    }
}
```

`split_direction="horizontal"` = children stacked top/bottom.
`split_direction="vertical"` = children side by side.

## Spawning panes from scripts

```bash
# Fire-and-forget — pane closes when command exits
zellij --session $ZELLIJ_SESSION_NAME run \
  --name my-card \
  --close-on-exit \
  -- sh -c "nu /path/to/adapter.nu ..."

# Inspectable — pane stays open after command exits (for debugging)
zellij --session $ZELLIJ_SESSION_NAME run \
  --name my-card \
  -- sh -c "nu /path/to/adapter.nu ..."

# Open a new tab
zellij --session $ZELLIJ_SESSION_NAME action new-tab --name "logs"

# Navigate to an existing tab by name
zellij --session $ZELLIJ_SESSION_NAME action go-to-tab-name "bop"
```

**Use `--close-on-exit` for**: dispatch worker panes (adapter runs).
**Omit `--close-on-exit` for**: interactive panes, debugging, long-lived agents.

## PATH gotcha — critical for dispatch workers

`nu` (Nushell) is **not** in PATH inside Zellij panes spawned via `zellij run`.
Always use the full path:

```bash
# WRONG — fails silently in detached pane context
nu dispatch.nu mark-done 022

# CORRECT
/opt/homebrew/bin/nu dispatch.nu mark-done 022
```

Same applies to `bop` binary: use `$ROOT/target/debug/bop` or add to PATH explicitly:
```bash
export PATH=$HOME/bop/target/debug:$PATH
```

## Suspending TUI to drop to shell (`!` key in bop ui)

**Do NOT use `Ctrl+O`** — that is Zellij's built-in Session mode key and will
be intercepted before reaching any app running inside a pane.

`bop ui` uses `!` (normal mode, no modifier) for the subshell drop, inspired
by vim's `:!` convention. Zellij does not intercept bare printable keys in
normal mode.

```rust
// Triggered by KeyCode::Char('!') in the AppEvent::Key handler
// Suspend TUI
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(stdout(), crossterm::terminal::LeaveAlternateScreen)?;

// Run shell in card's worktree
std::process::Command::new(std::env::var("SHELL").unwrap_or("sh".into()))
    .current_dir(&card_worktree)
    .status()?;

// Restore TUI
crossterm::execute!(stdout(), crossterm::terminal::EnterAlternateScreen)?;
crossterm::terminal::enable_raw_mode()?;
// trigger full redraw
```

This does NOT open a new Zellij pane — it suspends and restores within the
existing pane. Works correctly inside Zellij because alternate screen is
stacked (Zellij has its own alternate screen above it).

## Zellij keys already taken — do NOT use in TUI apps

These are intercepted by Zellij before reaching pane content:

| Key | Zellij action |
|-----|---------------|
| `Ctrl+O` | Session mode (session manager) |
| `Ctrl+P` | Pane mode |
| `Ctrl+T` | Tab mode |
| `Ctrl+N` | New pane |
| `Ctrl+H` | Move pane focus left |
| `Ctrl+B` | Scroll up |
| `Ctrl+F` | Scroll down |
| `Ctrl+G` | Unlock (in locked mode) |

**Safe to use in TUI apps**: bare printable keys (`!`, `e`, `n`, `r`, etc.),
`Alt+<key>` (Zellij uses Alt+arrow but not most Alt+letter combos),
function keys `F1`–`F12`.

## Key bindings (Zellij default — navigation)

| Key | Action |
|-----|--------|
| `Alt + arrow` | Move focus between panes |
| `Alt + n` | New pane |
| `Alt + [` / `]` | Previous / next tab |
| `Ctrl+p d` | Detach from session (session stays alive) |
| `Ctrl+p z` | Toggle pane fullscreen |
| `Ctrl+p x` | Close focused pane |
| `Ctrl+p r` | Rename focused pane |

## Integration with dispatch.nu (all three projects)

All three projects (bop, efi, zam) use the same `zellij_session` pattern:

```
dispatch.nu run --wave N --yes
  → zellij_session []          # reads $ZELLIJ_SESSION_NAME
  → zellij --session <name> run --name <spec-id> --close-on-exit -- sh -c <AC cmd>
  → pane runs Auto-Claude agent
  → on exit: mark-done / mark-failed called via /opt/homebrew/bin/nu
```

Lock file (`dispatch-lock.json`) records `session: (zellij_session)` so rescue
operations can target the correct session.

## Nested panes — bop ui card tabs (spec 029 future)

Each running card will optionally get a tab with nested panes:
- Top pane: adapter stdout (live)
- Bottom pane: log tail (`logs/stderr`)

Tab naming convention: `card-<id>` (e.g. `card-feat-auth`).
Created via `zellij action new-tab --name card-<id>`, then pane splits via
`zellij action new-pane --direction down`.

## Projects and session names

| Project | Canonical session | Layout |
|---------|-------------------|--------|
| bop | `bop` | `layouts/bop.kdl` |
| efi (dub) | `efi` | `layouts/efi.kdl` |
| zam | `zam` | `layouts/zam.kdl` |

These are current names — they WILL change. Always use `$ZELLIJ_SESSION_NAME`.

## Quick reference

```bash
# Get current session name (canonical method)
SESSION=${ZELLIJ_SESSION_NAME:-$(zellij list-sessions --no-formatting | head -1 | awk '{print $1}')}

# Start/attach to session
zellij attach --create <session-name>

# List sessions
zellij list-sessions --no-formatting

# Run command in new pane
zellij --session $SESSION run --name pane-name --close-on-exit -- command

# Detach without killing
Ctrl+p d

# Navigate between panes
Alt + arrow keys
```

## Common mistakes

- **Hardcoding session names**: Never write literal names like "bop" or "efi" — always read `$ZELLIJ_SESSION_NAME`
- **Using Ctrl+O in TUI apps**: Zellij intercepts this key for Session mode — use bare printable keys like `!` instead
- **Not using full paths to binaries**: `nu` and other tools are not in PATH inside spawned panes — use full paths like `/opt/homebrew/bin/nu`
- **Forgetting --close-on-exit**: Worker panes should close when done to avoid resource leaks
- **Using Ctrl+O for subshell drop**: Use bare `!` key instead, as Ctrl+O is intercepted by Zellij

