Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save YoraiLevi/1e363143b5d01b731787e9d16fc49a7b to your computer and use it in GitHub Desktop.

Select an option

Save YoraiLevi/1e363143b5d01b731787e9d16fc49a7b to your computer and use it in GitHub Desktop.
Plan: unified zellij Ctrl+Shift+A tab+session picker (across all sessions) + live tab naming for WSL bash & pwsh on Windows. Includes architectural decisions with inline source citations, schematic diagrams, success/failure criteria, and a component roadmap with ship milestones.

Unified zellij tab+session picker, plus live tab naming for WSL bash & pwsh

Context

Two related quality-of-life upgrades to ~/.config/zellij:

  1. Ctrl+Shift+A is currently a session-only picker. It opens a list of zellij sessions (via dispatch.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.
  2. 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).

Architectural decisions, with provenance

Decision 1 — Use the CLI + Python path, not a Rust WASM plugin

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-session as a CLI command with what appears to be a --tab-position flag (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.py is already a polished single-instance Python picker with raw-mode UI, recency tracking, and a HANDLERS registry 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.

Decision 2 — Tab-name resolution is live-query first, cache only as a Phase 0-gated fallback

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-names works 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 via Ctrl+Shift+W without 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).

Decision 4 — Pwsh tab naming via Set-PSReadLineOption -CommandValidationHandler

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.

Decision 5 — Never use undo-rename-tab in shell hooks

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.

Decision 6 — Sequence rename calls to prevent backgrounded races

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 flock on a per-tab lockfile (/tmp/zellij-rename.<session>.<tab_id>.lock). flock is 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-Job per rename — it's heavyweight (full child PowerShell process). Use a single module-scope debounce: cancel any pending timer, schedule a new one with Register-ObjectEvent on 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.

How it works at a glance (schematic diagrams)

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.

Diagram 1 — System overview: components and their wiring

            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                         │
   │                                                                       │
   └─────────────────────────────────────────────────────────────────────┘

Diagram 2 — Picker flow: Ctrl+Shift+A to focused tab

   ┌─────────────────┐
   │ 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
   └───────────────────────────────────────────────┘

Diagram 3 — Tab-naming hook flow + why flock is correctness-critical

   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)

Diagram 4 — The WSL binary identity trap (silent failure, called out visually)

                                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

Diagram 5 — Phase 0 decision tree (what to do when each test outcome happens)

                          ┌───────────────┐
                          │ 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:

  1. "What are the pieces?" → Diagram 1.
  2. "What happens when I press the keybind?" → Diagram 2.
  3. "What happens when I type a command?" → Diagram 3.
  4. "Where does this silently go wrong?" → Diagram 4.
  5. "What if the empirical tests say X?" → Diagram 5.

Component roadmap (build order, dependencies, ship-ability)

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.

Component table

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.

Dependency graph (linear and parallel edges shown)

                          ┌──── 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.    ║
                    ╚═════════════════════════╝

Ship milestones — what becomes usable when

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.

Recommended build order (with rationale)

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.

  1. 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.
  2. C1 + C2 — small, mechanical, low risk. Knock them out in one sitting so you have working idle-name helpers everywhere before you touch IPC.
  3. C3 (bash side first, then pwsh) — bash-preexec is a well-trodden pattern; pwsh's CommandValidationHandler is the riskier one. Build the bash version first so you have a reference for "what success looks like" before tackling the pwsh peculiarities.
  4. C4 — Bash auto-naming is a one-line addition to preexec_functions. Reach M2 in under 3 hours from the start.
  5. Pause and use C4 for a day. Discover any real-world rough edges (slow renames, prompt conflicts) before doubling them in pwsh.
  6. C5 — Pwsh auto-naming. Reach M3.
  7. C6 — only if C0b said the cache is needed. Skip otherwise; the live-query path in C7 covers it.
  8. 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.
  9. C10 — soak and polish. Do this last because all earlier components must be in place to soak meaningfully.

Rollback strategy per component

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.

Implementation plan

