Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 70 additions & 14 deletions bin/zellij-picker
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ from pathlib import Path
import re
import runpy
import shlex
import signal
import uuid
import subprocess
import sys
import time
Expand Down Expand Up @@ -44,8 +46,9 @@ def status_pipe(name, payload, *, session, timeout=5):
session=session, timeout=timeout)


def snapshot(session, kind, timeout=5):
result = json.loads(run_zellij("action", f"list-{kind}", "--json", session=session, timeout=timeout))
def snapshot(session, kind, timeout=5, *, show_all=False):
options = ["--all"] if show_all else []
result = json.loads(run_zellij("action", f"list-{kind}", "--json", *options, session=session, timeout=timeout))
if not isinstance(result, list):
raise RuntimeError(f"Zellij did not return a {kind} list")
return result
Expand Down Expand Up @@ -518,7 +521,34 @@ def swap_tab(session):
raise


def move_pane(session):
def gather_panes(session, request):
# Expiry bounds new work, not the lifetime of an already-issued native call.
# Only the same plugin's serialized stop acknowledgement releases this helper.
request["operation"] = uuid.uuid4().hex
request["expires"] = int(time.time() * 1000) + 15000
previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
try:
try:
status_pipe("lane-move-pane", request, session=session)
except (OSError, RuntimeError, subprocess.TimeoutExpired):
pass
print("Confirming pane transfer has stopped. Keep this pane open; cancellation is deferred.", flush=True)
while True:
try:
output = status_pipe("lane-move-status", request, session=session)
stopped = json.loads(output)
if stopped.get("stopped") is True:
return stopped["result"]
except (OSError, RuntimeError, subprocess.TimeoutExpired, ValueError):
pass
# Reconciliation never resends the move. If the server is
# unreachable, retain the source anchor rather than guess it stopped.
time.sleep(0.25)
finally:
signal.signal(signal.SIGINT, previous)


def move_pane(session, *, all_panes=False):
# Zellij records the originating pane before it opens this floating command.
# Never infer it from mutable focus or from the picker’s own pane ID.
if (Path.home() / ".config/zellij/config.kdl").resolve().name == "config-legacy.kdl":
Expand All @@ -538,30 +568,56 @@ def move_pane(session):
raise RuntimeError("Cannot identify the picker’s attached client; reopen the move picker")
owned = owned_titles(session)
choices = {str(tab["tab_id"]): tab for tab in snapshot(session, "tabs")}
rows = ["new\tNew Tab"] + [
rows = ([] if all_panes else ["new\tNew Tab"]) + [
f"{key}\t{tab['position']}: {display_tab_name(tab, panes, owned=owned)}" +
(" [current]" if tab["tab_id"] == source["tab_id"] else "")
for key, tab in sorted(choices.items(), key=lambda item: item[1]["position"])
]
selected = choose(rows, "Move pane to tab", "--preview-move")
selected = choose(rows, "Move all panes to tab" if all_panes else "Move pane to tab", "--preview-move")
if selected is None:
return
if selected != "new" and selected not in choices:
if selected == str(source["tab_id"]):
return
if selected not in choices and (all_panes or selected != "new"):
raise RuntimeError("Invalid destination selection")
if selected != "new" and not any(str(tab["tab_id"]) == selected
for tab in snapshot(session, "tabs")):
raise RuntimeError("The destination tab closed; reopen the move picker")
current = snapshot(session, "panes", show_all=all_panes)
if not any(not p["is_plugin"] and p["id"] == source_id and
p["tab_id"] == source["tab_id"] and not p["is_suppressed"]
for p in snapshot(session, "panes")):
and not p["exited"] for p in current):
raise RuntimeError("The original pane changed; reopen the move picker")
request = {"token": owners[0]["token"], "source": source_id,
"source_tab": source["tab_id"], "picker": picker_id,
"destination": None if selected == "new" else int(selected)}
# Single-shot mutation. A lost reply must never replay a pane transfer.
reply = status_pipe("lane-move-pane", request, session=session).strip()
if all_panes:
# This one live screen snapshot carries both stable tab IDs and complete
# pane state. Never join a cached PaneManifest's positions to live tabs.
source_panes = [p for p in current if p["tab_id"] == source["tab_id"]]
if any(not p["is_plugin"] and p["is_suppressed"] for p in source_panes):
raise RuntimeError("Restore suppressed terminals or close scrollback editors before moving all panes")
if not any(not p["is_plugin"] and p["id"] == picker_id for p in source_panes):
raise RuntimeError("The picker changed; reopen it")
# Keep original tiled terminals until floating work has left: even an
# externally closed helper must not strand floats in a tiled-empty tab.
request["sources"] = [p["id"] for p in sorted(source_panes,
key=lambda p: (not p["is_floating"], p["id"]))
if not p["is_plugin"] and p["id"] != picker_id and
p["is_selectable"] and not p["exited"]]
if source_id not in request["sources"]:
raise RuntimeError("The original pane changed; reopen the move picker")
reply = gather_panes(session, request)
else:
# Single-shot mutation. A lost reply must never replay a pane transfer.
reply = status_pipe("lane-move-pane", request, session=session).strip()
if all_panes:
try:
status_pipe("lane-move-ack", request, session=session)
except (OSError, RuntimeError, subprocess.TimeoutExpired):
pass # Result retention cleanup never starts or continues a transfer.
if reply != "moved":
raise RuntimeError(reply or "Pane transfer was not confirmed; inspect the tabs before retrying")
raise RuntimeError(reply or "Pane transfer was not confirmed; inspect both tabs before retrying")


def pick_session(session):
Expand All @@ -585,9 +641,9 @@ def pick_session(session):

def main():
if len(sys.argv) < 2 or sys.argv[1] not in (
"tabs", "swap-tab", "move-pane", "sessions", "clients", "--preview-tab", "--preview-move", "--preview-session"
"tabs", "swap-tab", "move-pane", "move-all-panes", "sessions", "clients", "--preview-tab", "--preview-move", "--preview-session"
):
raise RuntimeError("Usage: zellij-picker tabs|swap-tab|move-pane|sessions|clients (inside Zellij)")
raise RuntimeError("Usage: zellij-picker tabs|swap-tab|move-pane|move-all-panes|sessions|clients (inside Zellij)")
session = os.environ.get("ZELLIJ_SESSION_NAME")
if not session:
raise RuntimeError("Run zellij-picker inside a Zellij session")
Expand All @@ -602,8 +658,8 @@ def main():
pick_tab(session)
elif mode == "swap-tab":
swap_tab(session)
elif mode == "move-pane":
move_pane(session)
elif mode in ("move-pane", "move-all-panes"):
move_pane(session, all_panes=mode == "move-all-panes")
elif mode == "clients":
pick_client(session)
else:
Expand Down
6 changes: 4 additions & 2 deletions docs/zellij-keybinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The main session normally stays in **Locked** mode, passing application shortcut
| Next tab / last-focused tab | **Ctrl-f n** / **Ctrl-f Ctrl-p** |
| Select a tab by position | **Ctrl-f 0** through **Ctrl-f 9** (modern configuration) |
| Move this pane to a tab | **Ctrl-f m** |
| Move all live panes to an existing tab | **Ctrl-f M** (modern configuration) |
| Open or hide the shared popup | **Ctrl-f P** |
| Select and copy terminal text | **Ctrl-f e**, select in Neovim, **y**, then `:q!` |
| Paste the session's last Neovim yank | **Ctrl-f p** (modern configuration) |
Expand Down Expand Up @@ -73,6 +74,7 @@ Bare **Ctrl-h/j/k/l** is supplied by the pane-navigation plugin, not by the pref
| `J` / `K` | Move the pane down / up within the tab |
| `!` | Move the focused pane to a new tab |
| `m` | Choose New Tab or an existing destination tab for this pane |
| `M` | Move all live panes to an existing tab (modern configuration) |
| `Ctrl-a` | Next local tab needing you, otherwise activity/bell, in tab order |

**Tab positions are not tab names.** Modern numeric shortcuts are zero-based even if an automatically generated name still says `Tab #1`. The underlying Zellij `GoToTab` action is one-based. The custom bar and picker prepend the zero-based position; use that position rather than a number embedded in the name.
Expand All @@ -81,7 +83,7 @@ Bare **Ctrl-h/j/k/l** is supplied by the pane-navigation plugin, not by the pref

