https://github.com/otezz/zellij-vtabs
Personal Zellij vertical grouped-tab sidebar with Claude Code attention indicators
https://github.com/otezz/zellij-vtabs
rust terminal tui wasm zellij zellij-plugin
Last synced: 14 days ago
JSON representation
Personal Zellij vertical grouped-tab sidebar with Claude Code attention indicators
- Host: GitHub
- URL: https://github.com/otezz/zellij-vtabs
- Owner: otezz
- License: mit
- Created: 2026-07-06T16:15:36.000Z (19 days ago)
- Default Branch: main
- Last Pushed: 2026-07-07T10:30:38.000Z (18 days ago)
- Last Synced: 2026-07-07T11:09:12.562Z (18 days ago)
- Topics: rust, terminal, tui, wasm, zellij, zellij-plugin
- Language: Rust
- Homepage: https://otezz.github.io/zellij-vtabs/
- Size: 111 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# zellij-vtabs
A vertical, grouped, collapsible tab sidebar for [Zellij](https://zellij.dev) — with attention indicators driven by Claude Code.
> ⚠️ **This is a personal tool, built for my own setup — not a general-purpose plugin.**
> It hardcodes assumptions about *my* workflow: my plugin path, my `:`-based tab-naming
> convention, my Zellij version, my Claude Code hooks. It is published for my own reuse
> across machines, not as something meant to work out-of-the-box for anyone else. There
> are no stability guarantees, no issue tracker, and no intention to generalize it. If you
> stumbled on this: feel free to read/fork, but expect to change paths and conventions to
> fit your own environment.
## What it does
Zellij has no native vertical tabs. This plugin renders a left sidebar showing tabs as a
**collapsible tree grouped by name prefix**, so I can keep many tabs organized at a glance:

Groups (`▼`/`▶`) collapse and expand; `◆` (yellow) marks a tab needing input, `✓` (green) a
finished one, and `●` marks the active tab.
- **Grouping** — a tab named `group:label` goes under group **group** with label **label**;
a tab with no `:` lands in **General**. (First `:` wins.)
- **Collapse / expand** groups (`▶`/`▼`).
- **Reorder groups and tabs** — `Shift+J`/`Shift+K` (or `Shift+↓`/`Shift+↑`) move the
selected row: a group header moves the whole group, a tab moves within its group. Tab
moves are *virtual* (display-order only — the plugin API can't move real tabs): a group
follows Zellij's native tab positions until you explicitly reorder in it, after which the
saved order wins for that group.
- **Persistent state** — group order, tab order, and collapse state survive session
restarts and stay in sync across every tab's sidebar (stored per session in the plugin's
cache dir).
- **Rename inline** — `r` opens an inline editor (`Enter` commits, `Esc` cancels): on a
group header it renames the group (every member tab is re-prefixed, saved state
follows); on a tab row it renames the tab's label, keeping its group.
- **Auto-grouping (opt-in)** — a small shell script pipes each pane's starting directory
(+ git facts) to the plugin, which auto-names still-default tabs (`repo:branch`,
`repo:worktree`, …) by configurable rules. Manual names always win. See below.
- **Navigate** with `j`/`k`/arrows, `Enter`/`Space` to switch tab or toggle a group.
- **Mouse**: left-click to switch/toggle, scroll to move the selection.
- **Active tab** marked with `●`; the selection highlight follows it.
- **Attention icons** — `◆` (yellow, needs input) and `✓` (green, done), appearing on the
tab and **rolling up to a collapsed group's header**. Cleared automatically when I focus
the tab. Wired to Claude Code's `Notification`/`Stop` hooks.
- **Working spinner** — an animated cyan spinner while Claude is running in a tab (wired
to the `UserPromptSubmit` hook). Unlike the attention icons it shows on the active tab
too and is *not* cleared by focusing — it ends when Claude needs input, finishes, or
exits (`SessionEnd`). Rolls up to collapsed group headers (waiting > working > done).
## Requirements
- **Zellij ≥ the `zellij-tile` version the wasm was built with** — release builds use
`0.44.2`, and the prebuilt wasm is verified working on Zellij **0.43.1, 0.44.2, and
0.44.3** (the protobuf plugin API tolerates version skew in both directions better than
Zellij's error messages suggest; a plugin built against a *newer* `zellij-tile` than the
running Zellij is the combination that reliably fails).
- **Building from source:** Rust + `cargo` with the `wasm32-wasip1` target
(`rustup target add wasm32-wasip1`), and keep the `zellij-tile` pin **at or below** your
Zellij version — don't use a caret/range requirement, Cargo resolves those upward.
## Quick try (zero install)
Zellij can load the plugin straight from the release URL — no download, no Rust. Save
this as `try-vtabs.kdl`:
```kdl
layout {
pane split_direction="vertical" {
pane size=28 borderless=true {
plugin location="https://github.com/otezz/zellij-vtabs/releases/latest/download/zellij-vtabs.wasm"
}
pane
}
pane size=1 borderless=true {
plugin location="zellij:status-bar"
}
}
```
Pre-seed the permissions in `~/.cache/zellij/permissions.kdl` (the in-pane grant prompt
doesn't render usably in a 28-column sidebar):
```kdl
"https://github.com/otezz/zellij-vtabs/releases/latest/download/zellij-vtabs.wasm" {
ReadApplicationState
ChangeApplicationState
ReadCliPipes
}
```
Then: `zellij --new-session-with-layout ./try-vtabs.kdl`. Zellij caches the download, so
this also survives offline use. Name a couple of tabs `group:label` and you have the tree.
## Build & install
```bash
cargo build --release --target wasm32-wasip1
cp target/wasm32-wasip1/release/zellij-vtabs.wasm ~/.config/zellij/plugins/zellij-vtabs.wasm
```
Or skip the Rust toolchain and grab the prebuilt `zellij-vtabs.wasm` from the
[latest release](https://github.com/otezz/zellij-vtabs/releases/latest):
```bash
curl -Lo ~/.config/zellij/plugins/zellij-vtabs.wasm \
https://github.com/otezz/zellij-vtabs/releases/latest/download/zellij-vtabs.wasm
```
Pre-seed the plugin's permissions (Zellij's in-pane grant prompt doesn't render usably in a
narrow sidebar), in `~/.cache/zellij/permissions.kdl`. Note Zellij keys this by the **resolved
absolute** path, so use your real home dir here (not `~`):
```kdl
"/home//.config/zellij/plugins/zellij-vtabs.wasm" {
ReadApplicationState
ChangeApplicationState
ReadCliPipes
}
```
Use the layout in `layouts/vtabs.kdl` (also copied to `~/.config/zellij/layouts/vtabs.kdl`),
and set it as the default in `~/.config/zellij/config.kdl`:
```kdl
default_layout "vtabs"
```
Fast dev loop (code-only changes — layout/config changes still need a fresh session):
```bash
cargo build --release --target wasm32-wasip1 \
&& cp target/wasm32-wasip1/release/zellij-vtabs.wasm ~/.config/zellij/plugins/zellij-vtabs.wasm
zellij action start-or-reload-plugin file:~/.config/zellij/plugins/zellij-vtabs.wasm
```
## Claude Code integration
The attention icons and working spinner are driven by Claude Code hooks. Rather than each
hook piping a fixed signal, they call `shell/vtabs-work.sh` with an event name; the script
keeps a per-pane count of the main-agent turn plus outstanding subagents and derives the
right signal — so the spinner stays lit for the *whole* task, not just the main agent's
first turn. Install it and wire the hooks:
```bash
cp shell/vtabs-work.sh ~/.config/zellij/vtabs-work.sh && chmod +x ~/.config/zellij/vtabs-work.sh
```
```json
{
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "timeout": 3,
"command": "(~/.config/zellij/vtabs-work.sh prompt >/dev/null 2>&1 &)" }] }],
"SubagentStart": [{ "hooks": [{ "type": "command", "timeout": 3,
"command": "(~/.config/zellij/vtabs-work.sh subagent-start >/dev/null 2>&1 &)" }] }],
"SubagentStop": [{ "hooks": [{ "type": "command", "timeout": 3,
"command": "(~/.config/zellij/vtabs-work.sh subagent-stop >/dev/null 2>&1 &)" }] }],
"Notification": [{ "hooks": [{ "type": "command", "timeout": 3,
"command": "(~/.config/zellij/vtabs-work.sh notify >/dev/null 2>&1 &)" }] }],
"Stop": [{ "hooks": [{ "type": "command", "timeout": 3,
"command": "(~/.config/zellij/vtabs-work.sh stop >/dev/null 2>&1 &)" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "timeout": 3,
"command": "(~/.config/zellij/vtabs-work.sh end >/dev/null 2>&1 &)" }] }]
}
}
```
- `UserPromptSubmit` (new turn) → start fresh, animated spinner on that tab
- `SubagentStart` / `SubagentStop` → adjust the outstanding-subagent count (spinner stays lit)
- `Notification` (needs input) → `◆` on that tab
- `Stop` (main-agent turn ended) → `✓` **only if no subagents are still outstanding**,
otherwise the spinner keeps running — this is the whole reason for the counter: `Stop`
fires once per *main-agent* turn, so without it the spinner would clear while background
subagents are still working
- `SessionEnd` (Claude exits) → clears any leftover spinner and the pane's count
- Focusing the tab clears `◆`/`✓`; the spinner survives focus and ends via the hooks above
The script also plays a freedesktop sound on exactly those two real events — `complete`
when the whole task finishes and `message-new-instant` when it needs input — gated by the
same counter, so you get one chime per task rather than one per main-agent turn or subagent.
It falls back `canberra-gtk-play` → `pw-play` → silent, so it's a no-op where neither exists.
Delete the `sound=` / playback lines in `vtabs-work.sh` to turn it off.
Why the script (rather than a fixed pipe per hook, and why a shell counter rather than one
in the plugin): the plugin runs one instance per tab and pipe delivery across instances
isn't reliable, so a counter in plugin memory would diverge — the shell gives one per-pane
state file guarded by `flock`. Inside the script every `zellij pipe` still uses
`< /dev/null` (without it the pipe reads Claude's hook stdin until EOF and deadlocks) and
`timeout 3` (which actually kills the pipe when no plugin is listening — a session without
this layout — since the hook-level `"timeout"` only stops Claude *waiting*). The `( … & )`
subshell makes each hook return instantly. (Shell backgrounding is fine; Claude Code's
`async: true` hook property is not — async hooks failed to deliver pipes reliably in testing.)
Manual test — note it must target a **non-active** tab (the plugin never marks the tab you're
currently on, by design):
```bash
# on tab B:
echo $ZELLIJ_PANE_ID # e.g. 3
# switch to another tab, then:
zellij pipe --name "zellij-vtabs::waiting::3"
```
## Auto-grouping (opt-in)
The plugin can name default-named (`Tab #N`) tabs from each pane's starting directory.
Plugins can't see pane cwds (WASI sandbox), so a small script pipes the facts in:
```bash
cp shell/vtabs-rename.sh ~/.config/zellij/vtabs-rename.sh
chmod +x ~/.config/zellij/vtabs-rename.sh
```
Run it once per shell start — e.g. in `~/.zshrc`:
```zsh
(~/.config/zellij/vtabs-rename.sh &) 2>/dev/null
```
Then enable it in the layout's `plugin` block:
```kdl
autogroup_default "repo" // repo | dir | off (default: off)
autogroup_1 "/mnt/d/codes/work/** -> work" // optional cwd-glob overrides, tried in order
```
- `repo` — group = the owning git repo's name, worktree-aware: a pane in a linked worktree
gets `repo:worktree-dir`; at the repo root the label is the branch; in a subdir, the dir
name. Non-repo dirs are left alone.
- `dir` — plain `basename $cwd` (lands in **General**) when no rule matches.
- Only tabs still named `Tab #N` are renamed — name a tab manually and it stays yours.
### Claude Code worktrees
To have `claude -w fix-auth` label its tab with the worktree name (`repo:fix-auth`), add a
`SessionStart` hook — `force` mode deliberately overrides the tab's current name:
```json
"SessionStart": [{ "hooks": [{ "type": "command", "timeout": 5,
"command": "(~/.config/zellij/vtabs-rename.sh force >/dev/null 2>&1 &)" }] }]
```
### Worktree launcher
`claude -w` names the worktree for you. To create one with **your own name**, in a new tab
grouped under the repo and already running Claude, use the `nw` / `rw` shell functions:
```bash
cp shell/vtabs-worktree.zsh ~/.config/zellij/vtabs-worktree.zsh
cp layouts/vtabs-claude.kdl ~/.config/zellij/layouts/vtabs-claude.kdl # a vtabs layout whose main pane runs `claude`
# in ~/.zshrc:
source ~/.config/zellij/vtabs-worktree.zsh
```
- `nw fix-auth` → worktree at `/.claude/worktrees/fix-auth` on a new branch `fix-auth`,
opened in a new tab named `:fix-auth` (so it lands in the repo's group) running Claude.
`nw fix-auth main` branches off `main`. Run it from anywhere inside the repo.
- `rw` (from inside a worktree tab) removes that worktree and closes the tab; `rw -f` forces
past uncommitted changes. It refuses to touch the main worktree.
## Configuration
Optional plugin config in the layout's `plugin` block (defaults shown):
```kdl
plugin location="file:~/.config/zellij/plugins/zellij-vtabs.wasm" {
separator ":" // tab-name group separator
waiting_icon "◆" // rendered yellow
completed_icon "✓" // rendered green
spinner "⣾⣽⣻⢿⡿⣟⣯⣷" // working animation: each char = one frame
// plus the autogroup_* keys described above
}
```
### Spinner

Compare animation candidates on the **[live preview page](https://otezz.github.io/zellij-vtabs/spinner.html)**
(every set spinning inside a mock sidebar row), copy the frames you like, and set them via
the `spinner` config key. Width-1 glyphs only — emoji frames are double-width and break
the sidebar's column math.
## Architecture notes (why it's built this way)
The one non-obvious design decision, learned the hard way:
**Zellij spawns one plugin instance per tab, and per-instance mutable state always diverges** —
broadcast CLI pipes and `pipe_message_to_plugin` don't reliably fan out to every instance, and
`Event::Visible` / `PaneUpdate.is_focused` aren't usable signals here (`is_focused` came through
as `None`). The *only* state every instance reads identically is the **tab name** (via
`TabUpdate`). So attention is encoded as a name suffix (` ⏳`/` ✅`) applied with `rename_tab`
(a global mutation), then parsed back out for display.
The working model, which avoids any set/clear race:
- **Set** marks a tab *only if it isn't the active tab* (you don't need a cue for what you're
looking at).
- **Clear** strips the marker from the active tab on every `TabUpdate` (switching to a tab makes
it active → it's "seen" → cleared).
Group order and collapse state follow the same "only global state survives" rule: they live
in a small file under the plugin's `/cache` mount (host side:
`~/.cache/zellij//plugin_cache/`), which Zellij keys by plugin *location* —
so every per-tab instance reads the same file. One file **per session** (named from
`ModeUpdate`'s `session_name`), because a single shared file would let each session's save
wipe the others' groups. Instances re-read the file on every `TabUpdate`, and only the
focused (visible) instance ever writes, so the sidebar you're looking at is always fresh.
Two build gotchas on modern Rust + Zellij:
1. **Pin `zellij-tile` exactly, at or below the oldest Zellij you target** (`= 0.44.2`). A
caret range grabs the newest patch, and a plugin built against a newer `zellij-tile`
than the running Zellij fails to load (`could not find exported function`). The reverse
direction is fine — an older-tile build runs on newer Zellij.
2. **Build as a binary crate, not `cdylib`.** On current Rust, a `cdylib` for `wasm32-wasip1`
emits a WASI *reactor* (no `_start`); Zellij needs a *command* (`_start`). A bin crate lets
`register_plugin!`'s own `main` become `_start`. (Don't add your own `fn main` — the macro
defines one.)
## Layout / status bar
`layouts/vtabs.kdl` puts the 28-col sidebar left of the main pane, with Zellij's single-line
`status-bar` (`size=1`) at the bottom to keep the key hints — matching the default layout's look.