Mohamed Ali Mejbar
Fès Book a call
Contents
  1. 01 Work
  2. 02 Method
  3. 03 Prices
  4. 04 About
Book a call ali@mejbar.com It's in Fès.
← The notebook Terminal · Tailscale September 2026 · 5 min

My zellij over SSH

One computer at home runs everything; I reach it from a laptop, an iPad or a phone. A session picker, a locked-by-default keymap, and presets that bring a whole project up with its ports.

My projects run on a Mac mini at home. The dev servers and the coding agents live there, and I reach them from whatever I have with me: a laptop, an iPad, a phone. The thing that makes it bearable is zellij, a terminal workspace that keeps sessions alive on the Mac while I come and go.

Three pieces make it work: how I connect, how zellij stays out of the way, and how a project comes up in one command.

Connecting: mosh, Tailscale, and a picker

Every device is on the same Tailscale network, so the Mac has one stable name wherever I am. I connect with mosh rather than plain SSH. It starts over SSH, then keeps the session alive through sleep, a dropped Wi-Fi or a move from Wi-Fi to 4G, and it echoes keystrokes locally, which matters on a phone.

Mosh can run a command instead of a shell. Mine is a small script, zpick, that lists the zellij sessions and lets me pick one:

mosh mac-mini -- zpick

Here’s all of it:

#!/bin/zsh
# Pick a zellij session to attach, or create one. Works as a mosh/ssh remote command.
#   enter          attach to the highlighted session (exited ones get resurrected)
#   ctrl-n         create a session named after what you typed (or $ZPICK_DEFAULT)
#   "+ new"        prompt for a name, defaulting to $ZPICK_DEFAULT
#   esc            no session
#
#   --shell        afterwards (esc, detach, exit), drop into a login shell instead of
#                  ending the connection — use this as the mosh/ssh remote command

# mosh-server/ssh commands run without .zprofile/.zshrc, so PATH is bare.
path=(/opt/homebrew/bin /opt/homebrew/sbin "$HOME/.local/bin" $path)
export LANG="${LANG:-en_US.UTF-8}"

then_shell=0
[[ "${1:-}" == --shell ]] && then_shell=1

finish() {
  (( then_shell )) && exec "${SHELL:-/bin/zsh}" -l
  exit "${1:-0}"
}

default="${ZPICK_DEFAULT:-dev}"
new_label="+ new session"
name="" query="" key="" choice=""

if [[ -n "${ZELLIJ:-}" ]]; then
  echo "Already inside zellij (${ZELLIJ_SESSION_NAME:-?})"
  exit 1
fi

sessions=("${(@f)$(zellij list-sessions 2>/dev/null)}")
sessions=(${sessions:#})

if (( ${#sessions[@]} == 0 )); then
  choice="${new_label}"
elif command -v fzf >/dev/null 2>&1; then
  out=("${(@f)$(printf '%s\n' "${new_label}" "${sessions[@]}" \
    | fzf --ansi --no-sort --reverse --height=~50% \
          --prompt='zellij > ' --print-query --expect=ctrl-n --bind=enter:accept-or-print-query \
          --header='enter: attach · ctrl-n: new from typed name · esc: shell')}")
  (( $? == 130 )) && finish
  query="${out[1]}" key="${out[2]}" choice="${out[3]}"

  if [[ "${key}" == ctrl-n ]] || [[ -z "${choice}" && -n "${query}" ]]; then
    name="${query:-${default}}"
  fi
else
  select choice in "${new_label}" "${sessions[@]}"; do
    [[ -n "${choice}" ]] && break
  done
  [[ -z "${choice}" ]] && finish
fi

if [[ -z "${name}" ]]; then
  if [[ "${choice}" == "${new_label}" ]]; then
    read -r "name?Session name [${default}]: " || finish
    [[ -z "${name}" ]] && name="${default}"
  else
    # strip ANSI (fallback path) and the "[Created ...]" suffix
    name="${${(S)choice//$'\e'\[[0-9;]#m/}%% *}"
  fi
fi

zellij attach --create "${name}"
finish $?

A few lines in it carry most of the weight.

The PATH line. A command run by mosh-server or ssh gets a bare environment: no .zshrc, no Homebrew on the path. Without that line, zellij and fzf simply aren’t found, and the connection closes before you’ve read why.

--print-query --expect=ctrl-n. fzf prints what you typed, which key ended the search, and what you picked, one per line. That’s what lets a single prompt both attach to a session and create a new one named after whatever you typed.

zellij attach --create. Attaching to a session that has exited brings it back with its layout, so a reboot of the Mac doesn’t lose the shape of the work.

--shell. Without it, leaving zellij ends the connection, which is what I want on a phone. With it, detaching or pressing Esc drops into a normal login shell instead.

Locked by default

Zellij’s default keymap catches a lot of keys, and over a remote connection, with editors and agents running inside, every caught key is one the program in the pane doesn’t get. So my config starts from nothing and stays out of the way:

default_mode "locked"
show_startup_tips false

keybinds clear-defaults=true {
    // In locked mode, zellij hears one key: the one that unlocks it.
    locked {
        bind "Ctrl g" { SwitchToMode "normal"; }
    }

    // Moving between panes and tabs works without unlocking.
    shared_among "normal" "locked" {
        bind "Alt left" { MoveFocusOrTab "left"; }
        bind "Alt down" { MoveFocus "down"; }
        bind "Alt up" { MoveFocus "up"; }
        bind "Alt right" { MoveFocusOrTab "right"; }
    }

    // Everywhere else, Ctrl g locks again.
    shared_except "locked" "renametab" "renamepane" {
        bind "Ctrl g" { SwitchToMode "locked"; }
        bind "Ctrl q" { Quit; }
    }
}

Everything else (panes, tabs, resizing, scrolling, sessions) lives behind Ctrl g, and every action that changes something ends by locking again: NewPane "down"; SwitchToMode "locked";. Unlock, do one thing, and you’re back to a terminal that passes every key through.

A project in one command

Each project wants the same things every time: a directory, a few panes, a dev server, and its ports reachable from the iPad. A small set of shell functions saves that as a preset.

znew captures the session I’m in. Zellij can describe its own current layout, so the preset records exactly what’s on screen:

znew myapp 3000,3001,4321
# Created preset: myapp
# Layout source: captured current zellij layout
# Start with: zuse myapp

Under the hood it’s zellij action dump-layout written to a .kdl file, plus a small file with the project’s directory and ports. Started outside zellij, it writes a plain default instead: a wide pane and a narrow one running the dev server.

layout {
  pane split_direction="vertical" {
    pane size="70%" {
      command "zsh"
    }
    pane size="30%" {
      command "zsh"
      args "-lc" "nr dev; exec zsh"
    }
  }
}

zuse myapp brings it all back. It serves the project’s ports over Tailscale, then attaches to the session if it exists, or opens a new one with the saved layout in the project’s directory:

zresetserve || return 1
zserve "${PORTS_CSV}" || return 1

if _z_has_session "${session}"; then
  zellij attach "${session}"
  return
fi

(
  cd "${PROJECT_DIR}" || exit 1
  zellij --session "${session}" --new-session-with-layout "${LAYOUT_FILE}"
)

zserve is the part that makes the iPad useful. tailscale serve publishes a local port to the Tailscale network over HTTPS, with a real certificate. The first port takes the default, 443; each next one gets its own HTTPS port, so three dev servers can run side by side and stay reachable:

_z_https_port_for_index() {
  echo $((6443 + ($1 * 1000)))   # second port → 8443, third → 9443
}

if (( idx == 1 )); then
  tailscale serve --bg "${port}"
else
  tailscale serve --bg --https="$(_z_https_port_for_index "${idx}")" "${port}"
fi

One detail in the saved layouts earns its place: panes can start suspended. The Médicis preset opens with the coding agent on the left and the dev server on the right, but neither starts until I press Enter in its pane:

// From the Médicis preset, simplified.
pane split_direction="vertical" {
    pane command="opencode" name="Opencode" size="50%" {
        start_suspended true
    }
    pane command="nr" name="Dev" size="50%" {
        args "dev"
        start_suspended true
    }
}

So reopening a project is free. Nothing runs that I didn’t ask for, and everything is one keystroke away.

The whole loop

From any device: mosh mac-mini -- zpick, pick the project’s session or type its name, and I’m back where I left it, with its servers on their usual ports. When I close the lid, nothing stops. When I open the iPad, it’s still there.

§ 05 · Contact

Your turn.

Tell me what you're building. I reply within a day, from Fès.

Book a 30-minute call

ali@mejbar.com. The calendar only offers the times I'm really free.