**Swap:** `Ctrl-f W` opens the fuzzy tab menu with pane previews. Enter exchanges the original and selected tab positions; intermediate tabs return to their original positions. Esc or selecting the current tab changes nothing. Clients retain their original tab IDs. The swap is a sequence of visible adjacent moves, not an atomic operation; on concurrent changes or uncertain results it stops with an inspection warning while the picker remains open. Enter or Ctrl-c dismisses the warning. Closing the picker during movement can leave partial tab order without a warning; ordinary panes and processes remain intact. Inspect positions after an interruption. See [tab swap limits](./zellij.md#familiar-keys).

**Pane destination picker:** Enter moves the original live pane, preserving its process and scrollback. Esc or Ctrl-c cancels; selecting the current tab does nothing. The helper needs the compiled status plugin and pinned runtime, including their permissions. Movement is within the current session; no key moves a live pane into another session.
**Pane destination picker:** `m` moves the original live pane, preserving its process and scrollback. Esc or Ctrl-c cancels; selecting the current tab does nothing. The helper needs the compiled status plugin and pinned runtime, including their permissions. `M` instead moves all live tiled and floating terminals from this tab to an existing tab. Neither cancellation nor selecting the current tab moves anything. Plugins and the chooser are excluded. Before using `M`, expose or restore suppressed source terminals, including hidden native stack members, or close their scrollback editors. Destination stacking is supported. Movement is within the current session; no key moves a live pane into another session.

The tab picker lists this session's tabs, prioritizing native attention when available. Attention uses native OSC 7501 state and ordinary bells; see [agent status and attention](./zellij.md#native-agent-status-and-attention).

Expand Down Expand Up @@ -266,7 +268,7 @@ Apply configuration with `make zellij`. To build the modern bar and reapply it,
## tmux habits that do not transfer

- **Direct balance and golden ratio:** prefix `=`, `g`, and `G` remain unbound. Cycle to the equalize layout with Space or Ctrl-o; it may rearrange panes.
- **Move across sessions or park in `later`:** prefix `H`, `S`, and `M` are unbound. Use `m` or `!` only for tabs within this session.
- **Move across sessions or park in `later`:** prefix `H` and `S` are unbound. `m`, `M`, and `!` only move panes between tabs within this session. Legacy configuration leaves `M` unbound.
- **Reload and refresh shortcuts:** prefix `R`, `Ctrl-l`, and `*` have no bindings in these files.
- **Timed repeat:** prefix actions return to Locked mode unless they explicitly enter a mode. Use Ctrl-r for repeated resizing. W is a fuzzy tab swap, not a repeatable reorder mode.
- **Stock Zellij defaults:** `clear-defaults=true` removes them. Follow this reference and plugin interfaces rather than the upstream default shortcut sheet.
Expand Down
6 changes: 5 additions & 1 deletion docs/zellij.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ The swap uses adjacent native moves, so other clients can see intermediate posit

`Ctrl-f m` follows the tmux pane-movement menu. It moves the original live pane, preserving its process and scrollback. Esc cancels; choosing the current tab does nothing. This menu needs the compiled status plugin and pinned runtime; rerun `make zellij-status zellij` and restart old sessions after upgrading. Existing-tab moves reserve a temporary inert split and replace it with the original pane, without running the default shell’s startup hooks or requiring additional plugin permissions. Cross-session movement remains unsupported. `Ctrl-f !` remains an optional direct shortcut, not a replacement for the menu.

`Ctrl-f M` moves all live tiled and floating terminals from the current tab to a selected existing tab in the modern configuration. There is no New Tab option; cancel and current-tab selection leave the layout unchanged. Plugins, exited commands, and the chooser are not transferred. A source with suppressed terminals is rejected, including hidden members of native stacks as well as scrollback-editor backing terminals. Expose or restore those source panes, or close the scrollback editor, before gathering. Native stacking in the destination is supported; this restriction applies to hidden panes in the source.

After confirmation, the chooser temporarily becomes tiled to keep the source tab alive while native pane replacements run. It exits after receiving the result. The invoking client follows its original pane; unrelated clients stay on their own tabs. If the source tab disappears, clients viewing it follow Zellij's native tab-close behavior. Floating terminals become tiled at the destination. Movement is sequential, not atomic: if a pane/tab changes or a reply is lost, inspect both tabs before retrying. If the CLI reply times out, the chooser stays open and defers Ctrl-c while it waits for the plugin to acknowledge that the operation has stopped. A deadline prevents queued requests from starting late or continuing with more transfers indefinitely. If the server becomes unreachable, keep the chooser open until it can confirm the stop. After acknowledgement an error remains visible until Enter; completed moves are not replayed or rolled back.

**Scroll mode:** `j/k` scroll, Ctrl-f/Ctrl-b page, Ctrl-d/Ctrl-u half-page, and `g/G` jump to the start/end. `/` starts a search; `n/N` find next/previous matches. `e` opens Neovim for arbitrary Vim selection. `y` copies an existing selection and returns to the prompt. `m` selects a command at the scroll position, and `c` copies the last command output when the application supplies OSC 133 shell markers. Esc or `q` returns to the bottom.

### Keyboard selection and paste
Expand Down Expand Up @@ -243,7 +247,7 @@ These are line limits, **not a byte budget or a guarantee against memory exhaust
| Exact Claude conversation restoration via saved session IDs | Zellij's native command restoration is enabled; the custom `claude --resume` session-ID integration remains tmux-specific. |
| Rex OSC 7501 delivery through the patched tmux server | No replacement transport in this configuration. Upstream development features are not assumed to exist in stable 0.45.1. |
| Smart bare Ctrl-h/j/k/l across Neovim and tmux panes | Supported in Neovim normal/insert modes, shells, and foreground applications such as Codex. Nested tmux/Zellij retains its own controls. |
| Moving live windows/panes across sessions, including parking in `later` | Unsupported by Zellij. `H`, `S`, and `M` are not assigned a restart-based substitute. Native movement between tabs in the same session is available. |
| Moving live windows/panes across sessions, including parking in `later` | Unsupported by Zellij. `H` and `S` are not assigned a restart-based substitute. `m`, `M`, and `!` move live panes between tabs in the same session. |
| Vi visual/rectangle copy mode and shared paste buffers | Neovim provides keyboard selection. `Ctrl-f p` uses the session's private Neovim-yank buffer; the terminal shortcut pastes the system clipboard. |
| Zero-based windows | Tab labels, picker labels, and `0`–`9` keys are zero-based; Zellij's underlying CLI positions remain one-based. |
| Three-cell resizing and timed repeat bindings | Native Zellij resize steps; an explicit repeatable mode under prefix+Ctrl-r. |
Expand Down
Loading
Loading