Phase 0 — Empirical CLI capability test (~20 minutes, BLOCKING all other phases)

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 listed

Paste 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 × 5

Test 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 tab

If 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-names against 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-names against 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-position flag doesn't exist → abort and replan — the entire CLI approach is non-viable on this zellij version.

Phase 1 — Tab-naming hooks (Feature 2) — establishes the cache infrastructure

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

  • CommandValidationHandler is called from ValidateAndAcceptLine (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 incomplete CommandAst; GetCommandName() returns $null, the if ($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 prompt reset covers this when the next prompt is drawn. Source: PowerShell #15271.
  • Debounce timer (20ms) avoids spawning a fresh Start-Job per rename and coalesces rapid bursts (e.g. accidental double-Enter) into one IPC. Source: validator's recommendation in concern #4.

Phase 2 — Tab-name cache (shared infrastructure for Features 1 & 2)

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.

Phase 3 — Unified tab+session picker (Feature 1)

Replace _list_sessions() and _switch_session_interactive() in dispatch.py with a tab-flat variant:

  1. Enumeration (_list_session_tabs() — new function):
    • For each session from zellij ls:
      • If session is current → use zellij action query-tab-names directly (always works).
      • If session is background → read from $TEMP/zellij-tab-names/<session>/. If empty (fresh session never had a hook fire), fall back to zellij --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.
    • Build flat list: [{session, tab_position, tab_name, is_current_tab, clients, ticks}, ...].
  2. 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_key at dispatch.py:645 adapted to (session_recency, tab_position).
  3. Jump (replace the existing ["zellij", "action", "switch-session", name] at dispatch.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-position flag is present in the installed zellij version. The 0.44.0 release notes mention zellij action switch-session as 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 --help output.
    • 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.

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.

Phase 4 — Wiring

  • WSL bash: add source ~/.config/zellij/scripts/zellij-tab-name.sh to ~/.bashrc (inside WSL).
  • pwsh: add . $HOME/.config/zellij/scripts/zellij-tab-name.ps1 to $PROFILE.
  • No config.kdl change beyond what's already there — the Ctrl+Shift+A binding to zellij-dispatch switch-session is unchanged; only dispatch.py internals grow.
  • Vendor bash-preexec.sh into ~/.config/zellij/scripts/ (a single ~250-line file from rcaloras/bash-preexec) — vendor rather than curl-at-source to keep the config self-contained per the existing pattern (the dispatch.py uv-tool installation already prizes zero-runtime-fetch).

Critical files

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

Reused existing infrastructure (no need to re-create)

  • 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-IdleName and __zellij_idle_name so naming is consistent across attach-time and idle-time.

Success criteria, failure criteria, and the done-gate

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.

How we'll know it's working (success criteria)

Feature 1 — Unified tab+session picker is "working" when ALL of these hold simultaneously:

  • F1.S1: Pressing Ctrl+Shift+A from 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 time in 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+A while the picker is already open replaces the open picker (single-instance behavior preserved from dispatch.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-ZellijAutoName from attach-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/python3python3). 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 — CommandValidationHandler correctly returns $null on incomplete CommandAst and 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 by flock (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_COMMAND from other tools (e.g. starship, oh-my-posh), pwsh's prompt overrides from oh-my-posh or other modules. Verified by smoke-testing those tools after the hooks are installed.

How we'll know it's broken (failure criteria — including silent failures)

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-names against 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-names blocks on a background session. Watch for time zellij --session X action query-tab-names exceeding ~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 resolved ZELLIJ_CMD value (must exist).
  • F2.F2 — Concatenated tab names like vim.vim.tmp.tmp: Issue #4199 race. Indicates flock/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-tab somewhere, 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 CommandValidationHandler is being invoked from the syntax-highlight parse loop, not just from ValidateAndAcceptLine. 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_precmd instead of git status). Indicates bash-preexec subshell-tracking or the ${cmd%% *} extraction is misfiring.
  • F2.F6 — Pwsh background jobs accumulate: Get-Job count grows over time. Indicates Start-ThreadJob (or Start-Job) isn't being properly cleaned up. (Debounce design specifically avoids Start-Job per rename to prevent this.)
  • F2.F7 — Idle name diverges between shells: in bash it shows myrepo, in pwsh it shows src (basename of cwd inside the repo). Indicates __zellij_idle_name and __Zellij-IdleName aren'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.

Done-gate (when we stop building and declare "shipped")

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.

  1. Phase 0 gate passed: 0a/0b/0c results pasted into README. --tab-position flag exists OR the plugin fallback path is opened as a tracking issue.
  2. 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.
  3. 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.
  4. 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.

Verification protocol

Each step lists: Do (the action), Pass (the observable success state), Fail (the observable broken state, including silent failures to actively check for).

Phase 0 gate (must pass before any code is written)

  1. 0a — Flag existence (BLOCKING).

    • Do: Run zellij --version and zellij action switch-session --help. Paste both outputs into the README.
    • Pass: --tab-position appears in the --help output as a flag with an integer argument.
    • Fail: Flag is absent OR zellij action switch-session returns "unrecognized subcommand". → Abort the CLI path; open a tracking issue for the Rust WASM plugin fallback using switch_session_with_focus. Do NOT proceed to Phase 1.
  2. 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, and time zellij --session S2 action query-tab-names ×5.
    • Pass: query-tab-names prints 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-names returns 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.
  3. 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: $ZELLIJ is non-empty (env var crossed the WSL boundary). which zellij.exe resolves to the Windows binary via WSL interop. The rename visibly updates the surrounding tab to wsl-test.
    • Fail (silent!): which zellij resolves to /usr/bin/zellij (Linux ELF) and zellij 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 use zellij.exe; the ZELLIJ_CMD shim handles this.

Smoke test (5 minutes — run after every code change during development)

  1. Tab naming, bash, happy path.

    • Do: In WSL bash, cd /tmp, then sleep 5, then vim, :q.
    • Pass: tab bar shows bash (or idle name) → sleep (during the 5 sec) → tmpvim (while in vim) → tmp (after quit).
    • Fail: tab stays on default name → hook didn't fire (check [[ -n $ZELLIJ ]], ZELLIJ_CMD). Tab shows bash.sleep.tmp.vim → concatenation race (F2.F2). Tab shows sleep after the command finished → precmd didn't fire (race with nohup ordering).
  2. 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-Job after — count must be 0 (F2.F6).
  3. 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 if on the first Enter — confirms my F2.F4 silent-failure mode and means CommandValidationHandler is being invoked from the wrong code path.
  4. 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-Job count is 0 in pwsh. No intermediate name appears in the tab bar.
    • Fail: Tab shows name1 (first value won race). Multiple Get-Job entries persist. Tab flickers visibly.

Full verification (15 minutes — run before declaring the feature done)

  1. Picker — current-session tab jump.

    • Do: Open 3 tabs in the current session, rename them foo/bar/baz (let the hooks do it via cd to different dirs). Press Ctrl+Shift+A. Select baz.
    • 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.
  2. 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).
  3. 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.
  4. 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 remains sleep (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).
  5. Thread-safety regression.

    • Do: In bash, run for i in {1..20}; do echo $i; done 5 times back-to-back.
    • Pass: Final idle tab name is clean (tmp, not tmp.tmp.tmp). No concatenation visible at any point.
    • Fail: Concatenated names appear — flock isn't gating effectively (F2.F2).
  6. 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_DIR cleanup at dispatch.py:612). Picker doesn't show foo anymore.
    • Fail: Cache directory persists for killed sessions, accumulating over time. Picker briefly shows the dead session before pruning.

Soak test (1+ day of real use — before declaring "shipped")

  1. 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.

Risks / known limitations to document in README

  • Background-session enumeration depends on Phase 0 outcome. If query-tab-names doesn'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-position flag 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 --help on the installed binary. If it's absent, fall back to plugin approach with switch_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-querying query-tab-names per 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 CommandValidationHandler doesn'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, not zellij. 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. The ZELLIJ_CMD shim in zellij-tab-name.sh handles 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment