Two related quality-of-life upgrades to ~/.config/zellij:
Ctrl+Shift+Ais currently a session-only picker. It opens a list of zellij sessions (viadispatch.py:_list_sessions) and switches to whichever the user picks, landing on whatever tab was last focused in that session. The user wants it upgraded to a flat list of every tab in every live session, with the picker jumping directly to the chosen(session, tab)pair. Without this, you cannot navigate by intent — only by session, then by tab via a second action.- Tab names are static and useless (default:
Tab #N). For the unified picker to be useful, tab names must reflect the currently running foreground program (e.g.vim,python,cargo) and fall back to the working directory when the shell is idle. This must work for both shells the user runs: WSL bash and PowerShell 7+ (pwsh).
These are designed together because they're mutually reinforcing: Feature 2 supplies the tab metadata that makes Feature 1's flat list meaningful, and Feature 2's hooks become load-bearing infrastructure (cache writers) if zellij's cross-session enumeration CLI proves unreliable (see Phase 0 below).
Zellij's plugin API exposes SessionUpdate(Vec<SessionInfo>) events where each SessionInfo.tabs: Vec<TabInfo> provides cross-session tab data, and the switch_session_with_focus(name, tab_position, pane_id) plugin function is the canonical atomic switch-and-focus primitive (Plugin API Types, Plugin API Commands). This is the architecturally "right" answer.
We're not going that route because:
- Zellij 0.44.0 reportedly added
zellij action switch-sessionas a CLI command with what appears to be a--tab-positionflag (CLI Actions docs) — atomic switch+focus from outside any plugin, callable from Python. The flag's existence is unverified in the release notes; Phase 0a's first task is to confirm with--help. If absent, this decision flips to "build a Rust WASM plugin" — but the toolchain weight (cargo, WASI target, plugin-API version pinning) is significant enough that we want to avoid it if at all possible. - The existing
dispatch.pyis already a polished single-instance Python picker with raw-mode UI, recency tracking, and aHANDLERSregistry contract. Extending it is one function and one dict entry (dispatch.py:879). - A middle path the validator surfaced —
zellij pipe --name <pipe-name>driving a long-lived plugin process — would serialize renames through a single queue (eliminating Decision 6's race concerns) and is available in zellij 0.38+. It's not chosen as the default because it still requires building & loading a plugin, but it's a documented fallback if the per-call CLI approach proves too noisy in practice.
The headline risk: zellij --session X action query-tab-names against a background (unattached) session is undocumented behavior. The closest documented precedent is Issue #3863, which reports that focus-mutating CLI actions silently no-op on background sessions — but the issue is still open with no maintainer response, so this is "community-reported, unconfirmed" rather than "officially documented." Whether read-only queries (query-tab-names) behave the same way is genuinely unknown without testing.
Revised approach (after validation pass): Don't build the cache speculatively. Phase 0 determines whether it's needed.
- If live
query-tab-namesworks on background sessions (likely — read-only queries often work where focus-mutations don't): the picker simply calls it per session at open time. No cache needed; skip Phase 2. This eliminates an entire class of staleness bugs (tab closes viaCtrl+Shift+Wwithout a shell command firing — see "Position drift" below). - If it doesn't: implement the cache as a fallback layer, with the explicit understanding that cached positions are advisory only, not source-of-truth. The picker always tries live query first per session and falls back to cache only when the query fails. Cached names survive position drift; cached positions don't.
Position-drift correction. The original plan claimed "every hook re-resolves position" — the validator correctly caught that this is wrong: closing a tab via the zellij Ctrl+Shift+W keybind doesn't fire any shell hook (no command ran), so cached positions for surviving tabs become stale until a hook fires in each one. The mitigation: live-query is always preferred at picker-open time; when it works, the cache is bypassed. When cache must be used, cross-reference cache entry count against live query-tab-names output — if counts diverge, mark those positions as untrusted and fall back to session-only switch (lose the tab-jump but keep the session-jump).
Cache design (if Phase 0 forces it): mirrors the existing _RECENCY_DIR pattern at dispatch.py:538, lives under $TEMP/zellij-tab-names/<session>/<tab_id>.json with a monotonic sequence number (see Decision 5 / race mitigation below), prune logic mirrors the existing _list_sessions cleanup at dispatch.py:612.
Decision 3 — Bash tab naming via bash-preexec, not raw DEBUG-trap
bash-preexec (rcaloras/bash-preexec) is the only reliable way to get zsh-style preexec/precmd semantics in bash. It de-duplicates pipeline DEBUG-trap firings (so ls | grep foo fires once with the full command, not once per stage) and is used in production by iTerm2, Ghostty, and Bashhub. A raw trap '...' DEBUG + PROMPT_COMMAND would fire on every pipeline stage and break on shell functions. Source: rcaloras/bash-preexec README; verification of the pattern: jcd.pub's zellij+zsh writeup which uses the equivalent zsh add-zsh-hook preexec (directly portable).
PowerShell has no native preexec hook — confirmed by PowerShell Issue #15271 "Feature Request: pre-exec hook" (open as of May 2026, no implementation). The least-bad pre-execution hook in pwsh 7+ is Set-PSReadLineOption -CommandValidationHandler, which fires on Enter, receives a parsed CommandAst, and can side-effect (rename the tab) without throwing (Set-PSReadLineOption docs Example 8). For the post-command idle reset, the standard prompt function (runs before each prompt) is the right place — that's where the existing attach-main.ps1:Get-ZellijAutoName style of CWD/git-root naming belongs.
Rejected alternative: $ExecutionContext.SessionState.InvokeCommand.PreCommandLookupAction fires on every internal pipeline stage and nested call, producing noisy rapid-fire renames. No dotfiles repo uses it for this purpose.
Issue #4602 documents that undo-rename-tab has no tab-ID parameter and always acts on the currently focused tab. If the user tab-switches while a command runs and the postcmd hook fires undo-rename-tab, it un-renames the tab the user switched to, not the original. The fix in all reference implementations: explicitly re-set the name in precmd to the idle value (basename of $PWD or git-root leaf), never undo.
Both shells need backgrounded renames to dodge Issue #4199's synchronous concatenation. But naive backgrounding introduces an order-of-completion race: preexec fires rename-tab vim & (job A); command exits 50ms later; precmd fires rename-tab myproject & (job B). If job B IPC finishes before job A, the tab briefly shows myproject, then job A lands and overwrites with vim — exactly the opposite of intended.
Mitigation per shell:
- Bash: gate the rename call with
flockon a per-tab lockfile (/tmp/zellij-rename.<session>.<tab_id>.lock).flockis in WSL by default. Backgrounded jobs serialize through the lock; later jobs see the lock contested and the most recent wins because they queue. - Pwsh: don't use
Start-Jobper rename — it's heavyweight (full child PowerShell process). Use a single module-scope debounce: cancel any pending timer, schedule a new one withRegister-ObjectEventon a[System.Timers.Timer]to fire 20ms later with the latest name. Coalesces a typing burst into a single rename.
The cache writer (if Phase 2 is built) also needs a monotonic sequence number written alongside each entry; the reader (picker) takes the highest-sequence entry per tab.
Five diagrams. The first three show the happy path of each major piece. The fourth shows the single most-easy-to-miss failure mode (silent failure in WSL). The fifth shows the decision tree Phase 0 walks.
Windows host
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ Windows-native zellij.exe (server, single process) │
│ ───────────────────────── │
│ │ Session A │ Session B │ Session C │ │
│ │ ┌────┬────┐ │ ┌────┐ │ ┌────┐ │ │
│ │ │ t0 │ t1 │ │ │ t0 │ │ │ t0 │ │ │
│ │ │pwsh│bash│ │ │pwsh│ │ │pwsh│ │ │
│ │ └────┴────┘ │ └────┘ │ └────┘ │ │
│ └──────────┬──────────────────────────────────────────────┘ │
│ │ child procs inherit: │
│ │ ZELLIJ=0 ZELLIJ_SESSION_NAME=A ZELLIJ_PANE_ID=… │
│ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ pwsh (host) │ │ bash (in WSL) │ │
│ │ │ │ │ │
│ │ hooks (Feat. 2): │ │ hooks (Feat. 2): │ │
│ │ • CmdValidation │ │ • bash-preexec │ │
│ │ Handler │ │ • flock gate │ │
│ │ • prompt wrap │ │ • ZELLIJ_CMD= │ │
│ │ • 20ms debounce │ │ zellij.exe │ │
│ └─────────┬────────┘ └────────┬─────────┘ │
│ │ rename-tab "vim" │ rename-tab "vim" │
│ │ (via zellij.exe IPC) │ (via zellij.exe interop)│
│ └─────────────┬─────────────┘ │
│ ▼ │
│ back to zellij.exe server │
│ │ │
│ ▼ │
│ tab bar updates to "vim" │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ │
│ Picker (Feature 1): │
│ │
│ user keypress Ctrl+Shift+A │
│ │ │
│ ▼ │
│ zellij key handler ──► Run "zellij-dispatch" "switch-session" │
│ (uv-installed binary on PATH) │
│ │ │
│ ▼ │
│ python dispatch.py │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ zellij ls query-tab-names per session │
│ │ │
│ ▼ │
│ flat picker (raw-mode UI) │
│ │ │
│ user picks (Session B, tab "vim") │
│ │ │
│ ▼ │
│ zellij action switch-session B --tab-position 1 │
│ │ │
│ ▼ │
│ focus lands on B/vim │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Ctrl+Shift+A │ (key binding declared at config.kdl:556)
└────────┬────────┘
│
▼
┌────────────────────────────────┐
│ zellij Run keybind │ fires the binary; no shell shim
│ → zellij-dispatch │ (uv-installed PATH entry)
│ switch-session │ (avoids ~ / $HOME issues per
│ │ zellij issues #2574, #2288, #4527)
└────────────────┬───────────────┘
▼
┌──────────────────────────────────────────────┐
│ dispatch.py HANDLERS["switch-session"] │ single-instance
│ → switch_session() │ lock at line 657
│ → _single_instance_lock() (line 657) │ kills any prior
└─────────────────┬─────────────────────────────┘ picker still open
▼
┌──────────────────────────────────────────────┐
│ ENUMERATE │
│ for s in `zellij ls`: │
│ tabs = zellij --session s action │ (Phase 0b confirms
│ query-tab-names │ this works for
│ for (pos, name) in enumerate(tabs): │ background sessions;
│ rows.append({s, pos, name, │ if not → cache path)
│ clients, ticks}) │
└─────────────────┬─────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ SORT (mirrors dispatch.py:645) │ current session last,
│ key = (session_recency, tab_position) │ most-recent-other
│ │ session at row 0
└─────────────────┬─────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ RENDER (raw-mode picker, reuses │
│ _pick_profile_interactive at dispatch.py: │
│ 305 — same key-handling, render-line, │
│ redraw pattern as the profile picker) │
│ │
│ 1 main / vim (2 attached) [3m] ◄── default highlight
│ 2 main / cargo run (2 attached) [3m]
│ 3 scratch / bash (0 attached) [1h]
│ 4 (current) / pwsh (1 attached) [0s]
│
│ ↑/↓ enter esc 1-9 jump
└─────────────────┬─────────────────────────────┘
▼
user picks row 3 (scratch / bash)
│
▼
┌──────────────────────────────────────────────┐
│ JUMP │
│ if row.session == current: │
│ zellij action go-to-tab-name <row.tab> │ ◄── F1.S2 path
│ else: │
│ zellij action switch-session <row.session> │ ◄── F1.S3 path
│ --tab-position <row.position> │ (Phase 0a flag check)
└─────────────────┬─────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ INVARIANT (verification step 8/9): │
│ visible tab name == row.tab_name │ ← the success predicate
│ visible session == row.session │ that catches the
│ │ silent miss-jump F1.F2
└───────────────────────────────────────────────┘
USER TYPES: vim main.py + Enter
│
▼
┌─────────────────────────────────────┐
│ bash-preexec fires preexec hook │ (zsh-style preexec emulation;
│ __zellij_preexec "$1" │ rcaloras/bash-preexec)
│ $1 = "vim main.py" (full command) │
└──────────┬──────────────────────────┘
│
▼
cmd="${1%% *}" // "vim main.py" → "vim"
cmd="${cmd##*/}" // /usr/bin/python3 → python3
│
▼
┌─────────────────────────────────────┐
│ __zellij_rename "vim" │ backgrounded subshell
│ ( flock -w 0.5 9 || exit │ contests a per-pane lockfile
│ "$ZELLIJ_CMD" action │ — at most one rename in
│ rename-tab "vim" │ flight at a time
│ ) 9>/tmp/zellij-rename.<s>.<t>.lock&│
└──────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ zellij.exe IPC to Windows server │ (via WSL interop because
│ → tab bar shows "vim" │ ZELLIJ_CMD=zellij.exe)
└─────────────────────────────────────┘
│
│ ... vim runs for 20 sec ...
▼
┌─────────────────────────────────────┐
│ vim exits, shell regains prompt │
│ bash-preexec fires precmd hook │
│ __zellij_precmd │
└──────────┬──────────────────────────┘
▼
idle = git-root-leaf OR basename($PWD)
│
▼
┌─────────────────────────────────────┐
│ __zellij_rename "<idle>" │ same flock-gated path
└──────────┬──────────────────────────┘ — queues behind any
▼ in-flight rename
┌─────────────────────────────────────┐
│ tab bar reverts to "<idle>" │
│ before next prompt fully drawn │ (F2.S2 invariant)
└─────────────────────────────────────┘
WHY flock IS REQUIRED — the race it prevents:
Without flock (validator concern #4): With flock:
preexec → rename "vim" & preexec → lock → rename "vim" → unlock
cmd runs cmd runs
precmd → rename "idle" & precmd → lock (blocks until A done)
→ rename "idle" → unlock
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ job A │ │ job B │ │ job A │ │ job B │
│ "vim" │ │ "idle" │ │ "vim" │ │ "idle" │
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
│ race for IPC │ │ COMPLETES │
▼ ▼ ▼ FIRST ▼
could land in any order ordered: vim → idle
Possible bug: B wins, then A lands Result: brief "vim",
on top → tab says "vim" forever then "idle". Correct.
(matches F2.F2 "stale name" symptom)
Windows host
(running zellij.exe as the server)
│
▼
┌───────────────────────────────┐
│ zellij.exe (Windows server) │
│ socket: \\?\pipe\zellij-… │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ tab 0 │ │ tab 1 │ … │
│ │ pwsh │ │ WSL │ │
│ │ │ │ bash │ │
│ └─────────┘ └────┬────┘ │
└────────────────────│──────────┘
│ this tab's process
│ runs inside the WSL VM
▼
┌──────────────────────────────┐
│ WSL (Linux kernel) │
│ bash hook fires rename │
└──────────────┬───────────────┘
│
┌─────────────────┴──────────────────┐
│ │
WRONG path RIGHT path
(bare `zellij`) (`zellij.exe` via interop)
│ │
▼ ▼
┌─────────────────┐ ┌────────────────────┐
│ Linux ELF binary│ │ zellij.exe via │
│ /usr/bin/zellij │ │ WSL interop │
│ │ │ │
│ talks to: │ │ talks to: │
│ /tmp/zellij-* │ │ \\?\pipe\zellij-… │
│ (Linux socket) │ │ ↑ the SAME server │
│ │ │ that hosts this │
│ NO SERVER │ │ tab │
│ THERE! │ │ │
│ (or a wholly │ │ │
│ different one) │ │ │
└────────┬────────┘ └─────────┬──────────┘
│ │
▼ ▼
command exits 0 tab name updates
─ no error printed ─ visibly to "vim"
tab name DOES NOT change │
│ ▼
▼ ✅ INVARIANT HOLDS
❌ SILENT FAILURE
(F2.F1 symptom, but
user may not notice for
minutes/hours)
Guard in zellij-tab-name.sh:
if command -v zellij.exe >/dev/null 2>&1; then
ZELLIJ_CMD=zellij.exe ◄── always preferred when present
elif command -v zellij >/dev/null 2>&1; then
ZELLIJ_CMD=zellij ◄── only fallback (e.g. pure-Linux host)
else
return ◄── neither → bail rather than fail silently
fi
export ZELLIJ_CMD
┌───────────────┐
│ START PHASE 0 │
└───────┬───────┘
│
▼
┌──────────────────────────────────┐
│ 0a — does `--tab-position` flag │
│ exist on `switch-session`? │
└────────────────┬─────────────────┘
│
┌─────────┴─────────┐
│ │
YES NO
│ │
│ ▼
│ ╔═══════════════════════════╗
│ ║ STOP. CLI path is dead. ║
│ ║ ║
│ ║ • Open tracker for Rust ║
│ ║ WASM plugin path using ║
│ ║ switch_session_with_ ║
│ ║ focus (Plugin API) ║
│ ║ • Revisit when zellij ║
│ ║ upgraded ║
│ ╚═══════════════════════════╝
│
▼
┌──────────────────────────────────────┐
│ 0b — does `zellij --session X │
│ action query-tab-names` work │
│ on background sessions, < 50ms? │
└──────────────────┬───────────────────┘
│
┌───────────┴───────────┐
│ │
YES & FAST FAILS or > 200ms
│ │
│ ▼
│ ┌──────────────────────────┐
│ │ ENABLE Phase 2 (CACHE) │
│ │ │
│ │ • Shell hooks write to │
│ │ $TEMP/zellij-tab-names │
│ │ • Picker reads cache for │
│ │ background sessions │
│ │ • Cached positions are │
│ │ ADVISORY (cross-check │
│ │ count at picker open) │
│ └─────────────┬────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ 0c — in WSL bash inside a Windows zellij │
│ pane: $ZELLIJ visible AND │
│ zellij.exe rename-tab works? │
└──────────────────┬───────────────────────┘
│
┌──────────┴──────────┐
│ │
YES NO
│ │
│ ▼
│ ╔═══════════════════════════════╗
│ ║ BLOCKER for bash hook. ║
│ ║ ║
│ ║ Investigate: ║
│ ║ • WSL env-forwarding config ║
│ ║ (wsl.conf, env vars) ║
│ ║ • Is zellij.exe on PATH ║
│ ║ inside WSL? ║
│ ║ Don't deploy bash hooks until ║
│ ║ resolved. ║
│ ╚═══════════════════════════════╝
│
▼
╔═══════════════════════════════════╗
║ ✓ ALL GATES PASSED. ║
║ ║
║ Proceed to Phase 1 (hooks) and ║
║ Phase 3 (picker upgrade). ║
║ ║
║ Phase 2 cache is built if and ║
║ only if 0b said it's needed. ║
╚═══════════════════════════════════╝
These five diagrams together answer:
- "What are the pieces?" → Diagram 1.
- "What happens when I press the keybind?" → Diagram 2.
- "What happens when I type a command?" → Diagram 3.
- "Where does this silently go wrong?" → Diagram 4.
- "What if the empirical tests say X?" → Diagram 5.
The implementation is decomposed into 11 components (C0–C10), each one buildable and verifiable in isolation. The table below is the master index; the dependency graph and ship-milestones beneath it tell you the order and what becomes user-visible at each stop.
| ID | Name | Scope (single sentence) | Depends on | Verify by (single observable test) | Effort |
|---|---|---|---|---|---|
| C0 | Phase 0 verification | Three CLI tests (0a flag, 0b background enum, 0c WSL binary) recorded in README before any code is touched. | — | README has zellij --help output + 0b session-switch test result + 0c zellij.exe rename screenshot. |
20 min |
| C1 | Hook scaffolding | Vendored bash-preexec.sh, empty stub zellij-tab-name.sh / .ps1, sourcing wired into .bashrc & $PROFILE. |
C0 | echo $ZELLIJ_TAB_NAME_LOADED returns 1 in fresh bash/pwsh sessions (stubs export this). |
30 min |
| C2 | Idle-name helper | __zellij_idle_name (bash) and __Zellij-IdleName (pwsh) — git-root-leaf or basename($PWD). No IPC yet. |
C1 | Manually call each; both return the same string for the same cd. (Mirrors Get-ZellijAutoName.) |
30 min |
| C3 | Rename primitive | __zellij_rename/__Zellij-Rename: gated IPC call (flock in bash, 20ms debounce timer in pwsh). No hooks yet. |
C2, C0a | Manually call __zellij_rename "test-name"; tab bar updates to test-name. F2.S4 burst test passes. |
1 hr |
| C4 | Bash auto-naming | preexec_functions/precmd_functions registration → command/idle cycle. |
C3 (bash) | Smoke test step 4 passes: cd /tmp; vim; :q cycles tmp → vim → tmp. |
30 min |
| C5 | Pwsh auto-naming | CommandValidationHandler + prompt wrap → command/idle cycle. |
C3 (pwsh) | Smoke test steps 5+6 pass (incl. multi-line statement doesn't flicker if). |
1 hr |
| C6 | (Conditional) Tab-name cache writer | HANDLERS["tab-cache-write"] in dispatch.py; hooks call it when ZELLIJ_TAB_CACHE_ENABLED=1. |
C4, C5, C0b says cache needed | $TEMP/zellij-tab-names/<sess>/<tab>.json is written and parseable after a rename. Skip if C0b is green. |
1.5 hr |
| C7 | Picker enumeration | New _list_session_tabs() in dispatch.py: live query-tab-names per session, falling back to cache (C6) if built. |
C0b (+ C6 if conditional) | Call from REPL: returns list of {session, tab_position, tab_name, ...} rows for all live sessions. |
1 hr |
| C8 | Picker UI render | New row-formatter + adapt _pick_profile_interactive for tab rows; replace _switch_session_interactive call. |
C7 | Open the picker (Ctrl+Shift+A): all tabs visible, ↑↓/enter/esc/digit keys work, Esc returns unchanged. | 1.5 hr |
| C9 | Picker jump action | Replace switch_session's final ["zellij", "action", "switch-session", name] with branched same-session/cross-session jump. |
C8, C0a | Full verification steps 8+9 pass: name-equality post-jump in both same-session and cross-session cases. | 1 hr |
| C10 | Polish & soak | README updates (per "done-gate" point 4), cache pruning verified, ≥4hr soak test passes, all F1.F*/F2.F* watched. | C9 | Done-gate four points all green. | 1+ day |
Total active build effort: ~9 hours of focused work (excluding soak). Spread across however many sittings you want.
┌──── C0 ────┐
│ Phase 0 │ (sub-tests run in parallel:
│ verify │ 0a + 0b + 0c, all read-only)
└─────┬──────┘
│ if 0a fails → ABORT; switch to plugin path
│ if 0b fails → C6 becomes required
│ if 0c fails → C4 (bash) is blocked
▼
┌──────────────┐
│ C1 │ vendor bash-preexec.sh,
│ scaffolding │ sourcing wired into shells
└──────┬───────┘
▼
┌──────────────┐
│ C2 │ idle-name helper functions
│ idle-name │ (no zellij IPC yet)
└──────┬───────┘
▼
┌──────────────┐
│ C3 │ rename primitive
│ rename IPC │ (flock / debounce)
└──────┬───────┘
│
┌─────────────┴────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ C4 │ │ C5 │ (independent;
│ bash auto- │ │ pwsh auto- │ can be done in
│ naming │ │ naming │ either order
└──────┬───────┘ └──────┬───────┘ or in parallel)
└─────────────┬───────────────┘
▼
╔═════════════════════════╗
║ MILESTONE M2/M3: ║
║ Feature 2 ships. ║
║ Tab names track ║
║ running program. ║
║ Picker can be deferred.║
╚════════════╤════════════╝
│
│ (C6 cache writer — only if C0b said so)
│ ↓ if needed: C4/C5 → C6 → C7
│ ↓ if skipped: C4/C5 → C7 directly
▼
┌──────────────┐
│ C7 │ picker enumeration
│ enum tabs │
└──────┬───────┘
▼
┌──────────────┐
│ C8 │ picker UI render
│ render │
└──────┬───────┘
▼
┌──────────────┐
│ C9 │ picker jump action
│ jump │
└──────┬───────┘
▼
╔═════════════════════════╗
║ MILESTONE M4: ║
║ Feature 1 ships. ║
║ Ctrl+Shift+A flat tab ║
║ picker works. ║
╚════════════╤════════════╝
▼
┌──────────────┐
│ C10 │ polish & soak
│ ship │
└──────┬───────┘
▼
╔═════════════════════════╗
║ MILESTONE M5: ║
║ Production-ready. ║
║ Done-gate complete. ║
╚═════════════════════════╝
| After | Milestone | What the user can actually do |
|---|---|---|
| C0 | M1 | Architecture validated. No new behavior, but you know if the plan is going to work or needs replan. |
| C3 | (interim) | Manual __zellij_rename "foo" updates the tab. Proves IPC works end-to-end. Not yet user-facing. |
| C4 | M2 | Bash tab names track the running program. Feature 2 partial — only one shell, but the half that's most common during dev. Stop here and use it for a day to flush out issues before C5. |
| C5 | M3 | Pwsh tab names also track. Feature 2 complete. Both shells. Picker still shows old session-only list (still useful, just less rich). |
| C7 | (interim) | Picker enumeration works from a REPL but isn't wired into the keybind yet. Useful for debugging only. |
| C8 | (interim) | Picker UI opens and lets you cancel, but selection doesn't jump yet. Visual check only. |
| C9 | M4 | Picker jumps to specific tabs across sessions. Feature 1 complete. Both features now operate together. |
| C10 | M5 | Production-ready: README updated, cache pruning verified, soak test passed, all failure modes seen-and-tested. |
The dependency graph allows several orderings. The recommended one optimizes for: (a) fastest path to something usable, (b) earliest catch of unknowns, (c) parallelization where it doesn't add risk.
- C0 — do all three sub-tests in parallel (single tmux/zellij window switch). This is the fastest 20 minutes you'll spend; it either greenlights the plan or saves you from building on sand.
- C1 + C2 — small, mechanical, low risk. Knock them out in one sitting so you have working idle-name helpers everywhere before you touch IPC.
- C3 (bash side first, then pwsh) — bash-preexec is a well-trodden pattern; pwsh's
CommandValidationHandleris the riskier one. Build the bash version first so you have a reference for "what success looks like" before tackling the pwsh peculiarities. - C4 — Bash auto-naming is a one-line addition to
preexec_functions. Reach M2 in under 3 hours from the start. - Pause and use C4 for a day. Discover any real-world rough edges (slow renames, prompt conflicts) before doubling them in pwsh.
- C5 — Pwsh auto-naming. Reach M3.
- C6 — only if C0b said the cache is needed. Skip otherwise; the live-query path in C7 covers it.
- C7 → C8 → C9 — picker work, strictly sequential (each depends on the previous). Build in one focused sitting because the integration testing is easier when context is fresh.
- C10 — soak and polish. Do this last because all earlier components must be in place to soak meaningfully.
Every component is small enough to roll back independently. If C5 (pwsh auto-naming) misbehaves: remove the . $HOME/.config/zellij/scripts/zellij-tab-name.ps1 line from $PROFILE — bash hooks (C4) keep working. If C9 (picker jump) regresses: revert that one function in dispatch.py; tab naming (C4/C5) still works because the two features only share the cache (C6), not control flow.
This is the property the component decomposition is designed to give you: each milestone is a place you can stop, ship, and resume later without coordinating multiple in-flight changes.
This phase has expanded scope after a validation review caught two unverified assumptions: (a) the --tab-position flag may not actually exist in the installed zellij version, and (b) WSL bash inside a Windows-native zellij pane probably needs zellij.exe, not bare zellij. Both must be confirmed before any code is written.
0a. Flag existence (BLOCKER if fails). Run on the host that will host the picker:
zellij --version # record version
zellij action switch-session --help # verify --tab-position flag exists
zellij action --help # see if list-tabs / query-tab-names are listedPaste the --help output into the README. If --tab-position is absent: stop. The plan's Phase 3 jump primitive doesn't exist; revisit by either upgrading zellij to 0.44.0+ or falling back to a Rust WASM plugin using switch_session_with_focus (Plugin API Commands).
0b. Background-session enumeration & jump. Open two concurrent sessions; detach from one. From inside the other:
zellij --session <background> action query-tab-names # multi-tab
zellij --session <background> action list-tabs # with --all if available
zellij action switch-session <background> --tab-position 0 # first tab
zellij action switch-session <background> --tab-position 2 # mid tab — catches off-by-one
time zellij --session <background> action query-tab-names # measure latency × 5Test against an EXITED session too (since zellij ls lists those and the picker must decide whether to filter them). Source for the underlying risk: Issue #3863 — "User can't tweak session (moving or changing pane/tab focus/position) via CLI if session is in background" — reports this behavior but is still open with no maintainer confirmation, so empirical verification is the only way to know what actually happens on this version.
0c. WSL binary identity (BLOCKER for the bash hook). Inside a WSL bash pane that is itself inside a Windows-native zellij session:
echo "$ZELLIJ" # should be non-empty (Windows env var via WSL interop)
which zellij # likely /usr/bin/zellij (Linux ELF) — wrong!
which zellij.exe # should resolve via WSL interop to the Windows .exe
zellij.exe action rename-tab "wsl-test" # should rename the surrounding tabIf bare zellij (Linux ELF) is installed inside WSL, it talks to a Linux Unix socket — a different zellij server, not the Windows one hosting the surrounding tab. The bash hook must invoke zellij.exe, not zellij. The plan's bash script (below) uses a ZELLIJ_CMD shim to handle this.
0d. Determine cache necessity (decision gate for the rest of the plan). Based on results:
- If 0b's
query-tab-namesagainst background sessions returns the tab list cleanly and latency is acceptable (~<50ms × N sessions for N up to ~10) → no cache needed. Live query at picker-open time, full stop. Skip Phase 2. - If 0b's
query-tab-namesagainst background sessions fails or hangs → cache becomes correctness-critical. Implement Phase 2 as planned, BUT treat position numbers in the cache as advisory only (see Phase 3 jump-mechanism notes). - If 0a's
--tab-positionflag doesn't exist → abort and replan — the entire CLI approach is non-viable on this zellij version.
File: new ~/.config/zellij/scripts/zellij-tab-name.sh (sourceable by .bashrc inside WSL)
# Guard — only run inside zellij.
[[ -n $ZELLIJ ]] || return
# Resolve the right zellij binary. Inside WSL bash that's running inside a
# Windows-native zellij pane, bare `zellij` would invoke a Linux ELF (if
# installed) which talks to a *different* Unix socket — i.e. a different
# server, not the Windows one hosting this tab. We need zellij.exe via WSL
# interop. This is validated by Phase 0c.
if command -v zellij.exe >/dev/null 2>&1; then
ZELLIJ_CMD=zellij.exe
elif command -v zellij >/dev/null 2>&1; then
ZELLIJ_CMD=zellij
else
return
fi
export ZELLIJ_CMD
# Vendor bash-preexec (one-time copy into ~/.config/zellij/scripts/bash-preexec.sh
# from https://github.com/rcaloras/bash-preexec/blob/master/bash-preexec.sh).
source "$(dirname "${BASH_SOURCE[0]}")/bash-preexec.sh"
__zellij_idle_name() {
# Mirror Get-ZellijAutoName at attach-main.ps1:168 — git-root leaf, else cwd basename.
local name
name="$(git rev-parse --show-toplevel 2>/dev/null | xargs -r basename)"
[[ -z $name ]] && name="${PWD##*/}"
[[ -z $name ]] && name="~"
printf '%s' "$name"
}
__zellij_rename() {
# Sequence-gated background rename. flock serializes concurrent renames
# (mitigates the preexec-vs-precmd race documented in Decision 6).
local name="$1"
local lock="/tmp/zellij-rename.${ZELLIJ_SESSION_NAME:-default}.${ZELLIJ_PANE_ID:-0}.lock"
(
flock -w 0.5 9 || exit
"$ZELLIJ_CMD" action rename-tab "$name" >/dev/null 2>&1
) 9>"$lock" &
# Cache writer is similarly gated through dispatch.py (Phase 2, optional).
[[ "$ZELLIJ_TAB_CACHE_ENABLED" == 1 ]] && (
flock -w 0.5 9 || exit
zellij-dispatch tab-cache-write "$name" >/dev/null 2>&1
) 9>"${lock}.cache" &
}
__zellij_preexec() {
# $1 is the full typed command; first word only.
local cmd="${1%% *}"
cmd="${cmd##*/}" # strip path prefix on /usr/bin/python3 → python3
[[ -n $cmd ]] && __zellij_rename "$cmd"
}
__zellij_precmd() {
# Reset to idle name. Never use undo-rename-tab — see Issue #4602.
__zellij_rename "$(__zellij_idle_name)"
}
preexec_functions+=(__zellij_preexec)
precmd_functions+=(__zellij_precmd)File: new ~/.config/zellij/scripts/zellij-tab-name.ps1 (dot-sourced from $PROFILE)
if (-not $env:ZELLIJ) { return }
# On Windows-native pwsh, `zellij` resolves to zellij.exe directly. No
# binary shim needed here (unlike the WSL bash case in Phase 0c).
function global:__Zellij-IdleName {
# Mirror Get-ZellijAutoName at attach-main.ps1:168.
$gitRoot = & git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0 -and $gitRoot) {
return ($gitRoot.Trim() -split '[/\\]')[-1]
}
return (Split-Path -Leaf $PWD.Path)
}
# Debounce timer: coalesces a burst of rename requests (e.g. fast Enter
# presses during multi-line editing) into a single IPC call. Avoids the
# Start-Job-per-rename overhead and mitigates the race in Decision 6.
$script:__ZellijPendingName = $null
$script:__ZellijRenameTimer = [System.Timers.Timer]::new(20)
$script:__ZellijRenameTimer.AutoReset = $false
Register-ObjectEvent -InputObject $script:__ZellijRenameTimer -EventName Elapsed -Action {
$name = $script:__ZellijPendingName
if ($name) { & zellij action rename-tab $name 2>$null }
} | Out-Null
function global:__Zellij-Rename {
param([string]$Name)
if (-not $Name) { return }
$script:__ZellijPendingName = $Name
$script:__ZellijRenameTimer.Stop()
$script:__ZellijRenameTimer.Start()
if ($env:ZELLIJ_TAB_CACHE_ENABLED -eq '1') {
Start-ThreadJob -ScriptBlock { param($n) zellij-dispatch tab-cache-write $n } -ArgumentList $Name | Out-Null
}
}
# Pre-execution: CommandValidationHandler fires from ValidateAndAcceptLine,
# which is bound to Enter — not the continuous syntax-highlight parse loop.
# Source: Set-PSReadLineOption docs Example 8, parameter description
# "Specifies a ScriptBlock that is called from ValidateAndAcceptLine."
# PowerShell Issue #15271 confirms there is no native preexec to use instead.
Set-PSReadLineOption -CommandValidationHandler {
param([System.Management.Automation.Language.CommandAst]$CommandAst)
if (-not $env:ZELLIJ) { return }
try {
# Returns $null for incomplete multi-line statements; the `if` guard
# handles that case — tab name unchanged for in-progress input.
$name = $CommandAst.GetCommandName()
if ($name) {
$leaf = Split-Path -Leaf $name
__Zellij-Rename $leaf
}
} catch {
# Validation handlers must not throw — would block command execution.
}
}
# Post-command idle reset: wrap the existing `prompt` function.
$global:__ZellijOriginalPrompt = (Get-Command prompt).ScriptBlock
function global:prompt {
__Zellij-Rename (__Zellij-IdleName)
& $global:__ZellijOriginalPrompt
}Caveats baked into the code above (all sourced):
CommandValidationHandleris called fromValidateAndAcceptLine(the function bound to Enter), not from the syntax-highlight parse loop — so it does NOT flap on every keystroke. Source: Set-PSReadLineOption docs, parameter description: "Specifies a ScriptBlock that is called from ValidateAndAcceptLine."- Multi-line statements (e.g.
if ($true) {then Enter) call the handler with an incompleteCommandAst;GetCommandName()returns$null, theif ($name)guard handles it, no rename fires. Verified by the validator's review. - Script execution and non-interactive pwsh don't trigger the handler (by design — only PSReadLine input does). Tabs won't update inside scripts; acceptable since tabs are a UI concern.
- pwsh has no callback when a long-running external process exits — the
promptreset covers this when the next prompt is drawn. Source: PowerShell #15271. - Debounce timer (20ms) avoids spawning a fresh
Start-Jobper rename and coalesces rapid bursts (e.g. accidental double-Enter) into one IPC. Source: validator's recommendation in concern #4.
Add to dispatch.py as HANDLERS["tab-cache-write"]. The shell hooks invoke it via:
zellij-dispatch tab-cache-write "<name>"Implementation: writes {tab_name, tab_position, ticks_now} to $TEMP/zellij-tab-names/<session>/<tab_id>.json. Reads ZELLIJ_SESSION_NAME, ZELLIJ_PANE_ID, and queries zellij action query-tab-names (from inside the session — confirmed working since the session is, by definition, the current one) to resolve own tab position.
Why a Python handler and not a direct file-write in the shell hook: keeps the JSON schema in one place (the picker's reader and the writer agree by construction), and gets free _single_instance_lock-style atomicity. Mirror the cleanup pattern at dispatch.py:612 — when sessions disappear from zellij ls, prune their cache subdirs.
Replace _list_sessions() and _switch_session_interactive() in dispatch.py with a tab-flat variant:
- Enumeration (
_list_session_tabs()— new function):- For each session from
zellij ls:- If session is current → use
zellij action query-tab-namesdirectly (always works). - If session is background → read from
$TEMP/zellij-tab-names/<session>/. If empty (fresh session never had a hook fire), fall back tozellij --session <name> action query-tab-names— and if Phase 0 proved that fails, show the session with(unknown tabs)and allow selecting it without a tab position.
- If session is current → use
- Build flat list:
[{session, tab_position, tab_name, is_current_tab, clients, ticks}, ...].
- For each session from
- Render (extend
_format_session_row→_format_tab_row):1 my-project / vim (2 attached) [3m12s] (current)2 my-project / cargo run (2 attached) [3m12s]3 scratch / bash (0 attached) [1h2m]- Format:
<position-label> <session-name> / <tab-name> (<n> attached) [<elapsed>] <tags> - Group ordering reuses
sort_keyatdispatch.py:645adapted to(session_recency, tab_position).
- Jump (replace the existing
["zellij", "action", "switch-session", name]atdispatch.py:872):- If chosen tab is in current session:
zellij action go-to-tab-name <name>(works from inside; name-based, so immune to position drift). - If chosen tab is in another session:
zellij action switch-session <session> --tab-position <position>— the atomic primitive whose existence is gated on Phase 0a confirming the--tab-positionflag is present in the installed zellij version. The 0.44.0 release notes mentionzellij action switch-sessionas new but don't enumerate its flags (release notes); the CLI Actions docs reference it but the doc page is the truth-source we'll cross-check against--helpoutput. - If Phase 0d marks a session's positions as untrusted: degrade to a session-only switch (
zellij action switch-session <session>) for that row and accept landing on the last-focused tab.
- If chosen tab is in current session:
The existing keybind bind "Ctrl Shift a" { Run "zellij-dispatch" "switch-session" ... } at config.kdl:556 requires no change — same entry point, upgraded internals. The _single_instance_lock at dispatch.py:657 already makes the picker self-killing on double-press.
- WSL bash: add
source ~/.config/zellij/scripts/zellij-tab-name.shto~/.bashrc(inside WSL). - pwsh: add
. $HOME/.config/zellij/scripts/zellij-tab-name.ps1to$PROFILE. - No
config.kdlchange beyond what's already there — theCtrl+Shift+Abinding tozellij-dispatch switch-sessionis unchanged; onlydispatch.pyinternals grow. - Vendor
bash-preexec.shinto~/.config/zellij/scripts/(a single ~250-line file from rcaloras/bash-preexec) — vendor rather thancurl-at-source to keep the config self-contained per the existing pattern (thedispatch.pyuv-tool installation already prizes zero-runtime-fetch).
| File | Change |
|---|---|
~/.config/zellij/dispatch.py |
Add tab-cache-write handler; rewrite switch_session as flat tab picker |
~/.config/zellij/scripts/zellij-tab-name.sh |
New — bash preexec/precmd hooks |
~/.config/zellij/scripts/zellij-tab-name.ps1 |
New — pwsh CommandValidationHandler + prompt wrap |
~/.config/zellij/scripts/bash-preexec.sh |
New — vendored from rcaloras/bash-preexec |
~/.bashrc (WSL) |
source ~/.config/zellij/scripts/zellij-tab-name.sh |
$PROFILE (pwsh) |
. $HOME/.config/zellij/scripts/zellij-tab-name.ps1 |
~/.config/zellij/config.kdl |
No change — existing Ctrl+Shift+A binding routes through the upgraded switch-session handler |
~/.config/zellij/README.md |
Document the new feature + cache location |
dispatch.py:_single_instance_lock(line 657) — picker mutex.dispatch.py:_RECENCY_DIR+_elapsed_str(line 538-558) — recency formatting.dispatch.py:_pick_profile_interactive(line 305) — raw-mode picker UI scaffold; the tab picker reuses the same key handling, render-line, redraw pattern.dispatch.py:_read_jsonc(line 108) — JSONC parsing if cache files ever grow comments.attach-main.ps1:Get-ZellijAutoName(line 168) — the "smart name from git root / cwd leaves" algorithm; port to__Zellij-IdleNameand__zellij_idle_nameso naming is consistent across attach-time and idle-time.
Every verification step below is paired with an explicit PASS predicate and an explicit FAIL predicate — including the silent-failure modes that look like success at a glance. The pattern is: a step that only defines "what to do" is useless; you don't know if you've done it.
Feature 1 — Unified tab+session picker is "working" when ALL of these hold simultaneously:
- F1.S1: Pressing
Ctrl+Shift+Afrom any session opens a floating picker showing every tab in every live session, not just sessions. Each entry shows<session-name> / <tab-name> (<n> attached) [<recency>] [<current-session?> <current-tab?>]. - F1.S2: Selecting a tab in the same session jumps to that exact tab — verified by the visible tab name in the tab bar after the jump matches the tab name that was selected in the picker. Implementation:
zellij action go-to-tab-name <name>. - F1.S3: Selecting a tab in a different session jumps to that session AND focuses that exact tab (not the last-focused tab of that session) — verified by the same name-equality check after the cross-session switch. Implementation:
zellij action switch-session <session> --tab-position <N>. - F1.S4: Picker open latency is < 500ms with up to 10 sessions × 5 tabs each — empirically measured with
timein Phase 0b and recorded in README. (Below this threshold, the picker feels instant; above it, users start typing past it.) - F1.S5: Pressing
Ctrl+Shift+Awhile the picker is already open replaces the open picker (single-instance behavior preserved fromdispatch.py:_single_instance_lock). - F1.S6: Cancelling the picker (Esc / q / Ctrl-C) returns to the previously-focused tab unchanged — no accidental switching.
Feature 2 — Live tab naming is "working" when ALL of these hold simultaneously:
- F2.S1: At an idle prompt, the tab name is the git-root leaf or cwd basename — matches the output of
Get-ZellijAutoNamefromattach-main.ps1:168. Both bash and pwsh produce the same name for the same cwd. - F2.S2: When a command runs, the tab name changes to the first word of the command (stripped of path prefix:
/usr/bin/python3→python3). When the command exits, the tab name reverts to the idle name before the next prompt is fully drawn (i.e. no perceivable "stale command name" delay). - F2.S3: In pwsh, multi-line command entry (e.g.
if ($true) {+ Enter + body) does not flicker the tab name —CommandValidationHandlercorrectly returns$nullon incompleteCommandAstand the rename is suppressed until the final closing-brace Enter. - F2.S4: Rapid command bursts (e.g. tab-completion looping over
ls; ls; ls) coalesce into the final state — the tab does not visibly flicker through intermediate names. Mediated byflock(bash) and the 20ms debounce timer (pwsh). - F2.S5: Switching to a different tab while a long-running command is in flight does not corrupt the running tab's name — when the user returns, the name is still the command's first word; when the command exits, only that tab reverts to idle (Issue #4602 regression check).
- F2.S6: The hooks do not break other shell features: bash
command-not-found,chpwd_functions/PROMPT_COMMANDfrom other tools (e.g.starship,oh-my-posh), pwsh'spromptoverrides fromoh-my-poshor other modules. Verified by smoke-testing those tools after the hooks are installed.
These are the symptoms that mean a regression. The list is structured so each entry is observable — you can tell whether it's happening without inspecting code.
Feature 1 failure modes:
- F1.F1 — Regression to session-only: picker shows only sessions, not tabs. Symptom: list is shorter than
sum(tab_count per session). Likely cause: enumeration code path didn't deploy. - F1.F2 — Silent wrong-tab jump: picker shows "session-A / vim" but after selection the focused tab name is "build". This is the worst failure mode because the user doesn't know it happened. Catch via: F1.S2/S3 require name-equality after jump, not just "the jump happened."
- F1.F3 — Background-session entries missing entirely: only the current session's tabs appear. Likely cause:
query-tab-namesagainst background sessions silently no-ops (Issue #3863) and the cache fallback isn't engaged. - F1.F4 — Picker hangs on open with > 2s latency:
query-tab-namesblocks on a background session. Watch fortime zellij --session X action query-tab-namesexceeding ~50ms × N sessions. - F1.F5 — Stale-position silent miss-jump: cached position drift (Phase 2 path) jumps to the wrong tab without surfacing any error. Mitigation already designed: count-cross-check at picker-open time.
- F1.F6 — Picker becomes its own modal trap: opens, doesn't accept input, doesn't dismiss with Esc. Symptom: stuck floating pane. Watch lockfile state (
picker.lock) — if it persists after the picker closes, single-instance is broken.
Feature 2 failure modes:
- F2.F1 — Tab name never changes from "Tab #N": hook didn't fire. Verify with
echo $ZELLIJ(must be set) and check the resolvedZELLIJ_CMDvalue (must exist). - F2.F2 — Concatenated tab names like
vim.vim.tmp.tmp: Issue #4199 race. Indicatesflock/debounce is not gating IPC effectively. - F2.F3 — Cross-tab corruption after tab-switching mid-command: a tab gets stuck on another tab's command name (Issue #4602). Indicates we accidentally used
undo-rename-tabsomewhere, or precmd in tab A's shell is targeting tab B. - F2.F4 — Pwsh typing lag: each keystroke during command entry causes a visible ~100ms hang. Indicates
CommandValidationHandleris being invoked from the syntax-highlight parse loop, not just fromValidateAndAcceptLine. Should not happen per docs, but watch for it. - F2.F5 — Tab name shows the wrong command: a shell function name appears instead of the actual external command (e.g. tab says
_p9k_precmdinstead ofgit status). Indicates bash-preexec subshell-tracking or the${cmd%% *}extraction is misfiring. - F2.F6 — Pwsh background jobs accumulate:
Get-Jobcount grows over time. IndicatesStart-ThreadJob(orStart-Job) isn't being properly cleaned up. (Debounce design specifically avoidsStart-Jobper rename to prevent this.) - F2.F7 — Idle name diverges between shells: in bash it shows
myrepo, in pwsh it showssrc(basename of cwd inside the repo). Indicates__zellij_idle_nameand__Zellij-IdleNamearen't running the same git-root-then-basename fallback. - F2.F8 — Hooks break starship/oh-my-posh: prompt rendering shows malformed output or duplicated lines. Indicates
prompt-wrapping clobbered the user's existing customization.
The feature is done when all four of these are satisfied. This is the explicit "stop building" signal — no further polish gets prioritized until something on this list fails.
- Phase 0 gate passed: 0a/0b/0c results pasted into README.
--tab-positionflag exists OR the plugin fallback path is opened as a tracking issue. - All 13 verification steps in the protocol below show PASS, with both pass and fail predicates having been observed (i.e. you triggered the failure mode at least once and observed the correct error/degraded state). A test you've only seen pass isn't a test — it's a coincidence.
- One day of normal use (≥ 4 hours of active terminal work) without any of the failure-criteria symptoms (F1.F1-6, F2.F1-8) appearing.
- README updated with: the new keybind behavior, the cache decision (built or skipped), the Phase 0 results, and the known limitations list. The README is the durable record — without it, the next person (you in 6 months) re-discovers the same gotchas.
Each step lists: Do (the action), Pass (the observable success state), Fail (the observable broken state, including silent failures to actively check for).
-
0a — Flag existence (BLOCKING).
- Do: Run
zellij --versionandzellij action switch-session --help. Paste both outputs into the README. - Pass:
--tab-positionappears in the--helpoutput as a flag with an integer argument. - Fail: Flag is absent OR
zellij action switch-sessionreturns "unrecognized subcommand". → Abort the CLI path; open a tracking issue for the Rust WASM plugin fallback usingswitch_session_with_focus. Do NOT proceed to Phase 1.
- Do: Run
-
0b — Background enumeration & jump.
- Do: Open sessions S1 and S2 (each with 3 tabs). Detach from S2. From inside S1: run
zellij --session S2 action query-tab-names,zellij action switch-session S2 --tab-position 0(then return),zellij action switch-session S2 --tab-position 2, andtime zellij --session S2 action query-tab-names×5. - Pass:
query-tab-namesprints 3 names matching S2's tabs. The two switch-session jumps land on the correct tab (verified by visible tab-bar name = position-0 tab and position-2 tab respectively). Timing averages < 50ms. - Fail:
query-tab-namesreturns empty / errors / hangs → engage Phase 2 cache. Switch-session lands on wrong tab → entire Phase 3 jump primitive is broken on this version; fall back to plugin path. Timing > 200ms × N → cache becomes a perf requirement, not just a correctness one.
- Do: Open sessions S1 and S2 (each with 3 tabs). Detach from S2. From inside S1: run
-
0c — WSL binary identity (BLOCKING for bash hook).
- Do: Inside WSL bash within a Windows-native zellij pane:
echo $ZELLIJ,which zellij,which zellij.exe,zellij.exe action rename-tab "wsl-test". - Pass:
$ZELLIJis non-empty (env var crossed the WSL boundary).which zellij.exeresolves to the Windows binary via WSL interop. The rename visibly updates the surrounding tab towsl-test. - Fail (silent!):
which zellijresolves to/usr/bin/zellij(Linux ELF) andzellij action rename-tab "x"runs without error but the tab doesn't change — meaning it talked to a Linux server that doesn't exist or isn't the one hosting this tab. The bash hook MUST usezellij.exe; theZELLIJ_CMDshim handles this.
- Do: Inside WSL bash within a Windows-native zellij pane:
-
Tab naming, bash, happy path.
- Do: In WSL bash,
cd /tmp, thensleep 5, thenvim,:q. - Pass: tab bar shows
bash(or idle name) →sleep(during the 5 sec) →tmp→vim(while in vim) →tmp(after quit). - Fail: tab stays on default name → hook didn't fire (check
[[ -n $ZELLIJ ]],ZELLIJ_CMD). Tab showsbash.sleep.tmp.vim→ concatenation race (F2.F2). Tab showssleepafter the command finished → precmd didn't fire (race withnohupordering).
- Do: In WSL bash,
-
Tab naming, pwsh, happy path.
- Do: In pwsh,
cd $env:TEMP,Start-Sleep 5,python --version,vim,:q. - Pass: same sequence as step 4.
- Fail: same patterns as step 4. Additionally, watch
Get-Jobafter — count must be 0 (F2.F6).
- Do: In pwsh,
-
Tab naming, pwsh, multi-line input (validator-added).
- Do: Type
if ($true) {+ Enter +"hello"+ Enter +}+ Enter. - Pass: During the first two Enters, the tab name does NOT briefly flicker to
if— the rename is suppressed until the complete statement is submitted. After the final Enter, tab reverts to idle name (since"hello"is just an expression with no command name). - Fail: Tab flickers to
ifon the first Enter — confirms my F2.F4 silent-failure mode and meansCommandValidationHandleris being invoked from the wrong code path.
- Do: Type
-
Debounce burst.
- Do: In pwsh, run a 5-iteration loop calling
__Zellij-Rename "name$_". In bash, run the same in a tight for-loop. - Pass: After the loop, the tab name is
name5(last value).Get-Jobcount is 0 in pwsh. No intermediate name appears in the tab bar. - Fail: Tab shows
name1(first value won race). MultipleGet-Jobentries persist. Tab flickers visibly.
- Do: In pwsh, run a 5-iteration loop calling
-
Picker — current-session tab jump.
- Do: Open 3 tabs in the current session, rename them
foo/bar/baz(let the hooks do it viacdto different dirs). PressCtrl+Shift+A. Selectbaz. - Pass: Picker shows all 3 tabs of the current session interleaved with any other sessions. After selection, the focused tab name is
baz. The picker closes cleanly; the lockfile is removed. - Fail (silent!): Picker shows only sessions (F1.F1). Selection lands on a different tab whose name was also displayed (F1.F2) — this is the worst silent failure and requires the explicit "compare tab name after jump = selected name" check.
- Do: Open 3 tabs in the current session, rename them
-
Picker — cross-session tab jump.
- Do: Detach, attach a second session with 2 tabs. From inside that second session, press
Ctrl+Shift+A. Select a tab in the first (now-backgrounded) session. - Pass: The visible session AND tab both match the selection. Critically, the focused tab in the destination session is the one named in the picker, not "the last-focused tab of that session." Implementation:
zellij action switch-session <name> --tab-position <N>. - Fail: Background session's tabs are missing from picker (F1.F3 — cache didn't engage). Lands in the right session but on a different tab (F1.F2 silent miss-jump).
- Do: Detach, attach a second session with 2 tabs. From inside that second session, press
-
Picker — tab-close position drift.
- Do: In session A, open 3 tabs (positions 0/1/2). Detach. In session B, open the picker and note the displayed positions. Re-attach to A, close the middle tab via
Ctrl+Shift+W(deliberately without running any shell command so no hook fires). Detach again. Back in B, re-open the picker and select what was previously the position-2 tab. - Pass (live-query path): Picker shows correct current positions after the close. Selection lands on the correct surviving tab.
- Pass (cache-fallback path): Picker displays "(positions stale)" or similar degraded indicator for session A, and selection either prompts for re-confirmation or falls back to session-only switch.
- Fail (silent!): Picker shows stale positions and selection silently jumps to a non-existent or wrong tab. This is exactly the validator's concern #2.
- Do: In session A, open 3 tabs (positions 0/1/2). Detach. In session B, open the picker and note the displayed positions. Re-attach to A, close the middle tab via
-
Race regression — tab switching mid-command.
- Do: In tab A, run
sleep 30. Immediately switch to tab B. Wait 10 seconds. Switch back to A. Wait for sleep to finish. - Pass: Throughout A's
sleep, A's tab name remainssleep(or whatever the first word was). B's tab name is unaffected. After A's sleep exits, A reverts to idle name. - Fail (Issue #4602 regression!): A's name reverts to idle while sleep is still running (precmd in another tab clobbered it). OR B's tab gets renamed to
sleep(we accidentally used undo-rename-tab and it hit the wrong tab).
- Do: In tab A, run
-
Thread-safety regression.
- Do: In bash, run
for i in {1..20}; do echo $i; done5 times back-to-back. - Pass: Final idle tab name is clean (
tmp, nottmp.tmp.tmp). No concatenation visible at any point. - Fail: Concatenated names appear —
flockisn't gating effectively (F2.F2).
- Do: In bash, run
-
Cache lifecycle (only if Phase 2 was built per Phase 0d outcome).
- Do:
zellij kill-session foo. Re-open the picker. - Pass:
$TEMP/zellij-tab-names/foo/directory is removed (mirrors_RECENCY_DIRcleanup atdispatch.py:612). Picker doesn't showfooanymore. - Fail: Cache directory persists for killed sessions, accumulating over time. Picker briefly shows the dead session before pruning.
- Do:
- Real-work soak.
- Do: Use the terminal normally for ≥ 4 hours across at least 2 sessions and 4 tabs. Mix bash and pwsh. Run things like
git status,vim,python, package installs, builds. - Pass: No occurrences of any failure-criterion symptom (F1.F1-6, F2.F1-8). Tab names remain informative and consistent. The picker never opens to a wrong state, never hangs, never miss-jumps.
- Fail: Any single occurrence of a silent-failure symptom (F1.F2, F1.F5, F2.F3, F2.F5) → not done; root-cause it. Cosmetic issues (F2.F1, F1.F4 if borderline) can be triaged but not until they're confirmed reproducible.
- Do: Use the terminal normally for ≥ 4 hours across at least 2 sessions and 4 tabs. Mix bash and pwsh. Run things like
- Background-session enumeration depends on Phase 0 outcome. If
query-tab-namesdoesn't work against background sessions, fresh sessions (no shell hook has fired) will appear as(unknown tabs)until any command runs in them. Source: Issue #3863 (community-reported, maintainer-unconfirmed). --tab-positionflag presence is gated on Phase 0a. The 0.44.0 release notes don't enumerate the flag; the CLI Actions docs reference it but the truth-source is--helpon the installed binary. If it's absent, fall back to plugin approach withswitch_session_with_focus(Plugin API Commands).- Tab-close position drift in cached entries. If a tab is closed via
Ctrl+Shift+W(no shell command fires), cached positions for surviving tabs in that session become stale. The picker mitigates by live-queryingquery-tab-namesper session at open time and cross-referencing entry count against the cache; on count divergence, that session degrades to session-only switch. Cache positions are advisory, not source-of-truth. Validator-flagged in concern #2. - Pwsh
CommandValidationHandlerdoesn't fire for non-interactive scripts — by design, only interactive PSReadLine input triggers it. Tab names won't update inside scripts; acceptable since tabs are a UI concern. Source: Set-PSReadLineOption docs. - Long-running external processes in pwsh show the program name from start to exit; no mid-execution name-change is possible without OS-level process inspection. Source: PowerShell #15271.
- WSL bash MUST use
zellij.exe, notzellij. Inside a Windows-native zellij pane, the Linux ELF binary (if installed in WSL) talks to a different Unix socket and would silently target the wrong server. TheZELLIJ_CMDshim inzellij-tab-name.shhandles this. Validator-flagged in concern #5. - Backgrounded rename race is sequenced by
flock(bash) and a 20ms debounce timer (pwsh); without these, fast preexec→precmd cycles would visibly flicker between the command name and the idle name. Source: Issue #4199, validator concern #4.